@sprid/scout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,14 @@
1
+ Copyright (c) 2026 Väder AB. All rights reserved.
2
+
3
+ Use is subject to Sprid's Terms of Service at https://sprid.studio/terms,
4
+ which set out what you may do with these files.
5
+
6
+ Bundled third-party components keep their own licences, which are included
7
+ beside the files they cover.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
11
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
12
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
13
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
14
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # @sprid/scout
2
+
3
+ A local MCP server that researches the accounts already winning in your niche by
4
+ driving your own Chrome. It finds accounts posting photo slideshows about your
5
+ keywords, measures each one, and files what it learns into a library on your
6
+ machine.
7
+
8
+ Nothing leaves this computer unless you ask for it. There is no account and no
9
+ server behind it; the Sprid plugin's `scout` skill teaches your agent how to use
10
+ it well.
11
+
12
+ ## What it does
13
+
14
+ - **Discovery**: searches for accounts posting slideshows about your keywords,
15
+ then visits each candidate profile and measures it. Accounts that clear your
16
+ filters are the answer; the ones that miss are kept too, with the margin by
17
+ which they missed.
18
+ - **Analysis**: reads one named account in depth and reports what it is doing.
19
+ - **Evidence**: pulls slideshows to disk with a `metadata.json` beside each one,
20
+ so a format can be taken apart.
21
+ - **A local library**: everything discovery passes is filed and answers
22
+ instantly from then on, with no second browser run.
23
+
24
+ ## The ads lane
25
+
26
+ The same niche, read from the other side: `start_ad_harvest` collects the ads
27
+ currently running in a country from Meta's public Ad Library, which covers
28
+ Facebook and Instagram. One source shows what people choose to watch, the other
29
+ shows what somebody is paying to put in front of them.
30
+
31
+ It needs no login and uses a throwaway browser rather than your signed-in
32
+ research profile. From a terminal instead of an agent:
33
+
34
+ ```sh
35
+ npm run ads -- harvest --country US --q "budget app" --brand "Acme"
36
+ npm run ads -- push --account yourapp --file out/us-2026-01-31.json
37
+ ```
38
+
39
+ **Days live is the only performance signal the Ad Library exposes** outside EU
40
+ political ads. There is no spend, no impressions, no click-through rate. An ad
41
+ running 200 days means somebody keeps paying for it, which is survival rather
42
+ than efficiency - so nothing found here is "best-performing", however tempting
43
+ the phrase.
44
+
45
+ ## What comes down is evidence, not material
46
+
47
+ The numbers are facts about the world and yours to reason from. The posts belong
48
+ to the people who made them, and they come down so you can study the structure -
49
+ the order of the cards, where the hook sits, what the last one does - and rebuild
50
+ it in your own words and images. Not to repost, and not to place in a draft.
51
+
52
+ ## How it works
53
+
54
+ Plain Playwright launches your real Google Chrome, not a bundled Chromium,
55
+ headless off, with a dedicated profile. You log in once in that window.
56
+
57
+ Pages are loaded and scrolled the way a person would. The data comes from the
58
+ JSON the pages fetch for themselves, read off the wire, plus the server-rendered
59
+ blob stashed by an init script before hydration removes it. No private API is
60
+ called directly, and there is **no bot-detection evasion in this package** - it
61
+ reads pages you could open yourself, signed in as you.
62
+
63
+ Runs are asynchronous: a tool returns a `job_id` immediately and `await_job`
64
+ blocks server-side for up to an hour, sending progress notifications on a
65
+ heartbeat so a long run survives inside one agent turn. When a verification
66
+ appears, the job parks, Chrome is brought to the front, and your agent asks you
67
+ to solve it and resume.
68
+
69
+ ## Install
70
+
71
+ ```sh
72
+ npm install -g @sprid/scout
73
+ sprid-scout # or: npm run start, from a checkout
74
+ npm run register # registers the stdio MCP server with Claude Code
75
+ npm run login # opens Chrome; log in once
76
+ ```
77
+
78
+ `register` runs `claude mcp add --transport stdio --scope user scout -- <node> <path>/src/server.js`.
79
+ If the `claude` CLI is not on PATH it prints the command to run by hand. The
80
+ skill lives in the Sprid plugin, so nothing is copied into a skills directory
81
+ from here.
82
+
83
+ Start a new session afterwards and ask for something research-shaped.
84
+
85
+ ## Layout
86
+
87
+ ```
88
+ src/config.js paths, Chrome discovery, the name constant
89
+ src/chrome.js launch, profile, page leases, challenge detection, login
90
+ src/tiktok.js endpoint parsers, metrics, verdicts, marks
91
+ src/discover.js phase 1 search, phase 2 measure, the round loop
92
+ src/analyze.js one account, in depth
93
+ src/download.js slideshows to disk
94
+ src/jobs.js the async job registry and the wait protocol
95
+ src/store.js the local library
96
+ src/server.js the MCP surface
97
+ src/register.js registers the server with your agent
98
+ src/push-to-sprid.js optional: send a pull into Sprid as inspirations
99
+ ```
100
+
101
+ Data lives in `~/.scout/`: `chrome-profile/`, `runs/`, `downloads/`, and
102
+ `library.json`. A library written under the tool's previous name is read where it
103
+ already is rather than moved.
104
+
105
+ ## Sending findings to Sprid
106
+
107
+ Optional, and it needs a Sprid personal access token:
108
+
109
+ ```sh
110
+ SPRID_PAT=sprd_... npm run push -- <account-slug> ~/.scout/downloads/<job-id>
111
+ ```
112
+
113
+ They land in the inspiration library as reference for analysis - what the format
114
+ was and how it performed - which is what lets Sprid cluster them into formats
115
+ with evidence behind them.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@sprid/scout",
3
+ "version": "0.1.0",
4
+ "description": "Local MCP server that researches a niche over your own Chrome: the accounts winning it, and the ads running in it.",
5
+ "license": "SEE LICENSE IN LICENSE",
6
+ "homepage": "https://sprid.studio/agents",
7
+ "bugs": {
8
+ "email": "hello@sprid.studio"
9
+ },
10
+ "bin": {
11
+ "sprid-scout": "src/server.js",
12
+ "sprid-scout-ads": "src/ads/cli.mjs",
13
+ "sprid-scout-quick": "src/quick.js"
14
+ },
15
+ "main": "src/server.js",
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "dependencies": {
25
+ "@modelcontextprotocol/sdk": "^1.30.0",
26
+ "playwright-core": "^1.58.0",
27
+ "zod": "^3.25.76"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ }
32
+ }
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // The ads lane: harvest Meta's public Ad Library, push findings into Sprid.
3
+ //
4
+ // Runs on this machine, against a public page, with no login.
5
+
6
+ import { writeFileSync, readFileSync, mkdirSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { harvest, suggestArchetype } from "./harvest.mjs";
9
+ import { pushToSprid } from "./push.mjs";
10
+
11
+ function parseArgs(argv) {
12
+ const out = { _: [], q: [], brand: [] };
13
+ for (let i = 0; i < argv.length; i++) {
14
+ const a = argv[i];
15
+ if (a === "--q" || a === "--brand") out[a.slice(2)].push(argv[++i]);
16
+ else if (a.startsWith("--")) out[a.slice(2)] = argv[++i];
17
+ else out._.push(a);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ const args = parseArgs(process.argv.slice(2));
23
+ const cmd = args._[0];
24
+
25
+ if (cmd === "harvest") {
26
+ const country = (args.country || "SE").toUpperCase();
27
+ const queries = [...args.q, ...args.brand];
28
+ if (!queries.length) {
29
+ console.error("Give at least one --q <keyword> or --brand <name>.\n" +
30
+ "Keywords discover who advertises in a niche and are noisy; brand names are the clean seam.");
31
+ process.exit(1);
32
+ }
33
+
34
+ const ads = await harvest({
35
+ country,
36
+ queries,
37
+ headless: args.headless === "true",
38
+ onProgress: ({ query, found, total }) =>
39
+ console.log(` ${query}: ${found} cards (${total} unique so far)`),
40
+ });
41
+
42
+ const stamp = new Date().toISOString().slice(0, 10);
43
+ const file = args.out || join("out", `${country.toLowerCase()}-${stamp}.json`);
44
+ mkdirSync(dirname(file), { recursive: true });
45
+ writeFileSync(file, JSON.stringify(ads, null, 1));
46
+
47
+ const statics = ads.filter((a) => !a.hasVideo).length;
48
+ console.log(`\n${ads.length} unique ads → ${file}`);
49
+ console.log(` ${statics} static / ${ads.length - statics} video`);
50
+ const long = ads.filter((a) => (a.daysLive ?? 0) >= 150).length;
51
+ console.log(` ${long} running 150+ days (the only survival signal the library gives)`);
52
+ }
53
+
54
+ else if (cmd === "push") {
55
+ const file = args.file;
56
+ const account = args.account;
57
+ if (!file || !account) {
58
+ console.error("Usage: scout ads push --account <slug> --file <harvest.json>");
59
+ process.exit(1);
60
+ }
61
+ const ads = JSON.parse(readFileSync(file, "utf8"));
62
+ const archetypes = Object.fromEntries(
63
+ ads.map((a) => [a.libraryId, suggestArchetype(a)]).filter(([, v]) => v),
64
+ );
65
+ const res = await pushToSprid({
66
+ account, ads, pat: process.env.SPRID_PAT, url: process.env.SPRID_URL, archetypes,
67
+ });
68
+ console.log(`created ${res.created}, failed ${res.failed.length}`);
69
+ if (res.failed.length) console.log(JSON.stringify(res.failed.slice(0, 5), null, 1));
70
+ console.log("\nArchetypes were guessed from the copy where possible and left null otherwise.\n" +
71
+ "Classification is a judgement call — read the creatives and correct them before pushing.");
72
+ }
73
+
74
+ else {
75
+ console.log(`scout ads — Meta Ad Library research
76
+
77
+ harvest --country US --q "budget app" --brand "Acme" collect ads
78
+ push --account yourapp --file out/us-2026-01-31.json send to Sprid
79
+
80
+ Days live is the only performance signal the Ad Library exposes outside EU
81
+ political ads. It means somebody keeps paying, not that it is efficient.`);
82
+ }
@@ -0,0 +1,108 @@
1
+ // The DOM walk, kept as a string because it is evaluated inside the page.
2
+ //
3
+ // Two card shapes exist and both must be handled: one says "See ad details"
4
+ // before the advertiser name, the other "See summary details". Handling only
5
+ // the first silently loses about a quarter of every result set, which looks
6
+ // exactly like a thin niche.
7
+ //
8
+ // The UI language follows your IP, not the country parameter — searching US
9
+ // from Sweden returns Swedish chrome around English ads — so every label is
10
+ // matched in both languages.
11
+
12
+ export const EXTRACT_FN = `(() => {
13
+ const walk = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
14
+ const ids = [];
15
+ let n;
16
+ while ((n = walk.nextNode())) {
17
+ if (/^(Biblioteks-id|Library ID)/.test(n.textContent.trim())) ids.push(n);
18
+ }
19
+ const seen = new Set();
20
+ const out = [];
21
+ for (const t of ids) {
22
+ let p = t.parentElement;
23
+ for (let i = 0; i < 10 && p; i++) {
24
+ if (p.textContent.length > 250 || p.querySelector('img')) break;
25
+ p = p.parentElement;
26
+ }
27
+ let guard = 0;
28
+ while (p && p.querySelectorAll('img,video').length === 0 && guard++ < 3) p = p.parentElement;
29
+ if (!p) continue;
30
+ const txt = p.innerText || '';
31
+ const idm = txt.match(/(?:Biblioteks-id|Library ID):\\s*(\\d+)/);
32
+ if (!idm || seen.has(idm[1])) continue;
33
+ seen.add(idm[1]);
34
+ const imgs = [...p.querySelectorAll('img')]
35
+ .map((i) => i.src)
36
+ .filter((s) => s && s.startsWith('http') && !/s60x60|p60x60|s32x32/.test(s));
37
+ out.push({ id: idm[1], text: txt, imgs, hasVideo: p.querySelectorAll('video').length > 0 });
38
+ }
39
+ return JSON.stringify(out);
40
+ })()`;
41
+
42
+ const MONTHS = {
43
+ jan: 1, feb: 2, mar: 3, apr: 4, maj: 5, may: 5, jun: 6, jul: 7,
44
+ aug: 8, sep: 9, okt: 10, oct: 10, nov: 11, dec: 12,
45
+ };
46
+
47
+ // Both date orders, because the library writes the one its own locale uses:
48
+ // "Started running on 12 Aug 2026" in en-GB and Swedish chrome, "Started
49
+ // running on Aug 12, 2026" in en-US. Matching only the first returns null for
50
+ // every US ad, which reads as "nothing here has been running long" - the most
51
+ // misleading answer this tool can give, since days live is its only signal.
52
+ export function parseStartDate(text) {
53
+ const label = text.match(/(?:B[öo]rjade k[öo]ras den|Started running on)\s+([^\n]{0,28})/);
54
+ if (!label) return null;
55
+ const rest = label[1].trim();
56
+ const dayFirst = rest.match(/^(\d{1,2})\s+(\w{3})\w*\.?,?\s*(\d{4})/);
57
+ const monthFirst = rest.match(/^(\w{3})\w*\.?\s+(\d{1,2}),?\s*(\d{4})/);
58
+ const parts = dayFirst
59
+ ? { day: dayFirst[1], month: dayFirst[2], year: dayFirst[3] }
60
+ : monthFirst
61
+ ? { day: monthFirst[2], month: monthFirst[1], year: monthFirst[3] }
62
+ : null;
63
+ if (!parts) return null;
64
+ const mon = MONTHS[parts.month.slice(0, 3).toLowerCase()];
65
+ if (!mon) return null;
66
+ return `${parts.year}-${String(mon).padStart(2, "0")}-${String(Number(parts.day)).padStart(2, "0")}`;
67
+ }
68
+
69
+ export function parseCard(card, source, today = new Date()) {
70
+ const text = card.text.replace(/​/g, "");
71
+ const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
72
+
73
+ let advertiser = null;
74
+ let body = [];
75
+ for (let i = 0; i < lines.length; i++) {
76
+ const l = lines[i];
77
+ if (/^(Se annonsinformation|See ad details|Se detaljerad sammanfattning|See summary details)/.test(l)) {
78
+ advertiser = lines[i + 1] ?? null;
79
+ for (let j = i + 1; j < lines.length; j++) {
80
+ if (lines[j] === "Sponsras" || lines[j] === "Sponsored") {
81
+ body = lines.slice(j + 1);
82
+ break;
83
+ }
84
+ }
85
+ break;
86
+ }
87
+ }
88
+
89
+ const start = parseStartDate(text);
90
+ const daysLive = start
91
+ ? Math.round((today.getTime() - new Date(start + "T00:00:00Z").getTime()) / 86_400_000)
92
+ : null;
93
+
94
+ const vm = text.match(/(\d+)\s+annonser anv[äa]nder|(\d+)\s+ads use/);
95
+ const variants = vm ? Number(vm[1] || vm[2]) : (/flera versioner|multiple versions/.test(text) ? 2 : 1);
96
+
97
+ return {
98
+ libraryId: card.id,
99
+ advertiser,
100
+ start,
101
+ daysLive,
102
+ variants,
103
+ hasVideo: card.hasVideo || /0:00 \//.test(text),
104
+ primaryText: body.join("\n").trim(),
105
+ creatives: card.imgs,
106
+ source,
107
+ };
108
+ }
@@ -0,0 +1,90 @@
1
+ // Drive a real Chrome against the Ad Library and collect cards.
2
+ //
3
+ // Deliberately slow and sequential. The grid is React and hydrates late; a
4
+ // short wait returns an empty array that is indistinguishable from "no
5
+ // results", which is why every search retries once before being believed.
6
+
7
+ import { chromium } from "playwright-core";
8
+ import { EXTRACT_FN, parseCard } from "./extract.mjs";
9
+
10
+ const BASE = "https://www.facebook.com/ads/library/";
11
+
12
+ export function searchUrl({ country, query, activeOnly = true }) {
13
+ const u = new URL(BASE);
14
+ u.searchParams.set("active_status", activeOnly ? "active" : "all");
15
+ u.searchParams.set("ad_type", "all");
16
+ u.searchParams.set("media_type", "all");
17
+ u.searchParams.set("search_type", "keyword_unordered");
18
+ u.searchParams.set("country", country);
19
+ u.searchParams.set("q", query);
20
+ return u.toString();
21
+ }
22
+
23
+ async function extractOnce(page, url, { settleMs, scrolls }) {
24
+ await page.goto(url, { waitUntil: "domcontentloaded" });
25
+ await page.waitForTimeout(settleMs);
26
+ for (let i = 0; i < scrolls; i++) {
27
+ await page.mouse.wheel(0, 2400);
28
+ await page.waitForTimeout(2500);
29
+ }
30
+ const raw = await page.evaluate(EXTRACT_FN);
31
+ try {
32
+ return JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw));
33
+ } catch {
34
+ return [];
35
+ }
36
+ }
37
+
38
+ /**
39
+ * @param {object} opts
40
+ * @param {string} opts.country ISO-3166 alpha-2
41
+ * @param {string[]} opts.queries keywords or brand names
42
+ * @param {boolean} [opts.headless]
43
+ */
44
+ export async function harvest({ country, queries, headless = false, settleMs = 7000, scrolls = 2, onProgress }) {
45
+ const browser = await chromium.launch({ headless, channel: "chrome" });
46
+ const context = await browser.newContext({ viewport: { width: 1280, height: 1400 } });
47
+ const page = await context.newPage();
48
+
49
+ const byId = new Map();
50
+ try {
51
+ for (const query of queries) {
52
+ const url = searchUrl({ country, query });
53
+ let cards = await extractOnce(page, url, { settleMs, scrolls });
54
+ // One retry: an empty result is far more often a slow hydrate than an
55
+ // empty niche, and believing the first empty answer is how a market
56
+ // gets written off.
57
+ if (cards.length === 0) {
58
+ await page.waitForTimeout(3000);
59
+ cards = await extractOnce(page, url, { settleMs: settleMs + 3000, scrolls });
60
+ }
61
+ const source = `${country.toLowerCase()}:${query}`;
62
+ for (const card of cards) {
63
+ const parsed = parseCard(card, source);
64
+ const existing = byId.get(parsed.libraryId);
65
+ if (existing) existing.source += `,${source}`;
66
+ else byId.set(parsed.libraryId, parsed);
67
+ }
68
+ onProgress?.({ query, found: cards.length, total: byId.size });
69
+ }
70
+ } finally {
71
+ await browser.close();
72
+ }
73
+
74
+ return [...byId.values()];
75
+ }
76
+
77
+ /**
78
+ * A first-pass archetype guess, offered as a suggestion and never written as
79
+ * fact. Classification is a judgement call and belongs to whoever reads the
80
+ * creative; this only narrows the list.
81
+ */
82
+ export function suggestArchetype(ad) {
83
+ const t = (ad.primaryText || "").toLowerCase();
84
+ if (/\b(\d+\s?%|rabatt|off|kr\b|\$\d|pay once|no subscription)/.test(t)) return "offer";
85
+ if (/\bquiz|test\b|which .* are you|vilken .* är du/.test(t)) return "quadrant-quiz";
86
+ if (/\bvs\.?\b|jämför|compare|instead of/.test(t)) return "comparison";
87
+ if (/\(\d{2}\)|\b\d{2}-year-old|berättar|trodde/.test(t)) return "named-person";
88
+ if (ad.primaryText && ad.primaryText.length > 400) return "text-wall";
89
+ return null;
90
+ }
@@ -0,0 +1,56 @@
1
+ // Push a harvest into Sprid as inspirations.
2
+ //
3
+ // Same door a phone screenshot upload uses: POST /api/inspirations with a PAT.
4
+ // No schema change was needed — an Ad Library find maps onto the existing
5
+ // columns.
6
+
7
+ const DEFAULT_URL = process.env.SPRID_URL || "https://api.sprid.studio";
8
+
9
+ export function toInspiration(ad, { archetype } = {}) {
10
+ return {
11
+ source: "meta_ads",
12
+ sourceUrl: `https://www.facebook.com/ads/library/?id=${ad.libraryId}`,
13
+ title: ad.advertiser ? `${ad.advertiser} · ${ad.daysLive ?? "?"}d` : `Ad ${ad.libraryId}`,
14
+ authorHandle: ad.advertiser ?? null,
15
+ caption: ad.primaryText || null,
16
+ // Using the archetype taxonomy for shapeKey is what puts a competitor's days-live next to our cost
17
+ // per event for the same shape.
18
+ shapeKey: archetype ?? null,
19
+ tags: [
20
+ ad.hasVideo ? "video" : "static",
21
+ ...(ad.source ? ad.source.split(",").map((s) => s.split(":")[0]) : []),
22
+ ],
23
+ notes: [
24
+ ad.start ? `Started ${ad.start}` : null,
25
+ ad.daysLive != null ? `${ad.daysLive} days live` : null,
26
+ ad.variants > 1 ? `${ad.variants} variants share this creative` : null,
27
+ `Library ID ${ad.libraryId}`,
28
+ ].filter(Boolean).join(" · "),
29
+ // Days live is a survival signal, not an efficiency one. It is stored
30
+ // under its own name so nobody later reads it as engagement.
31
+ metrics: {
32
+ daysLive: ad.daysLive,
33
+ variants: ad.variants,
34
+ isVideo: ad.hasVideo,
35
+ },
36
+ status: "ready",
37
+ };
38
+ }
39
+
40
+ export async function pushToSprid({ account, ads, pat, url = DEFAULT_URL, archetypes = {} }) {
41
+ if (!pat) throw new Error("SPRID_PAT is required");
42
+ const results = { created: 0, failed: [] };
43
+
44
+ for (const ad of ads) {
45
+ const body = toInspiration(ad, { archetype: archetypes[ad.libraryId] });
46
+ const res = await fetch(`${url}/api/inspirations?account=${encodeURIComponent(account)}`, {
47
+ method: "POST",
48
+ headers: { Authorization: `Bearer ${pat}`, "Content-Type": "application/json" },
49
+ body: JSON.stringify(body),
50
+ });
51
+ if (res.ok) results.created++;
52
+ else results.failed.push({ libraryId: ad.libraryId, status: res.status, body: await res.text() });
53
+ }
54
+
55
+ return results;
56
+ }
package/src/ads/run.js ADDED
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+
3
+ // The ads lane, as a run the job registry can follow.
4
+ //
5
+ // It launches its own throwaway Chrome rather than the persistent research
6
+ // profile: the Ad Library is public and needs no session, and putting that
7
+ // browsing inside the signed-in profile would mix two unrelated identities for
8
+ // no gain.
9
+
10
+ const fs = require("node:fs");
11
+ const path = require("node:path");
12
+ const jobsLib = require("../jobs");
13
+ const { ROOT } = require("../config");
14
+
15
+ const HARVESTS_DIR = path.join(ROOT, "harvests");
16
+
17
+ /** A compact row per ad, ordered by how long it has been paid for. */
18
+ function row(ad, archetype) {
19
+ return {
20
+ library_id: ad.libraryId,
21
+ advertiser: ad.advertiser,
22
+ days_live: ad.daysLive,
23
+ variants: ad.variants,
24
+ media: ad.hasVideo ? "video" : "static",
25
+ suggested_archetype: archetype ?? null,
26
+ primary_text: (ad.primaryText || "").slice(0, 400),
27
+ found_by: ad.source,
28
+ };
29
+ }
30
+
31
+ async function runAdHarvest(job) {
32
+ const { country, queries } = job.meta;
33
+ const { harvest, suggestArchetype } = await import("./harvest.mjs");
34
+ const gate = jobsLib.gate(job);
35
+
36
+ const ads = await harvest({
37
+ country,
38
+ queries,
39
+ onProgress: ({ query, found, total }) =>
40
+ jobsLib.log(job, `${query}: ${found} cards (${total} unique so far)`),
41
+ });
42
+ if (!(await gate())) return { country, queries, unique_ads: ads.length, stopped: true };
43
+
44
+ fs.mkdirSync(HARVESTS_DIR, { recursive: true });
45
+ const file = path.join(HARVESTS_DIR, `${job.id}.json`);
46
+ fs.writeFileSync(file, JSON.stringify(ads, null, 1));
47
+
48
+ const statics = ads.filter((ad) => !ad.hasVideo).length;
49
+ const ranked = [...ads].sort((a, b) => (b.daysLive ?? 0) - (a.daysLive ?? 0));
50
+
51
+ return {
52
+ country,
53
+ queries,
54
+ file,
55
+ unique_ads: ads.length,
56
+ static_ads: statics,
57
+ video_ads: ads.length - statics,
58
+ running_150_days: ads.filter((ad) => (ad.daysLive ?? 0) >= 150).length,
59
+ signal:
60
+ "Days live is the only performance signal the Ad Library exposes outside EU political " +
61
+ "ads. It means somebody keeps paying for this, not that it is efficient. Never call a " +
62
+ "find here best-performing.",
63
+ ads: ranked.slice(0, 40).map((ad) => row(ad, suggestArchetype(ad))),
64
+ };
65
+ }
66
+
67
+ module.exports = { runAdHarvest, HARVESTS_DIR };