crawldna 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/index.d.ts ADDED
@@ -0,0 +1,376 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Type definitions for crawldna — the public API of `src/index.mjs`.
4
+ // Hand-written (the source is plain ESM JavaScript). Kept in sync with
5
+ // DEFAULT_OPTIONS and the `crawlDocs` result shape documented in README.md.
6
+
7
+ export type Provider = 'ollama' | 'openai';
8
+ export type BrowserMode = 'never' | 'auto' | 'always';
9
+ /**
10
+ * What to extract — an explicit switch, never inferred from the task wording.
11
+ * `"complete"` (default): everything reachable (completeness shortcuts tried first,
12
+ * pages kept whole, zero AI link-gate/scoping calls). `"targeted"`: only what the
13
+ * task asks (AI link gate + section scoping — requires AI, incompatible with `noAi`).
14
+ * `"auto"`: legacy behaviour — a multilingual regex on the task picks the docs path;
15
+ * never the default (#23), kept only for saved runs and callers that name it.
16
+ */
17
+ export type CrawlMode = 'auto' | 'complete' | 'targeted';
18
+
19
+ /** One unit of work: a URL plus the natural-language task describing what to extract. */
20
+ export interface Target {
21
+ url: string;
22
+ /** What to extract. Falls back to `options.task` when omitted. */
23
+ task?: string;
24
+ }
25
+
26
+ /** Accepted shapes for the first argument of {@link crawlDocs}. */
27
+ export type Targets = string | string[] | Target | Target[];
28
+
29
+ export interface CrawlOptions {
30
+ /** Shared/default task when a target doesn't carry its own. */
31
+ task?: string;
32
+ /**
33
+ * Model id. REQUIRED — there is no default. For Ollama use a local model id
34
+ * (e.g. `"qwen3-coder:30b"`); for an OpenAI-compatible API use that API's id
35
+ * (e.g. `"gpt-4o-mini"`).
36
+ */
37
+ model?: string;
38
+ /** `"ollama"` (local, default) or `"openai"` (any OpenAI-compatible API). */
39
+ provider?: Provider;
40
+ /**
41
+ * Optional embedding model id (#22), from the same provider as `model` (e.g.
42
+ * `"nomic-embed-text"` on Ollama, `"text-embedding-3-small"` on OpenAI). Makes
43
+ * task→link relevance SEMANTIC — multilingual and synonym-aware — feeding the
44
+ * best-first frontier ordering, the route budget, the opt-in `minRelevance`
45
+ * pruning and the reshape context retrieval. Embeddings only ORDER; they never
46
+ * drop links by themselves. Unset (default) = the lexical scorer; unreachable
47
+ * backend = one loud warning, then the lexical floor. Ignored with `noAi`
48
+ * (zero calls to ANY model, embeddings included).
49
+ */
50
+ embedModel?: string;
51
+ /**
52
+ * Crawl with ZERO model calls (no model needed): reveal runs on DOM heuristics,
53
+ * pages are kept whole, every in-scope link is followed. Zero tokens; the output
54
+ * is not task-filtered and big sites can take longer — contain with `maxPages` /
55
+ * `include` / `exclude`. #23 — the task speaks only to the AI, so here it has NO
56
+ * role: an explicit `task` (or `minRelevance`, which reads it) is refused loudly,
57
+ * and output files are named from the site. Incompatible with `mode: "targeted"`
58
+ * (task-filtering IS the model's job) — that combination is refused loudly.
59
+ * Default `false`.
60
+ */
61
+ noAi?: boolean;
62
+ /** What to extract (see {@link CrawlMode}). Default `"complete"`. */
63
+ mode?: CrawlMode;
64
+ /** API base URL for `provider: "openai"` (e.g. `https://api.openai.com/v1`). */
65
+ baseUrl?: string;
66
+ /** API key for `provider: "openai"`. Falls back to `CRAWLDNA_API_KEY` / `OPENAI_API_KEY`. */
67
+ apiKey?: string;
68
+ /** Override the Ollama server URL (default `http://127.0.0.1:11434`). */
69
+ ollamaHost?: string;
70
+ /** Whether to render pages in a real browser. Lazy-loads Playwright. Default `"auto"`. */
71
+ browser?: BrowserMode;
72
+ /** Parallel page renders. Default `4`. */
73
+ concurrency?: number;
74
+ /** Per-scan page cap; `0` = unlimited. Default `0`. */
75
+ maxPages?: number;
76
+ /** Per-page reveal action budget. Default `40`. */
77
+ maxActions?: number;
78
+ /** Only crawl URLs matching this pattern. */
79
+ include?: string | RegExp;
80
+ /** Skip URLs matching this pattern. */
81
+ exclude?: string | RegExp;
82
+ /**
83
+ * Politeness, opt-in (#14): minimum milliseconds between requests to the SAME
84
+ * host (concurrent workers queue behind each other per host, never across
85
+ * hosts). `0` (default) = off.
86
+ */
87
+ delay?: number;
88
+ /**
89
+ * Politeness, opt-in (#14): read each origin's robots.txt — disallowed URLs
90
+ * are SKIPPED with a `robots` warning (never silently) and `Crawl-delay` is
91
+ * honoured (the larger of it and `delay` wins). Default `false`: the tool is
92
+ * user-directed, like wget. Note the anti-bot CHALLENGE guard is separate and
93
+ * always on: a bot-defense interstitial is never kept as content (one retry
94
+ * with backoff, then a declared skip with an `anti-bot` warning).
95
+ */
96
+ respectRobots?: boolean;
97
+ /**
98
+ * Cap on the speculative JS-mined route candidates (paths dug out of script/JSON
99
+ * blobs, up to 800 per page) sent to the AI link gate, top-ranked by task
100
+ * relevance. `0` = unlimited. Default `200`. Conservative: only cuts when the
101
+ * relevance scores discriminate among the routes — a generic task cuts nothing —
102
+ * and real DOM links are never capped.
103
+ */
104
+ maxRoutes?: number;
105
+ /**
106
+ * Focused mode (opt-in): prune links whose task-relevance score (`0..1`) falls below
107
+ * this threshold BEFORE the AI link gate. `0` (default) = off — relevance then only
108
+ * orders links best-first and never drops any. Trades some recall for speed/scope,
109
+ * and only applies when the task actually discriminates among a page's links.
110
+ */
111
+ minRelevance?: number;
112
+ /**
113
+ * Persist the run to the cache. Library default `false` — the result is returned
114
+ * in memory only. The CLI and Web UI set this to `true`. Passing `cacheDir` also
115
+ * turns saving on.
116
+ */
117
+ save?: boolean;
118
+ /** Where to save when saving is on. Default `<cwd>/.crawldna/runs`. */
119
+ cacheDir?: string;
120
+ /**
121
+ * Also package one identifiable Markdown document per page (with metadata + a stable
122
+ * id), plus an `index.md` and a `documents.jsonl`, alongside the consolidated `.md`.
123
+ * Off by default. Pure repackaging — content stays verbatim. See {@link Scan.documents}.
124
+ */
125
+ perDocument?: boolean;
126
+ /**
127
+ * Collapse near-duplicate pages ACROSS different paths whose 64-bit SimHash is within
128
+ * this Hamming distance of ANY already-kept page. `0` (default) = off. **Opt-in**
129
+ * because content similarity alone cannot tell a duplicate from a sibling — measured
130
+ * on a real run, genuinely distinct templated API pages sat at distance ≤3 — so this
131
+ * aggressive tier can drop real content and stays a deliberate user choice.
132
+ */
133
+ nearDupHamming?: number;
134
+ /**
135
+ * Collapse mirror/variant re-servings of a kept page (default `8`, on). A page is
136
+ * dropped only when BOTH signals agree: its URL is a SIBLING of a kept page's (same
137
+ * path once a leading locale segment is stripped — mirror hosts like dev./v2., UI-state
138
+ * query variants like `?panel=settings`, locale twins `/en/x` vs `/x`) AND its content
139
+ * SimHash is within this Hamming distance. Sibling-shaped pages with real content
140
+ * differences (`?version=A` vs `?version=B`) measure far apart and are kept. Links found
141
+ * on a dropped duplicate are not followed, so mirror cascades stop at the first page.
142
+ * `0` = off.
143
+ */
144
+ mirrorHamming?: number;
145
+ /**
146
+ * Incremental re-crawl (opt-in). On a re-crawl of the same target, reuse pages whose
147
+ * sitemap `<lastmod>` — or an HTTP `304` on a static-safe page — is unchanged since the
148
+ * last `incremental` run, skipping render + reveal, and re-crawl only what changed.
149
+ * Conservative: any doubt re-crawls, so a change is never missed. Implies saving; the
150
+ * first run establishes the baseline. Default `false`.
151
+ */
152
+ incremental?: boolean;
153
+ /** Called for every event, in addition to the async iterator. */
154
+ onEvent?: (event: CrawlEvent) => void;
155
+ }
156
+
157
+ export type EventType =
158
+ | 'site'
159
+ | 'strategy'
160
+ | 'discover'
161
+ | 'page'
162
+ | 'action'
163
+ | 'extracted'
164
+ | 'dedup'
165
+ | 'incremental'
166
+ | 'resume'
167
+ | 'progress'
168
+ | 'warn'
169
+ | 'error'
170
+ | 'saved'
171
+ | 'done';
172
+
173
+ /**
174
+ * A streamed progress event. `type` is one of {@link EventType}; the remaining
175
+ * fields depend on the type (e.g. `extracted` carries `url`/`title`/`bytes`,
176
+ * `progress` carries `done`/`total`, `warn` carries `reason`/`message`). Every
177
+ * event is also stamped with the active `scanId`/`scanIndex`.
178
+ */
179
+ export interface CrawlEvent {
180
+ type: EventType;
181
+ scanId?: string;
182
+ scanIndex?: number;
183
+ [key: string]: unknown;
184
+ }
185
+
186
+ export interface PageMeta {
187
+ strategy: string;
188
+ framework?: string;
189
+ fetchedAt?: string;
190
+ bytes?: number;
191
+ /**
192
+ * The reveal exit audit (#21): characters of text still hidden in the page's main
193
+ * content when the reveal loop ended. `0` = the page was measurably drained.
194
+ * Present on browser-engine pages (`strategy: "agent"`); absent on static paths.
195
+ */
196
+ revealResidualChars?: number;
197
+ }
198
+
199
+ /** One captured reveal state, whole and verbatim — the faithful per-state record.
200
+ * The consolidated `markdown` is compact (shared frame once); this keeps each
201
+ * state's full co-occurrence recoverable (e.g. a tab/view swap, a partial change). */
202
+ export interface RevealState {
203
+ /** The state's variant marker (e.g. a tab/view label), or null for the base state. */
204
+ label: string | null;
205
+ /** How the state was surfaced: `"baseline"`/`"tab:…"`/`"expander:…"`/`"dropdown:…"`/`"loadmore"`. */
206
+ provenance: string;
207
+ /** The revealing control's vertical position (#27), used to order states. */
208
+ order: number;
209
+ /** The whole state's Markdown, verbatim. */
210
+ markdown: string;
211
+ }
212
+
213
+ export interface Page {
214
+ url: string;
215
+ task: string;
216
+ title: string;
217
+ markdown: string;
218
+ /** The faithful per-state reveal record — present only when a page's reveal
219
+ * captured MORE THAN ONE state (else the single state IS `markdown`). Also
220
+ * written to disk under `states/<page>.md` when the run is saved. */
221
+ states?: RevealState[];
222
+ meta: PageMeta;
223
+ }
224
+
225
+ /** One consolidated, verbatim Markdown file produced for a scan. */
226
+ export interface OutputFile {
227
+ filename: string;
228
+ title: string;
229
+ /** The file's full Markdown — available in memory even when nothing is saved. */
230
+ markdown: string;
231
+ bytes: number;
232
+ /** Source page URLs that contributed to this file. */
233
+ pages: string[];
234
+ }
235
+
236
+ /** AI token usage counters. `byKind` splits the same totals by call type
237
+ * (`"reveal"`, `"scope"`, `"links"`, `"nav-plan"`, `"embed"`, `"health"`, …) so cost
238
+ * can be attributed to the judgment that spent it. `cachedInputTokens` is the slice of
239
+ * `inputTokens` a remote provider served from its prompt cache (~10× cheaper);
240
+ * `0` for providers that don't report it (e.g. local Ollama). */
241
+ export interface TokenUsage {
242
+ calls: number;
243
+ inputTokens: number;
244
+ outputTokens: number;
245
+ cachedInputTokens: number;
246
+ byKind: Record<
247
+ string,
248
+ { calls: number; inputTokens: number; outputTokens: number; cachedInputTokens: number }
249
+ >;
250
+ }
251
+
252
+ export interface Stats {
253
+ pages: number;
254
+ durationMs: number;
255
+ strategyCounts: Record<string, number>;
256
+ /** AI token usage for the run/scan; input and output are billed differently. */
257
+ tokens: TokenUsage;
258
+ /** Pages dropped as duplicates, by tier: `exact` (sha1), `mirror` (URL-sibling +
259
+ * SimHash, default on), `near` (cross-path SimHash, opt-in). */
260
+ deduped?: { exact: number; mirror: number; near: number };
261
+ /** Reveal exit audit (#21): how many kept pages ended with text still hidden, and the
262
+ * total hidden characters. `{ pages: 0, chars: 0 }` = every page measurably drained. */
263
+ revealResidual?: { pages: number; chars: number };
264
+ }
265
+
266
+ export interface Warning {
267
+ url?: string;
268
+ reason?: string;
269
+ message: string;
270
+ }
271
+
272
+ /** One page packaged as an identifiable document (opt-in `perDocument`). The body is
273
+ * the page's verbatim Markdown; `id` is stable (derived from the URL). */
274
+ export interface Document {
275
+ id: string;
276
+ url: string;
277
+ title: string;
278
+ fetchedAt: string;
279
+ bytes: number;
280
+ /** The verbatim page Markdown (no header). */
281
+ markdown: string;
282
+ /** An H1–H3 outline of the page, for section paths / chunking. */
283
+ headings: Array<{ level: number; text: string }>;
284
+ /** The per-document filename written under `documents/` when the run is saved. */
285
+ file: string;
286
+ }
287
+
288
+ /** One submitted link's crawl: its own pages, output files, dedup and stats. */
289
+ export interface Scan {
290
+ scanId: string;
291
+ index: number;
292
+ url: string;
293
+ task: string;
294
+ title: string;
295
+ pages: Page[];
296
+ files: OutputFile[];
297
+ /** Per-page documents, populated only when `perDocument` is enabled; else empty. */
298
+ documents: Document[];
299
+ stats: Stats;
300
+ warnings: Warning[];
301
+ }
302
+
303
+ /** Present on the result only when the run was saved (otherwise `run` is `null`). */
304
+ export interface SavedRun {
305
+ id: string;
306
+ dir: string;
307
+ scans: unknown[];
308
+ }
309
+
310
+ export interface Result {
311
+ scans: Scan[];
312
+ stats: Stats;
313
+ warnings: Warning[];
314
+ /** Where the run was saved, or `null` when saving was off (the library default). */
315
+ run: SavedRun | null;
316
+ }
317
+
318
+ /**
319
+ * A running crawl. Async-iterate it for live {@link CrawlEvent}s, await
320
+ * {@link Run.result} for the final {@link Result}, or call {@link Run.stop} to
321
+ * end early (the result still resolves with whatever was gathered).
322
+ */
323
+ export interface Run extends AsyncIterable<CrawlEvent> {
324
+ result: Promise<Result>;
325
+ stop(): void;
326
+ }
327
+
328
+ /** The default option values; spread under any options you pass to {@link crawlDocs}. */
329
+ export declare const DEFAULT_OPTIONS: Required<
330
+ Pick<
331
+ CrawlOptions,
332
+ | 'task'
333
+ | 'model'
334
+ | 'provider'
335
+ | 'noAi'
336
+ | 'mode'
337
+ | 'browser'
338
+ | 'delay'
339
+ | 'respectRobots'
340
+ | 'concurrency'
341
+ | 'maxPages'
342
+ | 'maxActions'
343
+ | 'maxRoutes'
344
+ | 'minRelevance'
345
+ | 'nearDupHamming'
346
+ | 'mirrorHamming'
347
+ | 'save'
348
+ | 'perDocument'
349
+ >
350
+ > &
351
+ CrawlOptions;
352
+
353
+ /** Normalise the various accepted `targets` shapes into `{ url, task }[]`. */
354
+ export declare function normalizeTargets(targets: Targets, defaultTask?: string): Target[];
355
+
356
+ /**
357
+ * Crawl one or more links, each with a natural-language task, and return a
358
+ * {@link Run}. The crawl renders pages in a real browser, reveals interaction-
359
+ * hidden content, and extracts task-relevant Markdown verbatim. Nothing is written
360
+ * to disk unless `save: true` or a `cacheDir` is given. When saving is on, every
361
+ * kept page is also journaled to disk as it is captured, so an interrupted run
362
+ * can be completed later with {@link resumeCrawl}.
363
+ */
364
+ export declare function crawlDocs(targets: Targets, options?: CrawlOptions): Run;
365
+
366
+ /**
367
+ * Resume an interrupted saved run: pages already extracted are restored verbatim
368
+ * from the run's incremental journal (never re-rendered), their recorded links
369
+ * re-seed the frontier, and the crawl completes into the same run folder.
370
+ * `overrides` are merged over the run's saved options. An API key is never
371
+ * persisted — for `provider: "openai"` pass `apiKey` again (or set the env var).
372
+ * Rejects if the run is already complete.
373
+ */
374
+ export declare function resumeCrawl(runId: string, overrides?: CrawlOptions): Promise<Run>;
375
+
376
+ export default crawlDocs;
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "crawldna",
3
+ "version": "0.1.0",
4
+ "description": "General, task-driven web crawler that reveals interaction-hidden content and outputs clean Markdown.",
5
+ "type": "module",
6
+ "license": "AGPL-3.0-only",
7
+ "author": "BogdanVasaiu",
8
+ "homepage": "https://crawldna.com",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/BogdanVasaiu/crawlDNA.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/BogdanVasaiu/crawlDNA/issues"
15
+ },
16
+ "keywords": [
17
+ "crawler",
18
+ "scraper",
19
+ "markdown",
20
+ "ai",
21
+ "llm",
22
+ "playwright",
23
+ "documentation",
24
+ "extraction"
25
+ ],
26
+ "main": "./src/index.mjs",
27
+ "types": "./index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./index.d.ts",
31
+ "default": "./src/index.mjs"
32
+ }
33
+ },
34
+ "bin": {
35
+ "crawldna": "bin/cli.mjs"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "bin",
40
+ "index.d.ts",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "start": "node bin/cli.mjs",
46
+ "serve": "node bin/cli.mjs serve",
47
+ "test": "node --test \"test/*.test.mjs\"",
48
+ "eval": "node scripts/eval.mjs",
49
+ "prepublishOnly": "npm test"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "dependencies": {
55
+ "fast-xml-parser": "^4.5.0",
56
+ "node-html-parser": "^7.0.1",
57
+ "ollama": "^0.5.11",
58
+ "turndown": "^7.2.0",
59
+ "turndown-plugin-gfm": "^1.0.2"
60
+ },
61
+ "optionalDependencies": {
62
+ "playwright": "^1.49.0"
63
+ }
64
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // Low-level page actuation via Playwright. Elements are targeted by the
4
+ // `data-crawldna-id` attribute that perceive() stamped on the live DOM.
5
+ // The "wait until the click's effects landed" signal lives in lib/settle.mjs
6
+ // (#15) — shared with the initial render and the browser-escalation fetch.
7
+
8
+ import { settle } from '../lib/settle.mjs';
9
+
10
+ const SETTLE_MS = 350;
11
+
12
+ /**
13
+ * Activate a revealer by id. Guards against navigation: if the click took us to
14
+ * a new URL (the control was really a link), we record that URL and go back so
15
+ * revealing continues on the original page.
16
+ *
17
+ * @returns {Promise<{ ok: boolean, navigatedTo?: string, note?: string }>}
18
+ */
19
+ export async function clickRevealer(page, id) {
20
+ const before = page.url();
21
+ const loc = page.locator(`[data-crawldna-id="${id}"]`).first();
22
+ try {
23
+ await loc.scrollIntoViewIfNeeded({ timeout: 2500 }).catch(() => {});
24
+ await loc.click({ timeout: 4000 });
25
+ } catch (err) {
26
+ return { ok: false, note: String(err && err.message).slice(0, 120) };
27
+ }
28
+ // Wait for the click's async content (AJAX panels + any first-touch lazy-load
29
+ // cascade) to finish before the caller captures. This is what reliably gets a
30
+ // slow first reveal — e.g. the day-2 slot grid — that a single immediate
31
+ // snapshot, or networkidle alone, can miss.
32
+ await settle(page);
33
+
34
+ const after = page.url();
35
+ if (after !== before) {
36
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }).catch(() => {});
37
+ await page.waitForTimeout(SETTLE_MS);
38
+ return { ok: true, navigatedTo: after };
39
+ }
40
+ return { ok: true };
41
+ }
42
+
43
+ /**
44
+ * Scroll down one viewport to trigger lazy/infinite content.
45
+ * @returns {Promise<boolean>} whether the document grew (more content loaded).
46
+ */
47
+ export async function scrollStep(page) {
48
+ try {
49
+ const before = await page.evaluate(() => document.body.scrollHeight);
50
+ await page.evaluate(() => window.scrollBy(0, Math.round(window.innerHeight * 0.9)));
51
+ await page.waitForTimeout(500);
52
+ const after = await page.evaluate(() => document.body.scrollHeight);
53
+ return after > before + 4;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
@@ -0,0 +1,125 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // #21a — consent/overlay dismissal: the DECISION, kept pure and testable.
4
+ //
5
+ // perceive() MEASURES (a visible button-like element inside a true overlay —
6
+ // fixed/sticky/dialog/aria-modal — with its label, geometry and the overlay's
7
+ // text); THIS module DECIDES which of those buttons to click. The split is the
8
+ // whole point: the in-page code stays a thin sensor, the policy is unit-tested
9
+ // offline with multilingual fixtures.
10
+ //
11
+ // Universality argument (why a lexicon is allowed here at all): we never read
12
+ // the SITE — we read the ARTEFACT OF THE DEFENSE, the cookie/consent banner,
13
+ // which is the same everywhere because the law that mandates it is. The words
14
+ // on its buttons come from a tiny, closed vocabulary per language ("accept
15
+ // all" / "reject all" / "close"), so a micro-lexicon of ~40 stems covers the
16
+ // world's banners the way `documenta` covers the word "documentation" — it is
17
+ // the deterministic-backstop rule (#4), not a per-site rule.
18
+ //
19
+ // Policy, in order:
20
+ // 1. Overlay ABOUT consent (its text mentions cookie/consent/GDPR/privacy):
21
+ // prefer a REJECT button (closes the banner just the same, and is the more
22
+ // respectful default), then ACCEPT, then a plain dismiss (ok/close/…),
23
+ // then — banners with exotic wording — the PRIMARY button by geometry
24
+ // (largest visible button with a short label).
25
+ // 2. Any OTHER overlay (newsletter modal, app interstitial): only a plain
26
+ // dismiss/close is ever clicked. Never accept/reject words out of context,
27
+ // and NEVER geometry — the biggest button of an arbitrary dialog is
28
+ // "Subscribe"/"Log in", exactly what must not be pressed.
29
+
30
+ /** Does the overlay's own text say it is a consent/cookie banner? These four
31
+ * stems are quasi-universal: "cookie" is used verbatim in virtually every
32
+ * language, "consent/GDPR/privacy" cover the legal phrasing. */
33
+ export const CONSENT_TOPIC_RE = /cookie|consent|gdpr|privacy|rgpd|dsgvo/i;
34
+
35
+ // The micro-lexicon. Stems, not full words, so inflections match; each entry
36
+ // reads the BANNER's button, never the site's content.
37
+ const REJECT_RE = new RegExp(
38
+ [
39
+ // en / it / de / fr / es / pt / nl / scandinavian / fi / pl / cs / hu / ro / ru / zh / ja / ko
40
+ 'reject', 'decline', 'refuse', 'deny', 'necessary only', 'only necessary',
41
+ 'rifiut', 'solo necessari',
42
+ 'ablehn', 'nur notwendige',
43
+ 'refuser', 'tout refuser',
44
+ 'rechaz', 'solo necesarias',
45
+ 'recus', 'rejeit',
46
+ 'weiger', 'alleen noodzakelijk',
47
+ 'avvisa', 'avslå', 'afvis', 'kun nødvendige',
48
+ 'hylkää', 'odrzuć', 'odmítn', 'elutasít', 'resping',
49
+ 'отклон', 'отказ',
50
+ '拒绝', '全部拒绝', '拒否', '거부',
51
+ ].join('|'),
52
+ 'i',
53
+ );
54
+ const ACCEPT_RE = new RegExp(
55
+ [
56
+ 'accept', 'agree', 'allow', 'got it', 'i understand', 'understood',
57
+ 'accett', 'consent[io]', 'ho capito',
58
+ 'akzeptier', 'zustimm', 'einverstanden', 'verstanden',
59
+ 'accepter', "j'accepte", 'tout accepter',
60
+ 'acept', 'aceit', 'de acuerdo',
61
+ 'accepteer', 'akkoord',
62
+ 'acceptera', 'godkänn', 'godta', 'accepter alle', 'hyväksy',
63
+ 'akceptuj', 'zgadzam', 'přijm', 'souhlas', 'elfogad', 'accept toate',
64
+ 'принять', 'принима', 'соглас',
65
+ '同意', '接受', '同意する', '승인', '동의',
66
+ ].join('|'),
67
+ 'i',
68
+ );
69
+ const DISMISS_RE = new RegExp(
70
+ [
71
+ '^ok$', '^okay$', 'close', 'dismiss', 'continue', 'no,? thanks', 'not now', 'maybe later',
72
+ 'chiudi', 'schließen', 'weiter', 'fermer', 'continuer', 'cerrar',
73
+ 'continuar', 'fechar', 'sluiten', 'luk', 'stäng', 'lukk', 'sulje',
74
+ 'zamknij', 'zavří', 'bezár', 'închide', 'закрыть', 'продолжить',
75
+ '关闭', '閉じる', '닫기', '^[×✕x]$',
76
+ ].join('|'),
77
+ 'i',
78
+ );
79
+
80
+ /**
81
+ * Pick which overlay buttons to dismiss, one per overlay.
82
+ *
83
+ * @param {Array<{ id:number, label:string, area:number, overlayText:string, href?:string }>} candidates
84
+ * measured by perceive(): every visible short-labelled button inside a true overlay.
85
+ * @returns {Array<{ id:number, label:string }>} the buttons to click, in order.
86
+ */
87
+ export function pickConsent(candidates = []) {
88
+ // Group by overlay (candidates carry their overlay's text sample).
89
+ const overlays = new Map();
90
+ for (const c of candidates) {
91
+ if (!c || !c.label) continue;
92
+ const key = c.overlayText || '';
93
+ if (!overlays.has(key)) overlays.set(key, []);
94
+ overlays.get(key).push(c);
95
+ }
96
+
97
+ const picks = [];
98
+ for (const [overlayText, group] of overlays) {
99
+ const isConsent = CONSENT_TOPIC_RE.test(overlayText);
100
+ // A policy/settings LINK ("Cookie policy", "Learn more") is never the way to
101
+ // close a banner — buttons only. (An <a> used AS the button has no href.)
102
+ const buttons = group.filter((c) => !c.href);
103
+ const byLexicon = (re) => buttons.find((c) => re.test(c.label));
104
+
105
+ let pick = null;
106
+ if (isConsent) {
107
+ pick =
108
+ byLexicon(REJECT_RE) ||
109
+ byLexicon(ACCEPT_RE) ||
110
+ byLexicon(DISMISS_RE) ||
111
+ // Exotic wording: the PRIMARY button by geometry — banners paint their
112
+ // one-click exit as the biggest button. Short label only (a long label
113
+ // is body text, not a button caption).
114
+ [...buttons].filter((c) => c.label.length < 40).sort((a, b) => (b.area || 0) - (a.area || 0))[0] ||
115
+ null;
116
+ } else {
117
+ // Not (visibly) a consent banner: only ever a plain dismiss. No accept/
118
+ // reject out of context, no geometry — never press an arbitrary modal's
119
+ // primary action.
120
+ pick = byLexicon(DISMISS_RE) || null;
121
+ }
122
+ if (pick) picks.push({ id: pick.id, label: pick.label });
123
+ }
124
+ return picks;
125
+ }