# /ig-channel — Instagram Channel Deep-Dive Skill

> Turn any IG profile into a queryable AI mentor.
> Drop the files into your repo. Run `/ig-channel @handle`.
> Claude Code handles the rest.

---

## What this skill does

You give it an Instagram profile URL or handle. It:

1. Opens a real Chrome session (Playwright + your dedicated profile)
2. Scrapes the last N reels from that creator's `/reels/` tab (default 25)
3. Sends each reel URL to a Relevance AI tool that wraps Apify's `instagram-reel-scraper` actor and returns the full transcript + caption + engagement stats
4. Saves every transcript to disk so re-runs of the same channel cost $0
5. Has Claude read all transcripts and synthesize a strategic audit:
   - Content pillars (% share + which reels prove each one)
   - Recurring hooks (with frequency counts)
   - Format patterns (length, shot type, on-screen text, CTAs)
   - "What's working" — the patterns driving the top-quartile reels
   - A "steal-this" list of concrete derivative content
6. Renders an HTML report you can open AND a queryable JSON corpus you can chat with

**Cost:** ~$0.90 per channel (25 reels × ~3.66 Relevance credits) · ~10 min runtime.

---

## Directory structure

Drop these into your repo:

```
your-repo/
├── .claude/
│   └── commands/
│       └── ig-channel.md             ← FILE 4 (slash command)
└── intelligence/
    ├── scripts/
    │   └── browser.ts                ← FILE 1 (Playwright launcher)
    └── instagram/
        ├── scrape-channel.ts         ← FILE 2 (profile scraper)
        └── generate-channel-report.ts ← FILE 3 (HTML renderer)
```

You also need a Relevance AI tool that wraps Apify's `instagram-reel-scraper` (instructions below — clone ours or build it in 5 minutes).

---

## One-time setup

### 1. Install dependencies

```bash
npm install playwright
npx playwright install chromium
npm install -D tsx typescript @types/node
```

`package.json`:

```json
{
  "private": true,
  "type": "module",
  "scripts": {
    "scrape": "npx tsx intelligence/instagram/scrape-channel.ts",
    "report": "npx tsx intelligence/instagram/generate-channel-report.ts"
  },
  "dependencies": {
    "playwright": "^1.50.0"
  },
  "devDependencies": {
    "tsx": "^4.0.0",
    "typescript": "^5.0.0",
    "@types/node": "^22.0.0"
  }
}
```

`tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "esModuleInterop": true,
    "strict": true,
    "outDir": "dist",
    "rootDir": "."
  },
  "include": ["intelligence/**/*.ts"]
}
```

### 2. Create a dedicated Chrome profile

All scraping uses a separate Chrome profile so your personal Chrome stays clean. No extensions, no automation banner — Instagram can't tell it's automated.

```bash
# macOS
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --user-data-dir="$HOME/.claude-browser" \
  --remote-debugging-port=9222 \
  --no-first-run --no-default-browser-check

# Linux
google-chrome --user-data-dir="$HOME/.claude-browser" --remote-debugging-port=9222 --no-first-run

# Windows (PowerShell)
& "C:\Program Files\Google\Chrome\Application\chrome.exe" `
  --user-data-dir="$HOME\.claude-browser" --remote-debugging-port=9222 --no-first-run
