# /spy

> If the user runs `/spy ACME Agency` and no `--keywords` are given, check if CLIENT.md has competitors.


# /spy — Competitor Ad Library Intelligence

## Triggers

- `/spy ACME Agency` — client mode, reads CLIENT.md competitors, DE country, client Slack/Drive
- `/spy ACME Agency --keywords "PKV Vergleich,PKV wechseln,private Krankenversicherung"` — client context + custom keywords
- `/spy --keywords "renson,warema" --countries HR` — pure keyword mode
- `/spy ACME Agency --all` — client mode, use ALL competitors (default caps at 20)
- `/spy ACME Agency --dry-run` — no Slack, no Drive, no saves — console output only

## What this skill does

1. Resolves search terms and context (client lookup + CLIENT.md parsing, or keywords)
2. Runs `spy_ad_library.mjs` — Playwright scrapes the public Meta Ad Library, takes 1 fullPage screenshot per term, uploads to Drive, outputs JSON
3. Reads each screenshot with the Read tool — Claude vision analysis
4. Synthesizes patterns across all ads
5. Creates a Google Doc report in Bosnian
6. Posts to Slack (client channel in client mode, `#your-channel` in keyword mode) with Google Doc link

---

## Step-by-Step Instructions

### Step 1 — Parse the argument

Check whether `--keywords` is present:
- `--client "X"` only → client mode (uses CLIENT.md competitors)
- `--client "X" --keywords "..."` → client+keywords mode (client context, custom search terms)
- `--keywords "..."` only → pure keyword mode (no client context)

If the user runs `/spy ACME Agency` and no `--keywords` are given, check if CLIENT.md has competitors. If none are found, ask the user to provide keywords and re-run with `--client "ACME Agency" --keywords "..."`.

For ACME Agency specifically: suggest keywords like `PKV Vergleich,PKV wechseln,private Krankenversicherung wechseln,günstige PKV`.

### Step 2 — Run the node script

```bash
# Client mode (has competitors in CLIENT.md)
node ACME Agency/scripts/spy_ad_library.mjs --client "ACME Agency" [--countries DE] [--all] [--dry-run]

# Client + keywords (no competitors defined, but want client context for Drive/Slack)
node ACME Agency/scripts/spy_ad_library.mjs --client "ACME Agency" --keywords "PKV Vergleich,PKV wechseln,private Krankenversicherung" --countries DE [--dry-run]

# Pure keyword mode
node ACME Agency/scripts/spy_ad_library.mjs --keywords "renson,warema" --countries HR
```

Parse stdout as JSON. Check for errors:
- If the script throws "No competitors found" → re-run with `--keywords`
- If `output.totalScreenshots === 0` → Playwright likely failed; report to user

The script automatically uploads screenshots to Drive (`Klijenti/<ClientName>/Spy/<YYYY-MM-DD>/`) if the client has a `drive_folder_id`. Each `searches[]` entry has both `screenshotPath` (local) and `screenshotDriveUrl` (Drive link).

### Step 3 — Understand the dataset

Key fields from the JSON:
- `output.isFirstRun` — true = establishing baseline, no comparison available
- `output.previousScanDate` — date of last scan (for diff context)
- `output.searches[]` — one entry per keyword: `{ term, screenshotPath, viewportPaths[], screenshotDriveUrl, viewportDriveUrls[], adCount }`
- `output.slackChannel` — post target (client channel or null for keyword mode)
- `output.driveFolder` — Drive folder ID where doc should be created

### Step 4 — Vision analysis

**Two-tier read per keyword:**

**Overview (fast, optional):** Read `screenshotPath` (full-page PNG) to get a landscape count and general layout sense. Do not try to read copy from this — ads are too small.

**Detail read (required):** Read each path in `viewportPaths[]` — 4 viewport-size slices (1280×900) per keyword. At this resolution each ad card renders at ~300–400px wide — copy is readable, imagery is visible.

For each visible ad card in the viewport slices, classify:
- **Page name / brand** — read from the ad card header
- **Hook text** — read the headline or first copy line (exact text if legible)
- **Hook type**: question / pain-point / transformation / offer / curiosity / social-proof / authority / urgency
- **Format**: single-image / video-thumbnail / carousel
- **Offer mechanism**: free-consultation / calculator / discount / guarantee / urgency / none
- **Visual style**: branded-corporate / lifestyle / UGC / product-only / stock-photo
- **Notable**: anything unusually strong, unique angle, high-quality production, or surprising claim

