[ PAID MEDIA ]
/spy
If the user runs `/spy ACME Agency` and no `--keywords` are given, check if CLIENT.md has competitors.
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
/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
- Resolves search terms and context (client lookup + CLIENT.md parsing, or keywords)
- Runs
spy_ad_library.mjs— Playwright scrapes the public Meta Ad Library, takes 1 fullPage screenshot per term, uploads to Drive, outputs JSON - Reads each screenshot with the Read tool — Claude vision analysis
- Synthesizes patterns across all ads
- Creates a Google Doc report in Bosnian
- Posts to Slack (client channel in client mode,
#your-channelin 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
# 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 availableoutput.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:
- Hook patterns — what angles dominate? Which are saturated vs. absent?
- Offer patterns — what offers appear most? What CTA types?
- Format distribution — rough % video vs image vs carousel across all terms
- Key advertisers — who is running consistently across multiple keywords? (= high spend, likely profitable)
- Messaging gaps — what angles is nobody using that the client could own?
- 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":
- Read the client's CLIENT.md or CLAUDE.md to understand the business
- Derive 3-5 relevant search terms from the business description
- Tell the user: "[Client] nema definiranih konkurenata. Koristit ću pojmove: [terms]. Ponovi s:
--keywords "[terms]"" - 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
driveFolderis available. Don't re-upload in Claude steps - Google Doc: always create when
driveFolderis 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)