```

A bare Chrome window opens. **Log into Instagram once.** Cookies persist across runs.

### 3. Pick a transcript engine

The slash command needs a way to take an Instagram reel URL and return its transcript + metadata. **Pick one of two options** — both work; pick the one that matches the stack you already have.

#### Option A — Clone the Relevance AI tool (fastest if you have a Relevance account)

**Direct clone link** (opens the studio in your Relevance workspace):
👉 **https://app.relevanceai.com/notebook/bcbe5a/a3cd1b1efb8c-4da4-8728-81a8bc7333ca/6e929269-2bcc-4079-aa50-195d5e479849**

Click → **Clone** → it lands in your own project with a new studio ID. The tool:

- **Name:** Instagram Reel Transcript Scraper · 🎬
- **Input:** `{ "reel_url": "https://www.instagram.com/reel/ABC123/" }`
- **Output:** `{ transcript, caption, hashtags, owner_username, likes, comments_count, views, play_count, duration_seconds, reel_url }`
- **Under the hood:** wraps Apify's `apify/instagram-reel-scraper` actor with `includeTranscript: true`, then a small Python step normalizes the response.
- **Cost:** ~3.66 Relevance credits per reel (~$0.036 each).

Connect your Relevance MCP server to Claude Code so Claude can call the tool directly via `relevance_run_tool` with your new studio ID. Done.

#### Option B — Skip Relevance entirely, call Apify directly (no Relevance account needed)

If you don't want Relevance in your stack at all, write a tiny Node helper that hits Apify directly. This is what the Relevance tool does internally — you're just removing the middleman.

1. **Get an Apify token:** sign up at apify.com → Settings → Integrations → API tokens.
2. **Set the env var:** `export APIFY_TOKEN=apify_api_xxx...`
3. **Install the client:** `npm install apify-client`
4. **Add this file** at `intelligence/instagram/transcribe-via-apify.ts`:

```typescript
// intelligence/instagram/transcribe-via-apify.ts
//
// Direct Apify path — drop-in replacement for the Relevance tool.
// Returns the same shape as the Relevance "Instagram Reel Transcript Scraper".
//
// Usage from another script:
//   import { transcribeReel } from "./transcribe-via-apify.js";
//   const t = await transcribeReel("https://www.instagram.com/reel/ABC123/");
//
// CLI usage:
//   npx tsx intelligence/instagram/transcribe-via-apify.ts <reel_url>

import { ApifyClient } from "apify-client";

const TOKEN = process.env.APIFY_TOKEN;
if (!TOKEN) {
  console.error("Missing APIFY_TOKEN env var. Get one at apify.com → Settings → Integrations.");
  process.exit(1);
}

const client = new ApifyClient({ token: TOKEN });

export interface TranscriptResult {
  transcript: string;
  caption: string;
  hashtags: string[];
  mentions: string[];
  owner_username: string;
  likes: number;
  comments_count: number;
  views: number;
  play_count: number;
  duration_seconds: number;
  reel_url: string;
  error?: string;
}

export async function transcribeReel(reelUrl: string): Promise<TranscriptResult> {
  const run = await client.actor("apify/instagram-reel-scraper").call({
    username: [reelUrl],
    includeTranscript: true,
    resultsLimit: 1,
  });

  const { items } = await client.dataset(run.defaultDatasetId).listItems();
  const reel: any = items[0];

  if (!reel) {
    return {
      transcript: "",
      caption: "",
      hashtags: [],
      mentions: [],
      owner_username: "",
      likes: 0,
      comments_count: 0,
      views: 0,
      play_count: 0,
      duration_seconds: 0,
      reel_url: reelUrl,
      error: "No reel data returned",
    };
  }

  return {
    transcript: reel.transcript || "No transcript available for this reel.",
    caption: reel.caption || "",
    hashtags: reel.hashtags || [],
    mentions: reel.mentions || [],
    owner_username: reel.ownerUsername || "",
    likes: reel.likesCount || 0,
    comments_count: reel.commentsCount || 0,
    views: reel.videoViewCount || 0,
    play_count: reel.videoPlayCount || 0,
    duration_seconds: reel.videoDuration || 0,
    reel_url: reel.url || reelUrl,
  };
}

// CLI entrypoint — useful for testing or for Claude Code to shell out to it
if (import.meta.url === `file://${process.argv[1]}`) {
  const url = process.argv[2];
  if (!url) {
    console.error("Usage: tsx transcribe-via-apify.ts <reel_url>");
    process.exit(1);
  }
  transcribeReel(url)
    .then((r) => console.log(JSON.stringify(r, null, 2)))
    .catch((e) => {
      console.error(e);
      process.exit(1);
    });
}
```

5. **Update the slash command** to call this helper instead of the Relevance tool. In the `.claude/commands/ig-channel.md` Step 2, replace the Relevance call with:

   > For each reel, run `npx tsx intelligence/instagram/transcribe-via-apify.ts "<reel_url>"` (or import `transcribeReel` directly into a batch script). Save the JSON output straight to `intelligence/instagram/channels/{username}/transcripts/{reel_id}.json` exactly like the Relevance path.

Costs are similar (~$0.05–0.10 per reel via Apify directly, ~$0.90–$1.25 per channel). Tradeoff: you skip Relevance + MCP plumbing entirely, but lose the Relevance dashboard + MCP-driven retries.

---

## FILE 1 — `intelligence/scripts/browser.ts`

Connect-first / spawn-as-fallback Playwright launcher. Doesn't kill Chrome on close (so your IG session persists across runs).

```typescript
// intelligence/scripts/browser.ts
import { chromium, type Browser } from "playwright";
import { spawn, type ChildProcess } from "child_process";
import { platform } from "os";

