@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 +14 -0
- package/README.md +115 -0
- package/package.json +32 -0
- package/src/ads/cli.mjs +82 -0
- package/src/ads/extract.mjs +108 -0
- package/src/ads/harvest.mjs +90 -0
- package/src/ads/push.mjs +56 -0
- package/src/ads/run.js +67 -0
- package/src/analyze.js +155 -0
- package/src/chrome.js +396 -0
- package/src/config.js +83 -0
- package/src/discover.js +463 -0
- package/src/download.js +324 -0
- package/src/frames.js +85 -0
- package/src/jobs.js +370 -0
- package/src/login-cli.js +30 -0
- package/src/push-to-sprid.js +194 -0
- package/src/quick.js +318 -0
- package/src/register.js +27 -0
- package/src/server.js +881 -0
- package/src/sheet.js +106 -0
- package/src/store.js +130 -0
- package/src/tiktok.js +391 -0
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
|
+
}
|
package/src/ads/cli.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/ads/push.mjs
ADDED
|
@@ -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 };
|