After reading all slices for a keyword, identify **2–3 standout winner ads**:
- Strongest hook (most specific, most compelling)
- Most unusual angle (something competitors aren't doing)
- Highest production quality or unique format

For each winner: note the page name, hook text (exact if legible), visual description, and which `viewportDriveUrls[]` index it appeared in.

For entries with `screenshotPath === null` and empty `viewportPaths`: skip vision, note as "scraping failed for this term".

Read all screenshots for all keywords first, then synthesize. Do not write the report term-by-term.

### Step 5 — Pattern synthesis

After reading all screenshots, identify across the full competitive landscape:

1. **Hook patterns** — what angles dominate? Which are saturated vs. absent?
2. **Offer patterns** — what offers appear most? What CTA types?
3. **Format distribution** — rough % video vs image vs carousel across all terms
4. **Key advertisers** — who is running consistently across multiple keywords? (= high spend, likely profitable)
5. **Messaging gaps** — what angles is nobody using that the client could own?
6. **Diff insight** — if `isFirstRun: false`, what changed vs. last scan?

Then compile the **winner ads list** for replication — 2–3 total across all keywords:

```javascript
winnerAds = [
  {
    keyword: "PKV Vergleich",
    pageName: "[exact page name from ad card]",
    hookText: "[exact headline/copy if readable]",
    hookType: "[type]",
    visualDescription: "[what the creative looks like — colors, imagery, layout]",
    whyItWorks: "[why this stands out vs. the rest of the market]",
    howToAdapt: "[specific suggestion for this client]",
    viewportDriveUrl: "[viewportDriveUrls[i] of the slice it appeared in]",
  },
  ...
]
```

### Step 6 — Create Google Doc (in Bosnian)

Use `createFormattedDoc()` from `ACME Agency/scripts/lib/google_docs.mjs`. Call it inline (Claude executes this, not a script).

```javascript
import { createFormattedDoc, getAccessToken } from './ACME Agency/scripts/lib/google_docs.mjs';
const token = await getAccessToken();
```

Target folder: `output.driveFolder` if set, otherwise skip Google Doc and note in Slack.

Doc title: `Spy analiza — [Client/keyword] — [YYYY-MM-DD]`

**Sections structure (in Bosnian):**

```javascript
const sections = [
  { type: 'title', text: `Spy analiza — ${client} — ${date}` },
  { type: 'subtitle', text: `Pojmovi: ${terms.join(', ')} | Zemlja: ${country} | Izvor: Meta Ad Library (javno)` },
  { type: 'divider' },

  { type: 'h1', text: 'Pregled' },
  { type: 'bullets', items: [
    `Broj traženih pojmova: ${terms.length}`,
    `Ukupno oglasa pronađeno: ~${totalAdCount}`,
    `Snimke ekrana: ${totalScreenshots} | Uploadovano na Drive: ${totalDriveUploads}`,
    isFirstRun ? 'Prvo skeniranje — uspostavlja baseline' : `Prethodno skeniranje: ${previousScanDate}`,
  ]},

  { type: 'h1', text: 'Ključni oglašivači' },
  { type: 'body', text: '...' },  // Who runs consistently across keywords

  { type: 'h1', text: 'Obrasci hookova' },
  { type: 'bullets', items: [...hook patterns with examples...] },

  { type: 'h1', text: 'Mehanizmi ponude' },
  { type: 'bullets', items: [...offer types found...] },

  { type: 'h1', text: 'Raspodjela formata' },
  { type: 'body', text: '~X% video / Y% slika / Z% karusel' },

  { type: 'h1', text: 'Praznine u poruci (šanse)' },
  { type: 'bullets', items: [...messaging gaps, what nobody is saying...] },

  { type: 'h1', text: 'Preporučeni testovi' },
  { type: 'bullets', items: [
    '1. [Specifičan test — format + hook + razlog]',
    '2. ...',
    '3. ...',
  ]},

  { type: 'h1', text: 'Reklamne reference za replikaciju' },
  { type: 'body', text: 'Oglasi koji se ističu na osnovu jačine hooka, jedinstvenosti kuta ili kvalitete kreative — preporučeni za adaptaciju.' },
  // For each winner ad:
  { type: 'h2', text: `${winnerAd.pageName} — "${winnerAd.hookText}"` },
  { type: 'bullets', items: [
    `Hook tip: ${winnerAd.hookType}`,
    `Vizual: ${winnerAd.visualDescription}`,
    `Zašto radi: ${winnerAd.whyItWorks}`,
    `Kako adaptirati: ${winnerAd.howToAdapt}`,
    winnerAd.viewportDriveUrl ? `Referentna snimka: ${winnerAd.viewportDriveUrl}` : null,
  ].filter(Boolean) },
  { type: 'spacer' },

  { type: 'h1', text: 'Detalji po pojmu' },
  // For each term:
  { type: 'h2', text: `"${term}" — ~${adCount} oglasa` },
  { type: 'body', text: '[Key observations from that search]' },
  screenshotDriveUrl ? { type: 'bullet', text: `Pregled (full-page): ${screenshotDriveUrl}` } : null,
  // Link each viewport slice:
  ...viewportDriveUrls.map((url, i) => url ? { type: 'bullet', text: `Detail view ${i + 1}: ${url}` } : null).filter(Boolean),
  { type: 'spacer' },
];
```

Get back `{ docId, docUrl }` from `createFormattedDoc()`.

### Step 7 — Post to Slack

Post to `output.slackChannel` (client mode) or `#your-channel` (`C18B7RUTD`) for keyword mode.

Follow the format standard at `.claude/skills/SLACK_REPORT_STANDARD.md` — no emojis, no humor, bold headers, links as `<url|label>`.

**Main message (in Bosnian):**
```
*Spy analiza — [Klijent] — [datum]*

*Pregled*
• [X] pojmova pretraženo — ~[Y] oglasa
• Zemlja: [X] | [Prvo skeniranje / Prethodno: datum]

*Dominantni hookovi*
• [Pattern 1] — primjer: "..."
• [Pattern 2]
• [Pattern 3]

*Ponuda i format*
• Mehanizmi: [offer summary]
• Format: ~[X]% video / [Y]% slika

*Praznine — što niko ne radi*
• [Gap 1]
• [Gap 2]

*Preporučeni testovi*
1. [Test 1]
2. [Test 2]
3. [Test 3]

*Oglasi za replikaciju*
• *[Page name]*: "[hook copy]" — [format] — [zašto je jak / šta adaptirati]
• *[Page name]*: "[hook copy]" — [format] — [...]
• *[Page name]*: "[hook copy]" — [format] — [...]

<[docUrl]|Kompletan izvještaj>
```

If Google Doc creation failed or `driveFolder` is null: omit the doc link line.

**Thread reply (competitor breakdown per page):**
```
*Pregled konkurenata*

*[Page Name]* — pojavljuje se u [X] pojmova
[1-line summary of their angle/approach]
...
```

If `--dry-run`: print both messages to console, skip posting.

### Step 8 — If client has no competitors (fallback)

If running `/spy SomeClient` and the script throws "No competitors found":
1. Read the client's CLIENT.md or CLAUDE.md to understand the business
2. Derive 3-5 relevant search terms from the business description
3. Tell the user: *"[Client] nema definiranih konkurenata. Koristit ću pojmove: [terms]. Ponovi s: `--keywords "[terms]"`"*
4. Re-run with `--client "[client]" --keywords "[derived terms]" [--countries XX]`

---

## Key Files

| File | Purpose |
|------|---------|
| `ACME Agency/scripts/spy_ad_library.mjs` | Script: Playwright scrape, 1 fullPage screenshot/term, Drive upload, JSON output |
| `ACME Agency/scripts/lib/google_docs.mjs` | `createFormattedDoc(token, { folderId, title, sections })` → `{ docId, docUrl }` |
| `ACME Agency/scripts/lib/google_drive.mjs` | `uploadFile`, `findOrCreateFolder`, `driveViewUrl` (used inside the script) |
| `ACME Agency/scripts/lib/slack.mjs` | `postMessage`, `getClientChannel` |
| `ACME Agency/clients/clients.json` | `drive_folder_id`, `slack_channel`, `country`, `competitors` per client |
| `ACME Agency/clients/<folder>/CLIENT.md` | `## Competitors` section → search terms |
| `ACME Agency/clients/<folder>/spy/spy-last.json` | Previous scan for diff |

---

## Script CLI Reference

```
--client "Name"       client mode — reads competitors from CLIENT.md + clients.json
--keywords "a,b,c"    keyword terms (overrides competitors OR used for pure keyword mode)
--countries "DE,AT"   country codes (default: from client.country, or "HR")
--limit 15            ads per term (unused in Playwright mode, kept for compatibility)
--all                 use ALL competitors from CLIENT.md (default: top 20)
--no-screenshots      skip Playwright — return empty searches array
--no-drive            skip Drive upload even if driveFolder is set
--dry-run             no Slack, no Drive, no file saves — stdout + console only
--force               ignore spy-last.json, treat as first run
```

---

## Rules

- **Language: Bosnian by default** — all Slack messages and Google Doc content in Bosnian unless user specifies otherwise
- **Screenshot approach**: 1 fullPage PNG per keyword — captures all visible ad cards at once
- **Drive upload**: automatic in the script when `driveFolder` is available. Don't re-upload in Claude steps
- **Google Doc**: always create when `driveFolder` is available. If creation fails, continue and note the failure in Slack
- **No competitors found**: gracefully suggest keywords from business context — don't crash
- **Playwright failures**: `screenshotPath: null` = scrape failed for that term — skip vision, note in report
- **First run**: `isFirstRun: true` — frame as "establishing baseline", no diff section

---

## Output Examples

**Client mode output paths:**
```
ACME Agency/clients/ACME Agency/spy/spy-2026-03-28.json
ACME Agency/clients/ACME Agency/spy/screenshots/search-0-renson.png
→ Drive: Klijenti/ACME Agency/Spy/2026-03-28/search-0-renson.png
→ Google Doc: drive.google.com/document/d/...
```

**Keyword mode output paths:**
```
ACME Agency/spy/pkv-vergleich/spy-2026-03-28.json
ACME Agency/spy/pkv-vergleich/screenshots/<id>.png
→ Drive: skipped (no driveFolder)
→ Google Doc: skipped (no driveFolder)
```