const CHROME_PATHS: Record<string, string> = {
  darwin: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
  win32: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
  linux: "google-chrome",
};

const HOME = process.env.HOME || process.env.USERPROFILE || "~";
const PROFILE_DIR = `${HOME}/.claude-browser`;
const DEBUG_PORT = 9222;
const CDP_URL = `http://127.0.0.1:${DEBUG_PORT}`;

export async function launchBrowser(): Promise<{ browser: Browser; chrome: ChildProcess | null }> {
  // Try connecting to already-running Chrome first
  try {
    const browser = await chromium.connectOverCDP(CDP_URL);
    console.log("Connected to existing Chrome on port 9222.");
    return { browser, chrome: null };
  } catch {}

  // Otherwise spawn Chrome with the dedicated profile
  console.log("No Chrome on port 9222. Launching with dedicated profile...");
  const chromePath = CHROME_PATHS[platform()] || "google-chrome";

  const chrome = spawn(
    chromePath,
    [
      `--user-data-dir=${PROFILE_DIR}`,
      `--remote-debugging-port=${DEBUG_PORT}`,
      "--no-first-run",
      "--no-default-browser-check",
      "--window-size=1280,900",
    ],
    { stdio: "ignore", detached: true }
  );
  chrome.unref();

  let browser: Browser | undefined;
  for (let attempt = 0; attempt < 20; attempt++) {
    await new Promise((r) => setTimeout(r, 1000));
    try {
      browser = await chromium.connectOverCDP(CDP_URL);
      console.log("Chrome launched and connected.");
      break;
    } catch {
      if (attempt === 19) {
        throw new Error(
          `Could not connect to Chrome after 20s.\nLaunch Chrome manually:\n  "${chromePath}" --user-data-dir="${PROFILE_DIR}" --remote-debugging-port=${DEBUG_PORT} --no-first-run\nThen log into Instagram and re-run.`
        );
      }
    }
  }

  return { browser: browser!, chrome };
}

export async function closeBrowser(browser: Browser, _chrome?: ChildProcess | null) {
  try {
    await browser.close();
  } catch {}
  // Do NOT kill Chrome — keep IG session alive for next run.
}
```

---

## FILE 2 — `intelligence/instagram/scrape-channel.ts`

Scrape the last N reels from a specific Instagram profile.

```typescript
// intelligence/instagram/scrape-channel.ts
// Usage: npx tsx scrape-channel.ts <username|profile-url> [count=25]

import { launchBrowser, closeBrowser } from "../scripts/browser.js";
import { mkdirSync, writeFileSync } from "fs";
import { join, dirname } from "path";
import { fileURLToPath } from "url";

const __dirname = dirname(fileURLToPath(import.meta.url));

const rawArg = process.argv[2];
const count = parseInt(process.argv[3] || "25");

if (!rawArg) {
  console.error("Usage: scrape-channel.ts <username|profile-url> [count=25]");
  process.exit(1);
}

function parseUsername(input: string): string {
  const cleaned = input.trim();
  const urlMatch = cleaned.match(/instagram\.com\/([\w._]+)/i);
  if (urlMatch) return urlMatch[1].toLowerCase();
  return cleaned.replace(/^@/, "").replace(/\/.*$/, "").toLowerCase();
}

const username = parseUsername(rawArg);
if (!username) {
  console.error(`Could not parse username from: ${rawArg}`);
  process.exit(1);
}

