Ad spy
Skill NovateStudioGit/novate-studio-skills/paid-growth/ad-spy
Pull live Meta ads from the Ad Library for a competitor brand — page-ID-resolved, DCO-unwrapped, ranked by longevity, classified by creative format. Triggered by /ad-spy <brand> or "pull ads for X", "ad library teardown".From its SKILL.md
npx -y skills add NovateStudioGit/novate-studio-skills --skill ad-spyAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
5.9 KB, ~1.6k tokens by cl100k_base, as published. Nobody here has run it
/ad-spy
Pull a competitor's live Meta / Facebook ads from the public Ad Library (via the Apify actor curious_coder/facebook-ads-library-scraper) and return a clean, ranked teardown. The official Meta Ad Library API only returns EU commercial ads, so scraping is required.
When to fire
/ad-spy <brand>- "pull ads for X"
- "ad library teardown for Y"
- "what's brand Z running on Meta right now"
- "show me brand A's active Facebook ads"
NOT for: owned-account performance (use METAAGENT performance.py), single-reel IG teardowns (/analyze-reel), site/offer/revenue recon (/gambit), or LinkedIn / lead-gen prospect lists (/linkedin-scraper, /lead-scraper).
Inputs
- brand (required) — display name OR a Meta Page ID (15+ digits → treated as page ID directly).
- country (default
US) — ISO code for the Ad Librarycountryfilter. - count (default
150) — max ads per page pull. Each page is hard-capped at 150 by the Ad Library; for advertisers with multiple pages, iterate page IDs.
Flow
- Resolve the page ID —
scripts/resolve_page_id.pydoes a keyword scrape, groups results bypage_name, picks the best name match, and returns(page_id, matched_name, confidence). If brand is already a numeric ID, skip resolution. Confidence ishigh(substring match),med(prefix overlap), orlow(no name overlap → flag in output). - Pull by page —
scripts/pull.pybuilds theview_all_page_id+search_type=pageAd Library URL and posts to the Apify actor. Saves raw JSON toout/<brand-slug>-<country>-page.json. Never pull by keyword for primary results — keyword returns mostly noise + mis-attributes generic names (Cadence → "Cadence OTC", Maple Diamonds → "Maple Cottage Designs", JAXXON → "JAXXON For Her"). - Parse —
scripts/parse.pywalks each ad and extracts:days_livefromstart_date→end_date(ornowif active)is_video—Trueifsnapshot.videosor anycards[].video_*_urlis set, else staticbody_text— DCO-unwrap: prefersnapshot.cards[].bodyand.titleoversnapshot.body(which is the{{product.brand}}template). Also pull fromextra_texts.angle— slug fromlink_url(e.g.brand.co/pages/<slug>→pcos,perimenopause,gut-health). Single richest persona signal.cta,display_format,collation_count,snapshot_url,page_name,ad_archive_id
- Rank + classify:
- Sort ads by
days_livedesc — long-runners = proven winners, 3-day clusters = active testing churn. - Group ads by
angle(landing-page slug) — count, median days, max days per angle. - Compute creative mix —
video %vsstatic %.
- Sort ads by
- Output:
- Raw JSON →
out/<brand-slug>-<country>-page.json - Parsed JSON →
out/<brand-slug>-<country>-parsed.json - Print a compact readout: total active ads, creative mix, angles table, top-20 longest-running, sample real (non-template) copy.
- Raw JSON →
Hard-won gotchas (DO NOT regress)
- Pull by page ID, not keyword. Keyword search returns ~60% relevant for unique brand names, far less for generic ones. Use
resolve_page_id.pyfirst. - 150-ad cap per page pull. For multi-page advertisers (e.g. Grüns runs two: 107585658730958 + 738568902671176), iterate. If
total_pulled >= 150, flagcapped: truein output and recommend paginating. - DCO copy lives in
snapshot.cards[].body/.title, NOTsnapshot.body.snapshot.bodyfor DCO ads is the literal template string{{product.brand}}. ~98% of Grüns ads were DCO/SHOP_NOW. - Video classification: check
snapshot.videosOR any ofvideo_preview_image_url/video_hd_url/video_sd_url/watermarked_video_sd_urlon cards. If none → static (cards or images). days_liveproxy:(end_date or now) - start_date) / 86400. Long-runners (60+ days) are the winning creatives; copy their angle/format.- Landing-page slug = persona/angle. Group by
link_urlslug; the slug names the avatar Meta optimised toward. resolve_page_id.pymis-PICKS on short/generic brand names even after grouping (e.g. "Ridge" → "Ridge Belts" med, "Ridge Wallet" → "Ridge Belts"; only "The Ridge" → correct page, high). When confidence ismed/lowOR the matched name looks wrong, readall_pages_seen— the dominant count is usually the real brand — and re-resolve with that exact page name (or pass--page-iddirectly). Some brands won't resolve at all via keyword scrape (e.g. "Our Place" / "From Our Place" → only ad-noise, never the brand page); substitute or get the page ID by hand rather than burning pulls.--quietis aparse.pyflag ONLY, NOTpull.py. Passing--quiettopull.pymakes argparse error out and the pull silently fails.pull.pyflags are--country --count --page-id --no-resolve --out-dir+ the brand positional. To pull without resolution, usepull.py "<name>" --page-id <id> --no-resolve.
Auth
Reads APIFY_API_TOKEN (or APIFY_TOKEN) and META_AD_LIBRARY_ACTOR from ~/Desktop/AGENTS/METAAGENT/.env. Never echo the token value.
Output structure
~/Desktop/AGENTS/METAAGENT/out/
<brand-slug>-<country>-page.json # raw Apify items
<brand-slug>-<country>-parsed.json # cleaned + ranked
Readout printed to stdout includes:
- Resolved page name + confidence flag
- Total active ads + cap status
- Creative mix (video vs static)
- Angles table (slug | count | median days | max days)
- Top-20 longest-running ads
- Sample non-template copy from the top runners
Downstream
Output is consumed by the (future) /ad-brief skill, which distils hook + angle + persona into a structured creative brief and writes it into the ExampleBrand Notion META tracker at Status=Brief.
What ships with it: 3 files
18.1 KB alongside SKILL.md, 3 of them executable
scripts/
- parse.pyruns7.6 KB
- pull.pyruns6.2 KB
- resolve_page_id.pyruns4.4 KB