| name | spy-competitor-ads |
| description | Find and download competitor ads from the Meta Ad Library (video, image, or both). Invoke with /arcads:spy-competitor-ads or via another skill. |
Spy Competitor Ads — Find & Download
Your only job: find competitor ads and download them. Nothing else. Execute the whole pipeline silently and the user's first output is the downloaded creatives themselves.
Media type — pick exactly ONE of three modes. NO default.
| Mode | Explicit trigger phrases | media_type param | Extractor | Downloads |
|---|
| VIDEO | "video ads", "competitor videos", "video creatives", "their videos" | video | <video> only | .mp4 |
| IMAGE | "static ads", "image ads", "photo ads", "still creatives", "posters", "image creatives" | image | <img> only (min 200px) | .jpg / .png |
| BOTH | "both", "video and image", "videos and statics", "all formats", "everything", "any media", "full swipe file" | all | <video> + <img> in one pass | .mp4 + .jpg / .png |
If — and only if — the user's request does not contain an explicit trigger for one of the three modes, ask with AskUserQuestion before doing anything else (see Step 1b). Do NOT silently default to video. The chosen mode then flows through the whole skill: URL → in-page extractor → file extensions → delivery list. Pick once and stick with it for every competitor in the run.
Golden rules
- Silent execution. No narration, no logging. Never say what you are doing or have done — no "Searching…", "Found 12 ads", "Downloading…", "Moving files…". No status lines, no step commentary. The user sees only the final downloaded creatives.
- Up to three forms, and only these three. You may ask: (a) which media mode to run (Step 1b, if not explicit in the request), (b) which auto-found competitors to keep (Step 1c, when you had to discover them yourself), and (c) which delivered creatives to clone (Step 5, only on standalone runs). All use
AskUserQuestion. Beyond these three, no other questions, confirmations, or "proceed?" check-ins. Between forms, go fully autonomous.
- No analysis. Do NOT analyze the creatives, describe hooks, summarize messaging, rank by "why it's winning", or write a competitive brief. Do not profile the competitors. Just find and download.
- Auto-find competitors when not given — then confirm. If the user names competitors, use them as-is, no confirmation. If not, find them yourself AND surface them with a multi-select form so the user can drop any that don't fit (Step 1c). Never silently scrape against a list the user never saw.
- No technical leakage. Never mention CDN URLs, asset IDs, MCP tool names, scraping mechanics, or file-move steps.
- One mode per run (VIDEO, IMAGE, or BOTH — see the mode table above). Skip carousels in every mode.
- Browser MCP is required. The Meta Ad Library is JavaScript-rendered. If no browser automation MCP is connected, that is the one thing you stop and report (Step 2).
Step 1 — Resolve the brand, mode, and competitors
You run up to three short setup interactions here, in this exact order, and only the ones whose answer isn't already in the request. Once these are settled, go autonomous.
1a — Brand
- Brand named or obvious from context? → use it.
- Not inferable? → ask exactly one short question: "What's your brand or product?" Nothing else.
1b — Media mode (NO default — ask if missing)
Read the user's request. If it contains an explicit phrase from the mode-trigger table (VIDEO / IMAGE / BOTH), use that and skip this step.
If the request is silent on media type, ask with AskUserQuestion:
Question: "Which kind of competitor ads should I grab?"
Header: "Media type"
Options:
- Video ads — "Playable video creatives only (.mp4)"
- Static / image ads — "Static image creatives only (.jpg / .png)"
- Both video and image — "Everything they're running, in one pass (Recommended)"
Wait for the answer before doing anything else. Do not default to video. Do not scrape without an explicit choice.
1c — Competitor shortlist
-
Competitors named by the user? → use them as-is. Skip this confirmation entirely.
-
Not named? → find 3–5 direct competitors yourself with a quick WebSearch/WebFetch (brands selling a similar product to a similar audience that plausibly run paid social). Then confirm with AskUserQuestion:
Question: "Found these competitors for [user's brand]. Pick the ones to spy on — keep the relevant ones, drop the rest."
Header: "Competitors"
Multiple: true
Options: one per discovered competitor, with a one-line label like "BrandX — meal kits", "BrandY — recipe app", etc. Each description explains why it was matched.
The form's custom option lets the user type any competitor you missed. Run the scrape against the final selection (kept + added). If the user selects zero, ask once for a manual list, then stop.
Never silently scrape against an auto-found list the user never saw.
Count
Default count: top 5 ads pooled across the final competitor selection. If the user specified a number ("2 ads", "one each"), honor it exactly.
Step 2 — Browser MCP check
Confirm a browser automation MCP is connected (e.g. mcp__Claude_in_Chrome__*, Playwright, Chrome DevTools, Puppeteer). Verify with list_connected_browsers or equivalent.
- Connected → proceed silently.
- Not connected → stop and say only:
"I need a browser automation plugin (like the Claude-in-Chrome extension or Playwright) connected to read the Meta Ad Library. Connect one and I'll grab the ads."
Do not try to work around this with WebFetch.
Step 3 — Scrape + download in one pass per competitor
Run competitors sequentially (parallel sessions trigger bot detection).
For each competitor, build the URL — pick media_type based on the chosen mode:
VIDEO mode (default):
https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=ALL&is_targeted_country=false&media_type=video&search_type=keyword_unordered&sort_data[direction]=desc&sort_data[mode]=total_impressions&q=<COMPETITOR_URL_ENCODED>
IMAGE mode:
https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=ALL&is_targeted_country=false&media_type=image_and_meme&search_type=keyword_unordered&sort_data[direction]=desc&sort_data[mode]=total_impressions&q=<COMPETITOR_URL_ENCODED>
BOTH mode (image + video):
https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=ALL&is_targeted_country=false&media_type=all&search_type=keyword_unordered&sort_data[direction]=desc&sort_data[mode]=total_impressions&q=<COMPETITOR_URL_ENCODED>
Use the user's primary market for country= if they mentioned one, else ALL.
Then, for speed, do everything in as few calls as possible — navigate, wait briefly, then one JavaScript call that scrolls, extracts, filters, and downloads:
Critical technical facts (learned the hard way)
curl does NOT work. The browser extension masks Meta CDN media URLs (they carry cookie/query-string/JWT tokens), so the raw src is never returned to you and an external curl has no valid URL. Download in-page instead: fetch(src) → blob() → temporary <a download> → click(). This saves to the browser's Downloads folder. You never need to see the URL.
- Keyword pollution is common. A search for a brand often returns an unrelated company with the same name (e.g. "Creatify" the AI tool vs. "Creatify.mx" a sticker shop). Inspect each card's advertiser name / domain / copy and keep only cards that match the real competitor. Drop the rest.
- Impressions are hidden for commercial (non-political) ads. Don't rank by impressions. Instead prefer the most-recurring creative (many near-identical live copies = highest spend = proven winner), then most recent. Pick the top N by that heuristic.
- Selector + extension depend on the mode. VIDEO mode uses
<video> + .mp4. IMAGE mode uses the card's main <img> + .jpg/.png. BOTH mode collects <video> and <img> in the same pass (with the same min-size filter for images). In every mode, filter out tiny avatars/icons (≤ ~200px on either side) so you only keep real ad creatives.
One-shot extract + download script — VIDEO mode
(async () => {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2500));
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2000));
const vids = Array.from(document.querySelectorAll('video'))
.filter(v => (v.src || v.currentSrc || '').startsWith('http'));
const cardText = (v) => {
let el = v;
for (let i = 0; i < 12 && el; i++) {
el = el.parentElement;
if (el && el.innerText && el.innerText.length > 100 && el.innerText.length < 2000) return el.innerText;
}
return '';
};
const BRAND = /creatify\.?ai|@creatify/i;
const kept = [];
const seen = new Set();
for (const v of vids) {
const t = cardText(v);
if (!BRAND.test(t)) continue;
const key = t.slice(0, 80);
kept.push({ v, t, dup: seen.has(key) });
seen.add(key);
}
const N = 5;
const picks = kept.slice(0, N);
const results = [];
for (let i = 0; i < picks.length; i++) {
const url = picks[i].v.src || picks[i].v.currentSrc;
try {
const b = await (await fetch(url)).blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `spy-ad-${i + 1}-COMPETITOR.mp4`;
document.body.appendChild(a); a.click(); a.remove();
results.push({ i: i + 1, bytes: b.size });
} catch (e) { results.push({ i: i + 1, error: String(e) }); }
}
return results;
})()
One-shot extract + download script — STATIC / IMAGE mode
(async () => {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2500));
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2000));
const MIN = 200;
const imgs = Array.from(document.querySelectorAll('img'))
.filter(i => (i.currentSrc || i.src || '').startsWith('http'))
.filter(i => (i.naturalWidth || i.width) >= MIN && (i.naturalHeight || i.height) >= MIN);
const cardText = (n) => {
let el = n;
for (let i = 0; i < 12 && el; i++) {
el = el.parentElement;
if (el && el.innerText && el.innerText.length > 100 && el.innerText.length < 2000) return el.innerText;
}
return '';
};
const BRAND = /creatify\.?ai|@creatify/i;
const kept = [];
const seen = new Set();
for (const img of imgs) {
const t = cardText(img);
if (!BRAND.test(t)) continue;
const key = t.slice(0, 80);
if (seen.has(key)) continue;
kept.push({ img, t });
seen.add(key);
}
const N = 5;
const picks = kept.slice(0, N);
const results = [];
for (let i = 0; i < picks.length; i++) {
const url = picks[i].img.currentSrc || picks[i].img.src;
try {
const b = await (await fetch(url)).blob();
const ext = (b.type && b.type.includes('png')) ? 'png' : 'jpg';
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `spy-ad-${i + 1}-COMPETITOR.${ext}`;
document.body.appendChild(a); a.click(); a.remove();
results.push({ i: i + 1, bytes: b.size, ext });
} catch (e) { results.push({ i: i + 1, error: String(e) }); }
}
return results;
})()
One-shot extract + download script — BOTH mode (image + video)
Collects videos and images in a single pass off the same media_type=all page. Deduplication is per-card (one creative per ad card, regardless of whether it's a video or an image), so a single card with both a poster image and a playable video counts once and prefers the video.
(async () => {
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2500));
window.scrollTo(0, document.body.scrollHeight);
await new Promise(r => setTimeout(r, 2000));
const MIN = 200;
const cardOf = (node) => {
let el = node;
for (let i = 0; i < 12 && el; i++) {
el = el.parentElement;
if (el && el.innerText && el.innerText.length > 100 && el.innerText.length < 2000) {
return { card: el, text: el.innerText };
}
}
return { card: null, text: '' };
};
const candidates = [];
for (const v of document.querySelectorAll('video')) {
const url = v.src || v.currentSrc || '';
if (!url.startsWith('http')) continue;
candidates.push({ kind: 'video', node: v, url });
}
for (const img of document.querySelectorAll('img')) {
const url = img.currentSrc || img.src || '';
if (!url.startsWith('http')) continue;
if ((img.naturalWidth || img.width) < MIN || (img.naturalHeight || img.height) < MIN) continue;
candidates.push({ kind: 'image', node: img, url });
}
const BRAND = /creatify\.?ai|@creatify/i;
const byCard = new Map();
for (const c of candidates) {
const { card, text } = cardOf(c.node);
if (!card) continue;
if (!BRAND.test(text)) continue;
const prev = byCard.get(card);
if (!prev || (prev.kind === 'image' && c.kind === 'video')) {
byCard.set(card, { ...c, text });
}
}
const seen = new Set();
const kept = [];
for (const c of byCard.values()) {
const key = c.text.slice(0, 80);
if (seen.has(key)) continue;
seen.add(key);
kept.push(c);
}
const N = 5;
const picks = kept.slice(0, N);
const results = [];
for (let i = 0; i < picks.length; i++) {
const p = picks[i];
try {
const b = await (await fetch(p.url)).blob();
let ext;
if (p.kind === 'video') {
ext = 'mp4';
} else {
ext = (b.type && b.type.includes('png')) ? 'png' : 'jpg';
}
const a = document.createElement('a');
a.href = URL.createObjectURL(b);
a.download = `spy-ad-${i + 1}-COMPETITOR.${ext}`;
document.body.appendChild(a); a.click(); a.remove();
results.push({ i: i + 1, kind: p.kind, bytes: b.size, ext });
} catch (e) { results.push({ i: i + 1, error: String(e) }); }
}
return results;
})()
If a fetch fails (expired/geo-blocked), it's skipped automatically — just move to the next card. No mention to the user.
Step 4 — Collect files and deliver
After downloading, move the files out of the browser's Downloads folder to /tmp/ with the Bash tool, in one command. Pick the glob that matches the mode you ran:
VIDEO mode:
cd ~/Downloads && mv -f spy-ad-*.mp4 /tmp/ && ls -la /tmp/spy-ad-*.mp4
IMAGE mode:
cd ~/Downloads && mv -f spy-ad-*.jpg spy-ad-*.png /tmp/ 2>/dev/null; ls -la /tmp/spy-ad-*.{jpg,png} 2>/dev/null
BOTH mode (image + video):
cd ~/Downloads && mv -f spy-ad-*.mp4 spy-ad-*.jpg spy-ad-*.png /tmp/ 2>/dev/null; ls -la /tmp/spy-ad-*.{mp4,jpg,png} 2>/dev/null
Then deliver — only the files, no analysis, no commentary, no brief. Present each creative inline if a preview tool is available; otherwise list them as clickable file links:
…in IMAGE mode:
…in BOTH mode (videos and images interleaved by rank):
Do not append observations, patterns, recommendations, or written analysis. The only thing that may follow this delivery is the Step 5 "clone which?" form — and only when this skill ran standalone.
Step 5 — Offer to clone (standalone runs only)
This step only fires when arcads:spy-competitor-ads was invoked directly by the user as the end goal, not when it was triggered as a sub-step by another skill. Detect the situation before running it:
Skip Step 5 (do NOT show the form) when any of these is true:
- The current invocation was triggered by another skill — most commonly
arcads:clone-hook (Step 1B auto-source) or arcads:clone-static-ad (Step 1B auto-source). Those skills will consume the downloaded files themselves; offering to clone again would loop.
- The original user request was explicitly research-only — "just download competitor ads", "build me a swipe file", "save these for me", "I want to look at them".
- Zero creatives were delivered in Step 4.
Run Step 5 (show the form) otherwise, including when the user's request was ambiguous-but-actionable like "spy on competitors and let me work from there", "find competitor ads I can use as inspiration", or a bare "find competitor ads from [brand]". When in doubt, run it — declining is one click for the user.
The form
Surface every delivered creative as a separate option in a single AskUserQuestion call with multiple: true:
Question: "Want me to clone any of these for your brand? Pick the ones to recreate — I'll rebuild each for you."
Header: "Clone which?"
Multiple: true
Options: one per delivered file, in the same order they were listed in Step 4. Each option's label is the short filename without the leading slug (e.g. "Ad #1 — BrandX (video)" or "Ad #3 — BrandY (image)"). Each option's description is one short cue from the card text if you have it (e.g. "Talking-head selfie, 22s"), or just the file type if you don't.
The custom default option ("Type your own answer") lets the user say "all of them", "none — I'm done", or a custom note.
Routing each selection
Walk the user's selection in the order they were picked. For each chosen file:
- Video file (
.mp4) → load and run the arcads:clone-hook skill, passing the local /tmp/spy-ad-*.mp4 path as the source video (skipping its Step 1B auto-source, since you already have a video). arcads:clone-hook will analyze the hook, then (after its own confirmation prompt) clone it for the user's brand.
- Image file (
.jpg / .png) → load and run the arcads:clone-static-ad skill, passing the local /tmp/spy-ad-*.{jpg,png} path as the reference static ad (skipping its Step 1B auto-source, since you already have an image). arcads:clone-static-ad will analyze the layout and clone it for the user's brand.
Run the chained skills sequentially (not in parallel) — each one needs the user's attention for brand/asset questions, and parallel runs would collide. After each chained skill finishes, move to the next selected file.
If the user picks just one creative, hand off immediately without re-confirming. If they pick "none", stop cleanly — no chaining, no further commentary. If they pick "all of them", chain through every delivered file in order.
Carry brand context forward
The downstream skills (arcads:clone-hook, arcads:clone-static-ad) will ask for the user's brand, product, and assets. If the user already gave that information during Step 1a or in the original request, pass it forward — don't make them re-answer.
Edge cases
- No ads of the requested mode found for a competitor → skip silently, continue with the rest. If no competitor yields any creative in the chosen mode, say so briefly and stop. Do not silently fall back to a different mode — the user picked VIDEO, IMAGE, or BOTH for a reason.
- BOTH mode but only one media type returns (e.g. competitor runs only videos): deliver what you got and say briefly "BrandX is only running videos right now — no statics in their library." Do not pad with the missing format.
- Only same-name/unrelated ads found → treat as "no ads found" for that competitor; skip silently.
- Browser not connected → the only blocking case; see Step 2.
- User dismisses the mode form (Step 1b) or doesn't pick a mode → stop and say "I need to know which kind of ads to grab — video, image, or both." Do not guess.
- User deselects every competitor in the Step 1c multi-select → ask once for a manual list ("Which competitors should I look at instead?"). If still empty, stop.
- User picks "none" in the Step 5 clone form → stop cleanly. No commentary, no follow-up.
- User picks one creative in Step 5 → hand off to the matching cloner skill (
arcads:clone-hook for video, arcads:clone-static-ad for image) without an extra confirmation.
- Step 5 chain — one of the cloner skills fails or is cancelled by the user → stop the chain (do not run the remaining selections silently). Surface what happened in one line and let the user restart the chain if they want.
- Invoked from inside another skill (e.g.
arcads:clone-hook auto-source) → skip Step 5 entirely. The calling skill owns the next step.
Quick reference
| Tool | Where |
|---|
AskUserQuestion | Step 1b — pick media mode (when not explicit). Step 1c — multi-select competitor shortlist (when auto-discovered). Step 5 — multi-select "clone which?" (standalone runs only). |
WebSearch / WebFetch | Step 1c — auto-find competitors (only if not named) |
Browser MCP (mcp__Claude_in_Chrome__* / Playwright) | Steps 2–3 — open Ad Library, run extract+download JS |
javascript_tool (in-page fetch→blob→download) | Step 3 — the ONLY reliable download path; curl does not work. Pick the extractor that matches the mode: <video> for VIDEO, <img> for IMAGE, combined for BOTH. |
Bash (mv) | Step 4 — move files from Downloads to /tmp/ (.mp4 for VIDEO, .jpg/.png for IMAGE, both for BOTH) |
arcads:clone-hook skill | Step 5 — chained per selected video, passing the local /tmp/spy-ad-*.mp4 path |
arcads:clone-static-ad skill | Step 5 — chained per selected image, passing the local /tmp/spy-ad-*.{jpg,png} path |