interface ChannelReel {
  url: string;
  reel_id: string;
  thumbnail?: string;
  view_count_text?: string;
}

async function scrapeChannel() {
  const { browser, chrome } = await launchBrowser();
  const context = browser.contexts()[0];
  const page = await context.newPage();
  page.on("dialog", (d) => d.dismiss().catch(() => {}));

  const profileUrl = `https://www.instagram.com/${username}/reels/`;
  console.log(`Scraping @${username} — target ${count} reels`);
  await page.goto(profileUrl, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
  await page.waitForTimeout(3500 + Math.random() * 2000);

  if (page.url().includes("/accounts/login")) {
    console.error("FAILED: Not logged in to Instagram. Open Chrome with the dedicated profile and log in.");
    await closeBrowser(browser, chrome);
    process.exit(1);
  }

  const pageStatus = await page.evaluate(`(() => {
    const text = document.body.innerText || '';
    return {
      notFound: text.includes("Sorry, this page isn't available") || text.includes('User not found'),
      hasReels: !!document.querySelector('a[href*="/reel/"]'),
    };
  })()`) as { notFound: boolean; hasReels: boolean };

  if (pageStatus.notFound) {
    console.error(`FAILED: Profile @${username} not found.`);
    await closeBrowser(browser, chrome);
    process.exit(1);
  }

  console.log("Profile loaded. Scrolling to collect reels...\n");

  const collected: Map<string, ChannelReel> = new Map();
  let lastCount = 0;
  let staleScrolls = 0;
  let scrollIdx = 0;
  const maxScrolls = Math.max(40, count * 2);

  while (collected.size < count && scrollIdx < maxScrolls) {
    const reels = await page.evaluate(`(() => {
      const out = [];
      const anchors = document.querySelectorAll('a[href*="/reel/"]');
      for (const a of anchors) {
        const href = a.getAttribute('href') || '';
        const m = href.match(/\\/reel\\/([\\w-]+)/);
        if (!m) continue;
        const id = m[1];
        const img = a.querySelector('img');
        const thumb = img ? (img.getAttribute('src') || '') : '';
        const tileText = (a.textContent || '').trim().split('\\n').map(s => s.trim()).filter(Boolean);
        let viewText = '';
        for (const t of tileText) {
          if (/^[\\d.,]+\\s*[KMB]?$/.test(t)) { viewText = t; break; }
        }
        out.push({ id, thumb, viewText });
      }
      return out;
    })()`) as Array<{ id: string; thumb: string; viewText: string }>;

    for (const r of reels) {
      if (!collected.has(r.id)) {
        collected.set(r.id, {
          url: `https://www.instagram.com/reel/${r.id}/`,
          reel_id: r.id,
          thumbnail: r.thumb || undefined,
          view_count_text: r.viewText || undefined,
        });
      }
      if (collected.size >= count) break;
    }

    if (collected.size === lastCount) {
      staleScrolls++;
      if (staleScrolls >= 4) break;
    } else {
      staleScrolls = 0;
      lastCount = collected.size;
    }

    console.log(`  [${collected.size}/${count}] scroll ${scrollIdx + 1}…`);
    if (collected.size >= count) break;

    await page.evaluate(`window.scrollBy(0, window.innerHeight * (0.85 + Math.random() * 0.4))`);
    await page.waitForTimeout(1400 + Math.random() * 1400);
    scrollIdx++;
  }

  const reels = Array.from(collected.values()).slice(0, count);
  console.log(`\nCollected ${reels.length} reels from @${username}.`);

  const outDir = join(__dirname, "channels", username);
  mkdirSync(outDir, { recursive: true });
  mkdirSync(join(outDir, "transcripts"), { recursive: true });

  const date = new Date().toISOString().slice(0, 10);
  const outPath = join(outDir, `reels-${date}.json`);
  writeFileSync(
    outPath,
    JSON.stringify(
      {
        username,
        profile_url: `https://www.instagram.com/${username}/`,
        scraped_at: new Date().toISOString(),
        requested: count,
        total: reels.length,
        reels,
      },
      null,
      2
    )
  );

  console.log(`Saved to ${outPath}`);
  await closeBrowser(browser, chrome);
}

scrapeChannel().catch((err) => {
  console.error("Error:", err.message);
  process.exit(1);
});
```

---

## FILE 3 — `intelligence/instagram/generate-channel-report.ts`

Renders the per-channel `analysis-{date}.json` (which Claude writes in step 4 of the slash command) into a polished HTML report. **Read the version in the SoloStack repo for the latest design** — it's a long file (~600 lines of inline HTML/CSS) but it's a single self-contained script. Drop it into `intelligence/instagram/` alongside `scrape-channel.ts`.

You can find a clean reference implementation here:
👉 `departments/intelligence/instagram/generate-channel-report.ts`

The key contract:

- Reads `intelligence/instagram/channels/{username}/analysis-{date}.json`
- Outputs `intelligence/instagram/channels/{username}/report-{date}.html`
- Run with `npx tsx generate-channel-report.ts <username> [--open]`

If you want to skip this and let Claude render directly into the chat, just have Claude read the analysis JSON and answer questions interactively — no HTML report needed for the chat-with-it flow.

---

## FILE 4 — `.claude/commands/ig-channel.md` (the slash command)

This is the playbook Claude Code follows when you type `/ig-channel @handle`. Save it at `.claude/commands/ig-channel.md` in your repo:

```markdown
# Instagram Channel Deep Dive

Scrape the last N reels from a specific Instagram channel, transcribe each via the Relevance reel-scraper, save every transcript to disk, then produce a strategic content analysis (pillars, recurring hooks, format patterns, what's working, what to steal) as both JSON and a visual HTML report.

## Args
- Required: Instagram URL or handle (e.g. `@gregisenberg`, `instagram.com/gregisenberg`, or full URL with params)
- Optional: reel count (default: 25)

## Storage layout
Everything for a given channel lives under `intelligence/instagram/channels/{username}/`:
  - `reels-YYYY-MM-DD.json` — scraped URL manifest
  - `transcripts/{reel_id}.json` — one cached Relevance response per reel
  - `analysis-YYYY-MM-DD.json` — Claude's strategic synthesis (source of truth)
  - `report-YYYY-MM-DD.html` — visual report

Transcripts persist across runs — re-running on the same channel reuses cached files instead of re-billing Relevance.

## Steps

### 1. Scrape the channel's reels grid
Parse the username from $ARGUMENTS. Run:
  `npx tsx intelligence/instagram/scrape-channel.ts "<username>" <count>`
If it fails with "Not logged in", tell the user to open Chrome with the `~/.claude-browser` profile and log into Instagram.

### 2. Transcribe each reel via Relevance (with caching)
For each reel in the manifest:
  1. If `intelligence/instagram/channels/{username}/transcripts/{reel_id}.json` already exists, load it and skip the API call.
  2. Otherwise call Relevance AI tool `6e929269-2bcc-4079-aa50-195d5e479849` (or your cloned tool ID) with `{ "reel_url": "<url>" }`.
  3. Call SEQUENTIALLY (parallel calls cause 504 timeouts).
  4. Write the full Relevance response immediately to `transcripts/{reel_id}.json`.
If a reel returns an error/no transcript, save a stub with `error: "..."` so we don't retry on every run.

### 3. Per-reel intelligence
For each reel that has a transcript, write:
  - `hook` — opening line/hook
  - `key_topics` — array of SPECIFIC angles (not "AI" → "Using subagents to draft 4 cold emails in parallel")
  - `format` — short label ("talking-head selfie", "screen recording", "split-screen demo", etc.)
  - `cta` — verbatim CTA if present
  - `steal_this` — concrete derivative: what we'd build/film/post as a remix

### 4. Channel-level synthesis
Write top-level fields:
  - `positioning` (1 sentence)
  - `summary` (4-7 sentences)
  - `audience` (1 sentence)
  - `content_pillars` — 3-5 pillars with `{ name, share, description, reel_ids }`
  - `recurring_hooks` — 3-6 reusable hook templates
  - `format_patterns` — 3-6 production patterns
  - `cta_patterns` — 2-4 CTA patterns
  - `recurring_topics` — array of `{ topic, count }` for topics in 2+ reels
  - `posting_cadence` (1 sentence)
  - `what_works` — 3-6 specific causes behind top-quartile reels
  - `what_to_steal` — 3-6 concrete actions for OUR brand

Save to `intelligence/instagram/channels/{username}/analysis-YYYY-MM-DD.json`.

### 5. Render the HTML report (optional)
  `npx tsx intelligence/instagram/generate-channel-report.ts "<username>" --open`

### 6. Chat with the corpus
Once the analysis is on disk, the user can ask follow-up questions ("what hook does X use most?", "write a script in their voice for Y", "what's the structure of their top 3 reels?"). You answer by reading `analysis-YYYY-MM-DD.json` + the relevant `transcripts/{reel_id}.json` files. Always cite the reel ID.

### Done
Summarize: handle, # reels analyzed, total/avg views, 1-line positioning, the 3-5 pillars, top 3 things to steal, paths to the report + analysis JSON + transcripts dir.
```

---

## How to use it

```bash
# Open Chrome with the dedicated profile (one time, then leave it running)
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
  --user-data-dir="$HOME/.claude-browser" \
  --remote-debugging-port=9222 --no-first-run

# Log into Instagram in that window. Once.

# In Claude Code, run:
/ig-channel @gregisenberg
/ig-channel https://www.instagram.com/nathan.perdriau 50
/ig-channel @sabrina_ramonov 30
```

After it finishes, ask follow-up questions — Claude has all 25 transcripts on disk:

- *"What hook does Greg use most often?"*
- *"Write a 90-second talking-head script in Greg's voice about [my topic]."*
- *"What's the structure of his 3 highest-view reels?"*
- *"Find every reel where he mentions [tool/topic] and summarize the takeaway."*
- *"Build me a content calendar with 10 reel ideas modeled on his proven formats."*

---

## Cost breakdown

| Item | Cost |
|---|---|
| Playwright + Chrome profile | $0 (your local machine) |
| Apify (under Relevance) — 25 reels | ~$0.05–0.10 per channel |
| Relevance credits — 25 × ~3.66 = ~91 credits | ~$0.80–0.90 per channel |
| Claude Code orchestration | inside your existing plan |
| **Total per channel** | **~$0.90** |

Re-runs on the same channel are FREE because transcripts are cached. So building a 10-creator knowledge base costs ~$9 — and it compounds every time you add a new creator.

---

## Use cases

1. **Competitor audit** — drop a competitor's handle, get a structured breakdown of their strategy
2. **Niche onboarding** — reverse-engineer 5 top creators in a new niche in a single afternoon
3. **Voice-cloned scripts** — Claude has the exact transcripts; ask it to write in their voice
4. **Lead-magnet pattern mining** — extract every "Comment X to get Y" CTA across creators
5. **Skill-stack research** — see what tools/frameworks creators in your space actually teach
6. **Repurposement seeds** — every "steal-this" is a content brief you can build a carousel from
7. **Content calendar planning** — let Claude propose a 30-day calendar based on the topic mix that's working
8. **Hooks library** — run on 10 creators → query the combined corpus → ranked hooks library

---

## Troubleshooting

| Issue | Fix |
|---|---|
| "Not logged in to Instagram" | Open the dedicated Chrome profile, log into Instagram, re-run |
| 504 timeout on Relevance call | You're calling in parallel — slash command says SEQUENTIAL for a reason |
| Empty transcript for a reel | Some reels have no audio or are images — the slow-path stub avoids retrying |
| Profile has fewer than 25 reels | Scraper exits early once it stalls (4 stale scrolls = end of profile) |
| Chrome won't connect on port 9222 | Another Chrome is using `~/.claude-browser` — close it first |

---

## What's next

Pair this with `/ig-scroll` (daily reels feed digest) so you have:

- **Daily intel:** what the algorithm is serving you (`/ig-scroll`)
- **On-demand audits:** deep-dive any creator at any time (`/ig-channel`)
- **Long-term knowledge base:** every transcript persists — your private creator-knowledge corpus that compounds

Both skills share the same `~/.claude-browser` profile and the same Relevance AI transcript tool. Build once, use everywhere.

— Built by [SoloStack](https://trysolostack.com) · The repo is the company.
