PORTAL / LIBRARY / spy

[ PAID MEDIA ]

/spy

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

Download the skill file (.md)

Placeholders like ACME Agency, <id> and you@example.com mark values that are per-agency — your install fills them with YOUR clients and accounts. If a section references a helper script you don't have yet, it ships with that workflow's install.

/spy — Competitor Ad Library Intelligence

Triggers

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:

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

# 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:

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:

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:

After reading all slices for a keyword, identify 2–3 standout winner ads:

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:

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).

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):

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

FilePurpose
ACME Agency/scripts/spy_ad_library.mjsScript: Playwright scrape, 1 fullPage screenshot/term, Drive upload, JSON output
ACME Agency/scripts/lib/google_docs.mjscreateFormattedDoc(token, { folderId, title, sections }){ docId, docUrl }
ACME Agency/scripts/lib/google_drive.mjsuploadFile, findOrCreateFolder, driveViewUrl (used inside the script)
ACME Agency/scripts/lib/slack.mjspostMessage, getClientChannel
ACME Agency/clients/clients.jsondrive_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.jsonPrevious 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


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)