From arcads
Find and download competitor ads from the Meta Ad Library (video, image, or both). Manual-only — invoke with /arcads:spy-competitor-ads or via another skill.
How this skill is triggered — by the user, by Claude, or both
Slash command
/arcads:spy-competitor-adsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
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.
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.
AskUserQuestion. Beyond these three, no other questions, confirmations, or "proceed?" check-ins. Between forms, go fully autonomous.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.
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.
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:
trueOptions: one per discovered competitor, with a one-line label like "BrandX — meal kits", "BrandY — recipe app", etc. Eachdescriptionexplains 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.
Default count: top 5 ads pooled across the final competitor selection. If the user specified a number ("2 ads", "one each"), honor it exactly.
Confirm a browser automation MCP is connected (e.g. mcp__Claude_in_Chrome__*, Playwright, Chrome DevTools, Puppeteer). Verify with list_connected_browsers or equivalent.
"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.
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:
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.<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.(async () => {
// 1. trigger lazy-load
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));
// 2. collect videos + their card text
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 '';
};
// 3. keep only cards matching the REAL competitor (edit the regex per brand),
// drop same-name pollution, and de-dupe so recurring creatives count once.
const BRAND = /creatify\.?ai|@creatify/i; // <-- set per competitor
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);
}
// 4. download up to N (recurring creatives appear first → already spend-weighted)
const N = 5; // <-- set to requested count
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`; // <-- set competitor slug
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;
})()
(async () => {
// 1. trigger lazy-load
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));
// 2. collect images + their card text. Drop avatars/icons by minimum size.
const MIN = 200; // px — anything smaller is likely an avatar/icon
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 '';
};
// 3. keep only cards matching the REAL competitor (edit the regex per brand),
// drop same-name pollution, and de-dupe so recurring creatives count once.
const BRAND = /creatify\.?ai|@creatify/i; // <-- set per competitor
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; // de-dupe identical creatives
kept.push({ img, t });
seen.add(key);
}
// 4. download up to N as JPGs (recurring creatives appear first → already spend-weighted)
const N = 5; // <-- set to requested count
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}`; // <-- set competitor slug
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;
})()
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 () => {
// 1. trigger lazy-load
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; // px — minimum image size to count as a real creative
// 2. helper: walk up to find the ad card and its text, return both
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: '' };
};
// 3. collect candidates: every video, plus every large image
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 });
}
// 4. attach card text + de-dupe per card (video wins over image when both exist on the same card)
const BRAND = /creatify\.?ai|@creatify/i; // <-- set per competitor
const byCard = new Map(); // card element → chosen candidate
for (const c of candidates) {
const { card, text } = cardOf(c.node);
if (!card) continue;
if (!BRAND.test(text)) continue;
const prev = byCard.get(card);
// prefer video over image when both exist on the same card
if (!prev || (prev.kind === 'image' && c.kind === 'video')) {
byCard.set(card, { ...c, text });
}
}
// 5. de-dupe near-identical recurring creatives by card text prefix
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);
}
// 6. download up to N (recurring creatives appear first → already spend-weighted)
const N = 5; // <-- set to requested count
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}`; // <-- set competitor slug
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.
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.
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:
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.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.
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:
trueOptions: one per delivered file, in the same order they were listed in Step 4. Each option'slabelis the short filename without the leading slug (e.g. "Ad #1 — BrandX (video)" or "Ad #3 — BrandY (image)"). Each option'sdescriptionis 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.
Walk the user's selection in the order they were picked. For each chosen 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..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.
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.
arcads:clone-hook for video, arcads:clone-static-ad for image) without an extra confirmation.arcads:clone-hook auto-source) → skip Step 5 entirely. The calling skill owns the next step.| 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 |
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub arcads-ai/skills --plugin arcads