@open-mercato/web-research-serp 0.7.1-develop.7194.1.ab4fc81f82

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.
@@ -0,0 +1,2 @@
1
+ [build:web-research-serp] found 4 entry points
2
+ [build:web-research-serp] built successfully
package/build.mjs ADDED
@@ -0,0 +1,10 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { buildPackage } from '../../scripts/build-package.mjs'
4
+
5
+ const packageDir = dirname(fileURLToPath(import.meta.url))
6
+
7
+ await buildPackage(packageDir, {
8
+ name: 'web-research-serp',
9
+ entryPoints: 'src/**/*.ts',
10
+ })
@@ -0,0 +1,102 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ blocked,
5
+ classifyPage,
6
+ defineAdapterModule,
7
+ notReady,
8
+ searchOk,
9
+ toOutcomeFailure
10
+ } from "@open-mercato/web-research";
11
+ import { z } from "zod";
12
+ import { extractSerpResults } from "./extract.js";
13
+ import { DEFAULT_ENGINES, SERP_PROFILES } from "./profiles.js";
14
+ const serpOptionsSchema = z.object({
15
+ engines: z.array(z.enum(["ddg-html", "ddg-lite"])).min(1).optional(),
16
+ /** Bytes to read per SERP; enough for a full result page, small enough to bound cost. */
17
+ maxBytes: z.number().int().min(4096).max(4 * 1024 * 1024).optional()
18
+ });
19
+ const DEFAULT_MAX_BYTES = 1024 * 1024;
20
+ const MIN_PLAUSIBLE_SERP_BYTES = 2048;
21
+ const ACCEPT_HTML = ["text/html", "application/xhtml+xml"];
22
+ function attempt(engine, request, context, maxBytes) {
23
+ const profile = SERP_PROFILES[engine];
24
+ return context.http.request(profile.buildUrl(request), {
25
+ maxBytes,
26
+ accept: ACCEPT_HTML,
27
+ signal: context.signal,
28
+ headers: { referer: profile.baseUrl }
29
+ }).then((response) => {
30
+ const results = extractSerpResults(response.body, profile.selector, profile.baseUrl, request.limit);
31
+ if (results.length > 0) return searchOk(results);
32
+ const verdict = classifyPage({
33
+ status: response.status,
34
+ contentType: response.contentType,
35
+ html: response.body,
36
+ text: ""
37
+ });
38
+ if (verdict.verdict === "blocked") {
39
+ return blocked(`${profile.label} served a challenge page (${verdict.reason ?? "unknown"})`);
40
+ }
41
+ if (response.body.length > MIN_PLAUSIBLE_SERP_BYTES) {
42
+ return blocked(
43
+ `${profile.label} returned a page we could not parse (${response.body.length} bytes, 0 results) - selectors may be stale`
44
+ );
45
+ }
46
+ return { status: "empty" };
47
+ }).catch((error) => toOutcomeFailure(error));
48
+ }
49
+ function createSerpAdapter(options) {
50
+ const engines = options.engines ?? DEFAULT_ENGINES;
51
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
52
+ return {
53
+ id: "serp-html",
54
+ kind: "serp",
55
+ capabilities: {
56
+ search: true,
57
+ fetch: false,
58
+ snippets: true,
59
+ content: false,
60
+ freshness: true,
61
+ siteFilter: true,
62
+ cost: "free"
63
+ },
64
+ /** `free` — no health check to pay for. */
65
+ probeCost: "free",
66
+ readiness: () => engines.length > 0 ? READY : notReady("no search engines are enabled"),
67
+ async search(request, context) {
68
+ let lastFailure = null;
69
+ for (const engine of engines) {
70
+ if (context.signal.aborted) break;
71
+ context.report("started", `querying ${SERP_PROFILES[engine].label}`);
72
+ const outcome = await attempt(engine, request, context, maxBytes);
73
+ if (outcome.status === "ok") {
74
+ const results = outcome.results;
75
+ context.report("ok", `${SERP_PROFILES[engine].label} returned ${results.length} result(s)`, {
76
+ resultCount: results.length
77
+ });
78
+ return outcome;
79
+ }
80
+ lastFailure = outcome;
81
+ context.report(
82
+ outcome.status === "empty" ? "empty" : "blocked",
83
+ outcome.status === "empty" ? `${SERP_PROFILES[engine].label} had no results` : outcome.reason
84
+ );
85
+ }
86
+ return lastFailure ?? { status: "empty" };
87
+ }
88
+ };
89
+ }
90
+ const serpAdapterModule = defineAdapterModule({
91
+ id: "serp-html",
92
+ kind: "serp",
93
+ contractVersion: CONTRACT_VERSION,
94
+ optionsSchema: serpOptionsSchema,
95
+ createAdapter: createSerpAdapter
96
+ });
97
+ export {
98
+ createSerpAdapter,
99
+ serpAdapterModule,
100
+ serpOptionsSchema
101
+ };
102
+ //# sourceMappingURL=adapter.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/adapter.ts"],
4
+ "sourcesContent": ["import {\n CONTRACT_VERSION,\n READY,\n blocked,\n classifyPage,\n defineAdapterModule,\n notReady,\n searchOk,\n toOutcomeFailure,\n type AdapterContext,\n type RawResult,\n type SearchAdapter,\n type SearchOutcome,\n type SearchRequest,\n} from '@open-mercato/web-research'\nimport { z } from 'zod'\nimport { extractSerpResults } from './extract'\nimport { DEFAULT_ENGINES, SERP_PROFILES, type SerpEngineId } from './profiles'\n\nexport const serpOptionsSchema = z.object({\n engines: z.array(z.enum(['ddg-html', 'ddg-lite'])).min(1).optional(),\n /** Bytes to read per SERP; enough for a full result page, small enough to bound cost. */\n maxBytes: z.number().int().min(4096).max(4 * 1024 * 1024).optional(),\n})\n\nexport type SerpOptions = z.infer<typeof serpOptionsSchema>\n\nconst DEFAULT_MAX_BYTES = 1024 * 1024\n/** Below this a 200 response is a challenge or an error page, not a result page. */\nconst MIN_PLAUSIBLE_SERP_BYTES = 2_048\n\nconst ACCEPT_HTML = ['text/html', 'application/xhtml+xml']\n\nfunction attempt(\n engine: SerpEngineId,\n request: SearchRequest,\n context: AdapterContext,\n maxBytes: number,\n): Promise<SearchOutcome> {\n const profile = SERP_PROFILES[engine]\n return context.http\n .request(profile.buildUrl(request), {\n maxBytes,\n accept: ACCEPT_HTML,\n signal: context.signal,\n headers: { referer: profile.baseUrl },\n })\n .then((response): SearchOutcome => {\n const results = extractSerpResults(response.body, profile.selector, profile.baseUrl, request.limit)\n if (results.length > 0) return searchOk(results)\n\n const verdict = classifyPage({\n status: response.status,\n contentType: response.contentType,\n html: response.body,\n text: '',\n })\n if (verdict.verdict === 'blocked') {\n return blocked(`${profile.label} served a challenge page (${verdict.reason ?? 'unknown'})`)\n }\n // A substantial page that yields nothing means our selectors no longer match\n // the engine's markup. Reporting `blocked` rather than `empty` is deliberate:\n // it escalates to the browser tier and shows up as a fault instead of\n // masquerading as a legitimate \"no results\".\n if (response.body.length > MIN_PLAUSIBLE_SERP_BYTES) {\n return blocked(\n `${profile.label} returned a page we could not parse (${response.body.length} bytes, 0 results) - selectors may be stale`,\n )\n }\n return { status: 'empty' }\n })\n .catch((error: unknown) => toOutcomeFailure(error))\n}\n\nexport function createSerpAdapter(options: SerpOptions): SearchAdapter {\n const engines = options.engines ?? DEFAULT_ENGINES\n const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES\n\n return {\n id: 'serp-html',\n kind: 'serp',\n capabilities: {\n search: true,\n fetch: false,\n snippets: true,\n content: false,\n freshness: true,\n siteFilter: true,\n cost: 'free',\n },\n /** `free` \u2014 no health check to pay for. */\n probeCost: 'free' as const,\n readiness: () => (engines.length > 0 ? READY : notReady('no search engines are enabled')),\n async search(request, context) {\n let lastFailure: SearchOutcome | null = null\n\n for (const engine of engines) {\n if (context.signal.aborted) break\n context.report('started', `querying ${SERP_PROFILES[engine].label}`)\n const outcome = await attempt(engine, request, context, maxBytes)\n if (outcome.status === 'ok') {\n const results: readonly RawResult[] = outcome.results\n context.report('ok', `${SERP_PROFILES[engine].label} returned ${results.length} result(s)`, {\n resultCount: results.length,\n })\n return outcome\n }\n lastFailure = outcome\n context.report(\n outcome.status === 'empty' ? 'empty' : 'blocked',\n outcome.status === 'empty' ? `${SERP_PROFILES[engine].label} had no results` : outcome.reason,\n )\n }\n\n return lastFailure ?? { status: 'empty' }\n },\n }\n}\n\nexport const serpAdapterModule = defineAdapterModule({\n id: 'serp-html',\n kind: 'serp',\n contractVersion: CONTRACT_VERSION,\n optionsSchema: serpOptionsSchema,\n createAdapter: createSerpAdapter,\n})\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
@@ -0,0 +1,90 @@
1
+ import { canonicalizeUrl, tokenizeHtml } from "@open-mercato/web-research";
2
+ function hasClass(attributes, wanted) {
3
+ const raw = attributes.get("class");
4
+ if (!raw) return false;
5
+ return raw.split(/\s+/).includes(wanted);
6
+ }
7
+ function extractSerpResults(html, selector, baseUrl, limit) {
8
+ const drafts = [];
9
+ let linkDepth = 0;
10
+ let pendingHref = null;
11
+ let pendingTitle = "";
12
+ let snippetDepth = 0;
13
+ let snippetTag = null;
14
+ let pendingSnippet = "";
15
+ const finishLink = () => {
16
+ if (pendingHref === null) return;
17
+ const title = pendingTitle.replace(/\s+/g, " ").trim();
18
+ let resolved;
19
+ try {
20
+ resolved = new URL(pendingHref, baseUrl).toString();
21
+ } catch {
22
+ pendingHref = null;
23
+ pendingTitle = "";
24
+ return;
25
+ }
26
+ const canonical = canonicalizeUrl(resolved);
27
+ pendingHref = null;
28
+ pendingTitle = "";
29
+ if (canonical === null) return;
30
+ drafts.push({ url: canonical.url, title, snippet: null });
31
+ };
32
+ for (const token of tokenizeHtml(html)) {
33
+ if (drafts.length >= limit && linkDepth === 0 && snippetDepth === 0) break;
34
+ if (token.kind === "open") {
35
+ if (linkDepth > 0) {
36
+ if (token.name === "a") linkDepth += 1;
37
+ continue;
38
+ }
39
+ if (snippetDepth > 0) {
40
+ if (token.name === snippetTag) snippetDepth += 1;
41
+ continue;
42
+ }
43
+ if (token.name === "a" && hasClass(token.attributes, selector.linkClass)) {
44
+ const href = token.attributes.get("href");
45
+ if (href) {
46
+ pendingHref = href;
47
+ pendingTitle = "";
48
+ linkDepth = 1;
49
+ }
50
+ continue;
51
+ }
52
+ if (selector.snippetClass && hasClass(token.attributes, selector.snippetClass)) {
53
+ snippetDepth = 1;
54
+ snippetTag = token.name;
55
+ pendingSnippet = "";
56
+ }
57
+ continue;
58
+ }
59
+ if (token.kind === "close") {
60
+ if (linkDepth > 0) {
61
+ if (token.name === "a") {
62
+ linkDepth -= 1;
63
+ if (linkDepth === 0) finishLink();
64
+ }
65
+ continue;
66
+ }
67
+ if (snippetDepth > 0 && token.name === snippetTag) {
68
+ snippetDepth -= 1;
69
+ if (snippetDepth === 0) {
70
+ const snippet = pendingSnippet.replace(/\s+/g, " ").trim();
71
+ const target = drafts[drafts.length - 1];
72
+ if (target && target.snippet === null && snippet.length > 0) target.snippet = snippet;
73
+ pendingSnippet = "";
74
+ }
75
+ }
76
+ continue;
77
+ }
78
+ if (linkDepth > 0) pendingTitle += token.value;
79
+ else if (snippetDepth > 0) pendingSnippet += token.value;
80
+ }
81
+ return drafts.slice(0, limit).map((draft) => ({
82
+ url: draft.url,
83
+ title: draft.title.length > 0 ? draft.title : draft.url,
84
+ snippet: draft.snippet
85
+ }));
86
+ }
87
+ export {
88
+ extractSerpResults
89
+ };
90
+ //# sourceMappingURL=extract.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/extract.ts"],
4
+ "sourcesContent": ["import { canonicalizeUrl, tokenizeHtml, type RawResult } from '@open-mercato/web-research'\n\nexport type SerpSelector = {\n /** Class carried by each result's primary anchor. */\n readonly linkClass: string\n /** Class carried by each result's snippet element. */\n readonly snippetClass?: string\n}\n\nfunction hasClass(attributes: ReadonlyMap<string, string>, wanted: string): boolean {\n const raw = attributes.get('class')\n if (!raw) return false\n return raw.split(/\\s+/).includes(wanted)\n}\n\ntype Draft = {\n url: string\n title: string\n snippet: string | null\n}\n\n/**\n * Pulls results out of a search engine's HTML.\n *\n * Runs over the shared tokenizer rather than regex: SERP markup is deeply nested\n * and attribute-heavy, which is exactly where a lazy-quantifier regex either\n * mismatches or stalls.\n */\nexport function extractSerpResults(\n html: string,\n selector: SerpSelector,\n baseUrl: string,\n limit: number,\n): RawResult[] {\n const drafts: Draft[] = []\n let linkDepth = 0\n let pendingHref: string | null = null\n let pendingTitle = ''\n let snippetDepth = 0\n let snippetTag: string | null = null\n let pendingSnippet = ''\n\n const finishLink = (): void => {\n if (pendingHref === null) return\n const title = pendingTitle.replace(/\\s+/g, ' ').trim()\n let resolved: string\n try {\n resolved = new URL(pendingHref, baseUrl).toString()\n } catch {\n pendingHref = null\n pendingTitle = ''\n return\n }\n // Engines wrap outbound links in their own redirector; canonicalizeUrl\n // unwraps the known ones and rejects anything that is not http(s).\n const canonical = canonicalizeUrl(resolved)\n pendingHref = null\n pendingTitle = ''\n if (canonical === null) return\n drafts.push({ url: canonical.url, title, snippet: null })\n }\n\n for (const token of tokenizeHtml(html)) {\n if (drafts.length >= limit && linkDepth === 0 && snippetDepth === 0) break\n\n if (token.kind === 'open') {\n if (linkDepth > 0) {\n if (token.name === 'a') linkDepth += 1\n continue\n }\n if (snippetDepth > 0) {\n if (token.name === snippetTag) snippetDepth += 1\n continue\n }\n if (token.name === 'a' && hasClass(token.attributes, selector.linkClass)) {\n const href = token.attributes.get('href')\n if (href) {\n pendingHref = href\n pendingTitle = ''\n linkDepth = 1\n }\n continue\n }\n if (selector.snippetClass && hasClass(token.attributes, selector.snippetClass)) {\n snippetDepth = 1\n snippetTag = token.name\n pendingSnippet = ''\n }\n continue\n }\n\n if (token.kind === 'close') {\n // Only the capturing element's own close ends a capture. Decrementing on\n // any close tag would truncate at the first nested `<b>`/`<span>`, which\n // real SERP markup is full of.\n if (linkDepth > 0) {\n if (token.name === 'a') {\n linkDepth -= 1\n if (linkDepth === 0) finishLink()\n }\n continue\n }\n if (snippetDepth > 0 && token.name === snippetTag) {\n snippetDepth -= 1\n if (snippetDepth === 0) {\n const snippet = pendingSnippet.replace(/\\s+/g, ' ').trim()\n const target = drafts[drafts.length - 1]\n if (target && target.snippet === null && snippet.length > 0) target.snippet = snippet\n pendingSnippet = ''\n }\n }\n continue\n }\n\n if (linkDepth > 0) pendingTitle += token.value\n else if (snippetDepth > 0) pendingSnippet += token.value\n }\n\n return drafts.slice(0, limit).map((draft) => ({\n url: draft.url,\n title: draft.title.length > 0 ? draft.title : draft.url,\n snippet: draft.snippet,\n }))\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB,oBAAoC;AAS9D,SAAS,SAAS,YAAyC,QAAyB;AAClF,QAAM,MAAM,WAAW,IAAI,OAAO;AAClC,MAAI,CAAC,IAAK,QAAO;AACjB,SAAO,IAAI,MAAM,KAAK,EAAE,SAAS,MAAM;AACzC;AAeO,SAAS,mBACd,MACA,UACA,SACA,OACa;AACb,QAAM,SAAkB,CAAC;AACzB,MAAI,YAAY;AAChB,MAAI,cAA6B;AACjC,MAAI,eAAe;AACnB,MAAI,eAAe;AACnB,MAAI,aAA4B;AAChC,MAAI,iBAAiB;AAErB,QAAM,aAAa,MAAY;AAC7B,QAAI,gBAAgB,KAAM;AAC1B,UAAM,QAAQ,aAAa,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACrD,QAAI;AACJ,QAAI;AACF,iBAAW,IAAI,IAAI,aAAa,OAAO,EAAE,SAAS;AAAA,IACpD,QAAQ;AACN,oBAAc;AACd,qBAAe;AACf;AAAA,IACF;AAGA,UAAM,YAAY,gBAAgB,QAAQ;AAC1C,kBAAc;AACd,mBAAe;AACf,QAAI,cAAc,KAAM;AACxB,WAAO,KAAK,EAAE,KAAK,UAAU,KAAK,OAAO,SAAS,KAAK,CAAC;AAAA,EAC1D;AAEA,aAAW,SAAS,aAAa,IAAI,GAAG;AACtC,QAAI,OAAO,UAAU,SAAS,cAAc,KAAK,iBAAiB,EAAG;AAErE,QAAI,MAAM,SAAS,QAAQ;AACzB,UAAI,YAAY,GAAG;AACjB,YAAI,MAAM,SAAS,IAAK,cAAa;AACrC;AAAA,MACF;AACA,UAAI,eAAe,GAAG;AACpB,YAAI,MAAM,SAAS,WAAY,iBAAgB;AAC/C;AAAA,MACF;AACA,UAAI,MAAM,SAAS,OAAO,SAAS,MAAM,YAAY,SAAS,SAAS,GAAG;AACxE,cAAM,OAAO,MAAM,WAAW,IAAI,MAAM;AACxC,YAAI,MAAM;AACR,wBAAc;AACd,yBAAe;AACf,sBAAY;AAAA,QACd;AACA;AAAA,MACF;AACA,UAAI,SAAS,gBAAgB,SAAS,MAAM,YAAY,SAAS,YAAY,GAAG;AAC9E,uBAAe;AACf,qBAAa,MAAM;AACnB,yBAAiB;AAAA,MACnB;AACA;AAAA,IACF;AAEA,QAAI,MAAM,SAAS,SAAS;AAI1B,UAAI,YAAY,GAAG;AACjB,YAAI,MAAM,SAAS,KAAK;AACtB,uBAAa;AACb,cAAI,cAAc,EAAG,YAAW;AAAA,QAClC;AACA;AAAA,MACF;AACA,UAAI,eAAe,KAAK,MAAM,SAAS,YAAY;AACjD,wBAAgB;AAChB,YAAI,iBAAiB,GAAG;AACtB,gBAAM,UAAU,eAAe,QAAQ,QAAQ,GAAG,EAAE,KAAK;AACzD,gBAAM,SAAS,OAAO,OAAO,SAAS,CAAC;AACvC,cAAI,UAAU,OAAO,YAAY,QAAQ,QAAQ,SAAS,EAAG,QAAO,UAAU;AAC9E,2BAAiB;AAAA,QACnB;AAAA,MACF;AACA;AAAA,IACF;AAEA,QAAI,YAAY,EAAG,iBAAgB,MAAM;AAAA,aAChC,eAAe,EAAG,mBAAkB,MAAM;AAAA,EACrD;AAEA,SAAO,OAAO,MAAM,GAAG,KAAK,EAAE,IAAI,CAAC,WAAW;AAAA,IAC5C,KAAK,MAAM;AAAA,IACX,OAAO,MAAM,MAAM,SAAS,IAAI,MAAM,QAAQ,MAAM;AAAA,IACpD,SAAS,MAAM;AAAA,EACjB,EAAE;AACJ;",
6
+ "names": []
7
+ }
package/dist/index.js ADDED
@@ -0,0 +1,14 @@
1
+ import { createSerpAdapter, serpAdapterModule, serpOptionsSchema } from "./adapter.js";
2
+ import { extractSerpResults } from "./extract.js";
3
+ import { DEFAULT_ENGINES, SERP_PROFILES } from "./profiles.js";
4
+ import { serpAdapterModule as serpAdapterModule2 } from "./adapter.js";
5
+ export {
6
+ DEFAULT_ENGINES,
7
+ SERP_PROFILES,
8
+ createSerpAdapter,
9
+ serpAdapterModule2 as default,
10
+ extractSerpResults,
11
+ serpAdapterModule,
12
+ serpOptionsSchema
13
+ };
14
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/index.ts"],
4
+ "sourcesContent": ["export { createSerpAdapter, serpAdapterModule, serpOptionsSchema, type SerpOptions } from './adapter'\nexport { extractSerpResults, type SerpSelector } from './extract'\nexport { DEFAULT_ENGINES, SERP_PROFILES, type SerpEngineId, type SerpEngineProfile } from './profiles'\n\nexport { serpAdapterModule as default } from './adapter'\n"],
5
+ "mappings": "AAAA,SAAS,mBAAmB,mBAAmB,yBAA2C;AAC1F,SAAS,0BAA6C;AACtD,SAAS,iBAAiB,qBAAgE;AAE1F,SAA8B,qBAArBA,0BAAoC;",
6
+ "names": ["serpAdapterModule"]
7
+ }
@@ -0,0 +1,49 @@
1
+ function buildQuery(request) {
2
+ const parts = [request.query];
3
+ if (request.site) parts.push(`site:${request.site}`);
4
+ return parts.join(" ");
5
+ }
6
+ function freshnessParam(request) {
7
+ switch (request.freshness) {
8
+ case "day":
9
+ return "d";
10
+ case "week":
11
+ return "w";
12
+ case "month":
13
+ return "m";
14
+ case "year":
15
+ return "y";
16
+ default:
17
+ return null;
18
+ }
19
+ }
20
+ function duckDuckGoUrl(base, request) {
21
+ const url = new URL(base);
22
+ url.searchParams.set("q", buildQuery(request));
23
+ const freshness = freshnessParam(request);
24
+ if (freshness) url.searchParams.set("df", freshness);
25
+ if (request.locale) url.searchParams.set("kl", request.locale.toLowerCase().replace("_", "-"));
26
+ return url.toString();
27
+ }
28
+ const SERP_PROFILES = {
29
+ "ddg-html": {
30
+ id: "ddg-html",
31
+ label: "DuckDuckGo (HTML)",
32
+ baseUrl: "https://html.duckduckgo.com/html/",
33
+ selector: { linkClass: "result__a", snippetClass: "result__snippet" },
34
+ buildUrl: (request) => duckDuckGoUrl("https://html.duckduckgo.com/html/", request)
35
+ },
36
+ "ddg-lite": {
37
+ id: "ddg-lite",
38
+ label: "DuckDuckGo (Lite)",
39
+ baseUrl: "https://lite.duckduckgo.com/lite/",
40
+ selector: { linkClass: "result-link", snippetClass: "result-snippet" },
41
+ buildUrl: (request) => duckDuckGoUrl("https://lite.duckduckgo.com/lite/", request)
42
+ }
43
+ };
44
+ const DEFAULT_ENGINES = ["ddg-html", "ddg-lite"];
45
+ export {
46
+ DEFAULT_ENGINES,
47
+ SERP_PROFILES
48
+ };
49
+ //# sourceMappingURL=profiles.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/profiles.ts"],
4
+ "sourcesContent": ["import type { SearchRequest } from '@open-mercato/web-research'\nimport type { SerpSelector } from './extract'\n\nexport type SerpEngineId = 'ddg-html' | 'ddg-lite'\n\nexport type SerpEngineProfile = {\n readonly id: SerpEngineId\n readonly label: string\n readonly baseUrl: string\n readonly selector: SerpSelector\n buildUrl(request: SearchRequest): string\n}\n\nfunction buildQuery(request: SearchRequest): string {\n const parts = [request.query]\n if (request.site) parts.push(`site:${request.site}`)\n return parts.join(' ')\n}\n\n/** DuckDuckGo maps a freshness window onto its `df` parameter. */\nfunction freshnessParam(request: SearchRequest): string | null {\n switch (request.freshness) {\n case 'day':\n return 'd'\n case 'week':\n return 'w'\n case 'month':\n return 'm'\n case 'year':\n return 'y'\n default:\n return null\n }\n}\n\nfunction duckDuckGoUrl(base: string, request: SearchRequest): string {\n const url = new URL(base)\n url.searchParams.set('q', buildQuery(request))\n const freshness = freshnessParam(request)\n if (freshness) url.searchParams.set('df', freshness)\n if (request.locale) url.searchParams.set('kl', request.locale.toLowerCase().replace('_', '-'))\n return url.toString()\n}\n\n/**\n * Engine markup is a contract nobody signed \u2014 it changes without notice. Keeping\n * profiles as data means a break is a one-line selector edit plus a fixture, and\n * the adapter's zero-results self-check turns a silent parser rot into a visible\n * `blocked` diagnostic rather than an empty answer.\n */\nexport const SERP_PROFILES: Readonly<Record<SerpEngineId, SerpEngineProfile>> = {\n 'ddg-html': {\n id: 'ddg-html',\n label: 'DuckDuckGo (HTML)',\n baseUrl: 'https://html.duckduckgo.com/html/',\n selector: { linkClass: 'result__a', snippetClass: 'result__snippet' },\n buildUrl: (request) => duckDuckGoUrl('https://html.duckduckgo.com/html/', request),\n },\n 'ddg-lite': {\n id: 'ddg-lite',\n label: 'DuckDuckGo (Lite)',\n baseUrl: 'https://lite.duckduckgo.com/lite/',\n selector: { linkClass: 'result-link', snippetClass: 'result-snippet' },\n buildUrl: (request) => duckDuckGoUrl('https://lite.duckduckgo.com/lite/', request),\n },\n}\n\nexport const DEFAULT_ENGINES: readonly SerpEngineId[] = ['ddg-html', 'ddg-lite']\n"],
5
+ "mappings": "AAaA,SAAS,WAAW,SAAgC;AAClD,QAAM,QAAQ,CAAC,QAAQ,KAAK;AAC5B,MAAI,QAAQ,KAAM,OAAM,KAAK,QAAQ,QAAQ,IAAI,EAAE;AACnD,SAAO,MAAM,KAAK,GAAG;AACvB;AAGA,SAAS,eAAe,SAAuC;AAC7D,UAAQ,QAAQ,WAAW;AAAA,IACzB,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT,KAAK;AACH,aAAO;AAAA,IACT;AACE,aAAO;AAAA,EACX;AACF;AAEA,SAAS,cAAc,MAAc,SAAgC;AACnE,QAAM,MAAM,IAAI,IAAI,IAAI;AACxB,MAAI,aAAa,IAAI,KAAK,WAAW,OAAO,CAAC;AAC7C,QAAM,YAAY,eAAe,OAAO;AACxC,MAAI,UAAW,KAAI,aAAa,IAAI,MAAM,SAAS;AACnD,MAAI,QAAQ,OAAQ,KAAI,aAAa,IAAI,MAAM,QAAQ,OAAO,YAAY,EAAE,QAAQ,KAAK,GAAG,CAAC;AAC7F,SAAO,IAAI,SAAS;AACtB;AAQO,MAAM,gBAAmE;AAAA,EAC9E,YAAY;AAAA,IACV,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,SAAS;AAAA,IACT,UAAU,EAAE,WAAW,aAAa,cAAc,kBAAkB;AAAA,IACpE,UAAU,CAAC,YAAY,cAAc,qCAAqC,OAAO;AAAA,EACnF;AAAA,EACA,YAAY;AAAA,IACV,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,SAAS;AAAA,IACT,UAAU,EAAE,WAAW,eAAe,cAAc,iBAAiB;AAAA,IACrE,UAAU,CAAC,YAAY,cAAc,qCAAqC,OAAO;AAAA,EACnF;AACF;AAEO,MAAM,kBAA2C,CAAC,YAAY,UAAU;",
6
+ "names": []
7
+ }
@@ -0,0 +1,28 @@
1
+ /** @type {import('jest').Config} */
2
+ const base = require('../../jest.config.base.cjs')
3
+
4
+ module.exports = {
5
+ ...base,
6
+ preset: 'ts-jest',
7
+ testEnvironment: 'node',
8
+ watchman: false,
9
+ rootDir: '.',
10
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
11
+ moduleNameMapper: {
12
+ '^@open-mercato/web-research/(.*)$': '<rootDir>/../web-research/src/$1',
13
+ '^@open-mercato/web-research$': '<rootDir>/../web-research/src/index.ts',
14
+ },
15
+ transform: {
16
+ '^.+\\.(t|j)sx?$': [
17
+ 'ts-jest',
18
+ {
19
+ tsconfig: {
20
+ rootDir: '.',
21
+ ignoreDeprecations: '6.0',
22
+ },
23
+ },
24
+ ],
25
+ },
26
+ testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
27
+ passWithNoTests: true,
28
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@open-mercato/web-research-serp",
3
+ "version": "0.7.1-develop.7194.1.ab4fc81f82",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "openMercato": {
8
+ "webResearchAdapter": {
9
+ "id": "serp-html"
10
+ }
11
+ },
12
+ "scripts": {
13
+ "build": "node build.mjs",
14
+ "watch": "node watch.mjs",
15
+ "test": "jest --config jest.config.cjs",
16
+ "typecheck": "tsc --noEmit"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "types": "./src/index.ts",
21
+ "default": "./dist/index.js"
22
+ },
23
+ "./*.ts": {
24
+ "types": "./src/*.ts",
25
+ "default": "./dist/*.js"
26
+ },
27
+ "./*": {
28
+ "types": [
29
+ "./src/*.ts"
30
+ ],
31
+ "default": "./dist/*.js"
32
+ }
33
+ },
34
+ "peerDependencies": {
35
+ "@open-mercato/web-research": "^0.6.0"
36
+ },
37
+ "dependencies": {
38
+ "zod": "^4.4.3"
39
+ },
40
+ "devDependencies": {
41
+ "@open-mercato/web-research": "0.7.1-develop.7194.1.ab4fc81f82",
42
+ "@types/jest": "^30.0.0",
43
+ "@types/node": "^26.0.1",
44
+ "esbuild": "^0.28.1",
45
+ "glob": "^13.0.6",
46
+ "jest": "^30.4.2",
47
+ "ts-jest": "^29.4.11",
48
+ "typescript": "^6.0.3"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "https://github.com/open-mercato/open-mercato",
56
+ "directory": "packages/web-research-serp"
57
+ },
58
+ "stableVersion": "0.7.0"
59
+ }
@@ -0,0 +1,154 @@
1
+ import { createStubHttpClient, createTestContext, describeAdapterContract } from '@open-mercato/web-research/testing'
2
+ import { createSerpAdapter, serpAdapterModule } from '../adapter'
3
+ import { extractSerpResults } from '../extract'
4
+ import { SERP_PROFILES } from '../profiles'
5
+
6
+ const DDG_HTML = `
7
+ <!DOCTYPE html>
8
+ <html><body>
9
+ <div class="results">
10
+ <div class="result results_links">
11
+ <h2 class="result__title">
12
+ <a rel="nofollow" class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fone&amp;rut=x">First &amp; best</a>
13
+ </h2>
14
+ <a class="result__snippet" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fone">Snippet <b>one</b> here.</a>
15
+ </div>
16
+ <div class="result results_links">
17
+ <h2 class="result__title">
18
+ <a rel="nofollow" class="result__a" href="https://other.org/two">Second result</a>
19
+ </h2>
20
+ <div class='result__snippet'>Snippet two.</div>
21
+ </div>
22
+ <div class="result results_links result--ad">
23
+ <a class="result__a" href="javascript:void(0)">Not a real link</a>
24
+ </div>
25
+ </div>
26
+ </body></html>`
27
+
28
+ const CHALLENGE_HTML = `<html><body><h1>Just a moment...</h1><div id="cf_chl_opt"></div>${'x'.repeat(3000)}</body></html>`
29
+
30
+ describe('extractSerpResults', () => {
31
+ it('reads title, url and snippet, unwrapping the engine redirector', () => {
32
+ const results = extractSerpResults(DDG_HTML, SERP_PROFILES['ddg-html'].selector, 'https://html.duckduckgo.com/html/', 10)
33
+
34
+ expect(results).toEqual([
35
+ { url: 'https://example.com/one', title: 'First & best', snippet: 'Snippet one here.' },
36
+ { url: 'https://other.org/two', title: 'Second result', snippet: 'Snippet two.' },
37
+ ])
38
+ })
39
+
40
+ it('keeps text that spans nested inline markup', () => {
41
+ const html = `<a class="result__a" href="https://e.com/x">Deep <b>bold <i>and</i></b> tail</a>`
42
+ const results = extractSerpResults(html, SERP_PROFILES['ddg-html'].selector, 'https://e.com/', 10)
43
+ expect(results[0].title).toBe('Deep bold and tail')
44
+ })
45
+
46
+ it('drops entries whose href is not http(s)', () => {
47
+ const results = extractSerpResults(DDG_HTML, SERP_PROFILES['ddg-html'].selector, 'https://html.duckduckgo.com/html/', 10)
48
+ expect(results.map((result) => result.title)).not.toContain('Not a real link')
49
+ })
50
+
51
+ it('respects the requested limit', () => {
52
+ const results = extractSerpResults(DDG_HTML, SERP_PROFILES['ddg-html'].selector, 'https://html.duckduckgo.com/html/', 1)
53
+ expect(results).toHaveLength(1)
54
+ })
55
+
56
+ it('returns nothing when the markup does not match', () => {
57
+ expect(extractSerpResults('<html><body><p>nope</p></body></html>', SERP_PROFILES['ddg-html'].selector, 'https://x/', 10)).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('serp adapter', () => {
62
+ it('returns results from the first engine that answers', async () => {
63
+ const http = createStubHttpClient(() => ({ body: DDG_HTML }))
64
+ const adapter = createSerpAdapter({})
65
+
66
+ const outcome = await adapter.search({ query: 'test', limit: 10 }, createTestContext({ http }))
67
+
68
+ expect(outcome.status).toBe('ok')
69
+ expect(http.calls).toHaveLength(1)
70
+ expect(http.calls[0]).toContain('html.duckduckgo.com')
71
+ })
72
+
73
+ it('falls through to the next engine when the first is blocked', async () => {
74
+ const isDuckDuckGoHtmlHost = (candidateUrl: string) => {
75
+ try {
76
+ return new URL(candidateUrl).hostname === 'html.duckduckgo.com'
77
+ } catch {
78
+ return false
79
+ }
80
+ }
81
+
82
+ const http = createStubHttpClient((url) =>
83
+ isDuckDuckGoHtmlHost(url)
84
+ ? { body: CHALLENGE_HTML, status: 200 }
85
+ : { body: DDG_HTML.replace(/result__a/g, 'result-link').replace(/result__snippet/g, 'result-snippet') },
86
+ )
87
+ const adapter = createSerpAdapter({})
88
+
89
+ const outcome = await adapter.search({ query: 'test', limit: 10 }, createTestContext({ http }))
90
+
91
+ expect(outcome.status).toBe('ok')
92
+ expect(http.calls).toHaveLength(2)
93
+ })
94
+
95
+ it('reports a substantial unparseable page as blocked, not empty', async () => {
96
+ // Stale selectors must surface as a fault. Reporting `empty` would let the
97
+ // engine quietly return nothing forever after a markup change.
98
+ const http = createStubHttpClient(() => ({ body: `<html><body>${'<div>x</div>'.repeat(400)}</body></html>` }))
99
+ const adapter = createSerpAdapter({ engines: ['ddg-html'] })
100
+
101
+ const outcome = await adapter.search({ query: 'test', limit: 10 }, createTestContext({ http }))
102
+
103
+ expect(outcome.status).toBe('blocked')
104
+ if (outcome.status === 'blocked') expect(outcome.reason).toContain('selectors may be stale')
105
+ })
106
+
107
+ it('reports a tiny empty page as empty', async () => {
108
+ const http = createStubHttpClient(() => ({ body: '<html><body></body></html>' }))
109
+ const adapter = createSerpAdapter({ engines: ['ddg-html'] })
110
+
111
+ const outcome = await adapter.search({ query: 'test', limit: 10 }, createTestContext({ http }))
112
+
113
+ expect(outcome.status).toBe('empty')
114
+ })
115
+
116
+ it('passes site and freshness filters through to the engine URL', async () => {
117
+ const http = createStubHttpClient(() => ({ body: DDG_HTML }))
118
+ const adapter = createSerpAdapter({ engines: ['ddg-html'] })
119
+
120
+ await adapter.search(
121
+ { query: 'pricing', limit: 5, site: 'example.com', freshness: 'week', locale: 'en-US' },
122
+ createTestContext({ http }),
123
+ )
124
+
125
+ const requested = decodeURIComponent(http.calls[0])
126
+ expect(requested).toContain('site:example.com')
127
+ expect(requested).toContain('df=w')
128
+ expect(requested).toContain('kl=en-us')
129
+ })
130
+
131
+ it('stops early when the caller aborts', async () => {
132
+ const http = createStubHttpClient(() => ({ body: CHALLENGE_HTML }))
133
+ const controller = new AbortController()
134
+ controller.abort()
135
+ const adapter = createSerpAdapter({})
136
+
137
+ const outcome = await adapter.search(
138
+ { query: 'test', limit: 10 },
139
+ createTestContext({ http, signal: controller.signal }),
140
+ )
141
+
142
+ expect(outcome.status).toBe('empty')
143
+ expect(http.calls).toHaveLength(0)
144
+ })
145
+ })
146
+
147
+ describeAdapterContract(
148
+ serpAdapterModule,
149
+ {
150
+ configuredOptions: {},
151
+ context: () => createTestContext({ http: createStubHttpClient(() => ({ body: DDG_HTML })) }),
152
+ },
153
+ { describe, it, expect: expect as never },
154
+ )
package/src/adapter.ts ADDED
@@ -0,0 +1,126 @@
1
+ import {
2
+ CONTRACT_VERSION,
3
+ READY,
4
+ blocked,
5
+ classifyPage,
6
+ defineAdapterModule,
7
+ notReady,
8
+ searchOk,
9
+ toOutcomeFailure,
10
+ type AdapterContext,
11
+ type RawResult,
12
+ type SearchAdapter,
13
+ type SearchOutcome,
14
+ type SearchRequest,
15
+ } from '@open-mercato/web-research'
16
+ import { z } from 'zod'
17
+ import { extractSerpResults } from './extract'
18
+ import { DEFAULT_ENGINES, SERP_PROFILES, type SerpEngineId } from './profiles'
19
+
20
+ export const serpOptionsSchema = z.object({
21
+ engines: z.array(z.enum(['ddg-html', 'ddg-lite'])).min(1).optional(),
22
+ /** Bytes to read per SERP; enough for a full result page, small enough to bound cost. */
23
+ maxBytes: z.number().int().min(4096).max(4 * 1024 * 1024).optional(),
24
+ })
25
+
26
+ export type SerpOptions = z.infer<typeof serpOptionsSchema>
27
+
28
+ const DEFAULT_MAX_BYTES = 1024 * 1024
29
+ /** Below this a 200 response is a challenge or an error page, not a result page. */
30
+ const MIN_PLAUSIBLE_SERP_BYTES = 2_048
31
+
32
+ const ACCEPT_HTML = ['text/html', 'application/xhtml+xml']
33
+
34
+ function attempt(
35
+ engine: SerpEngineId,
36
+ request: SearchRequest,
37
+ context: AdapterContext,
38
+ maxBytes: number,
39
+ ): Promise<SearchOutcome> {
40
+ const profile = SERP_PROFILES[engine]
41
+ return context.http
42
+ .request(profile.buildUrl(request), {
43
+ maxBytes,
44
+ accept: ACCEPT_HTML,
45
+ signal: context.signal,
46
+ headers: { referer: profile.baseUrl },
47
+ })
48
+ .then((response): SearchOutcome => {
49
+ const results = extractSerpResults(response.body, profile.selector, profile.baseUrl, request.limit)
50
+ if (results.length > 0) return searchOk(results)
51
+
52
+ const verdict = classifyPage({
53
+ status: response.status,
54
+ contentType: response.contentType,
55
+ html: response.body,
56
+ text: '',
57
+ })
58
+ if (verdict.verdict === 'blocked') {
59
+ return blocked(`${profile.label} served a challenge page (${verdict.reason ?? 'unknown'})`)
60
+ }
61
+ // A substantial page that yields nothing means our selectors no longer match
62
+ // the engine's markup. Reporting `blocked` rather than `empty` is deliberate:
63
+ // it escalates to the browser tier and shows up as a fault instead of
64
+ // masquerading as a legitimate "no results".
65
+ if (response.body.length > MIN_PLAUSIBLE_SERP_BYTES) {
66
+ return blocked(
67
+ `${profile.label} returned a page we could not parse (${response.body.length} bytes, 0 results) - selectors may be stale`,
68
+ )
69
+ }
70
+ return { status: 'empty' }
71
+ })
72
+ .catch((error: unknown) => toOutcomeFailure(error))
73
+ }
74
+
75
+ export function createSerpAdapter(options: SerpOptions): SearchAdapter {
76
+ const engines = options.engines ?? DEFAULT_ENGINES
77
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES
78
+
79
+ return {
80
+ id: 'serp-html',
81
+ kind: 'serp',
82
+ capabilities: {
83
+ search: true,
84
+ fetch: false,
85
+ snippets: true,
86
+ content: false,
87
+ freshness: true,
88
+ siteFilter: true,
89
+ cost: 'free',
90
+ },
91
+ /** `free` — no health check to pay for. */
92
+ probeCost: 'free' as const,
93
+ readiness: () => (engines.length > 0 ? READY : notReady('no search engines are enabled')),
94
+ async search(request, context) {
95
+ let lastFailure: SearchOutcome | null = null
96
+
97
+ for (const engine of engines) {
98
+ if (context.signal.aborted) break
99
+ context.report('started', `querying ${SERP_PROFILES[engine].label}`)
100
+ const outcome = await attempt(engine, request, context, maxBytes)
101
+ if (outcome.status === 'ok') {
102
+ const results: readonly RawResult[] = outcome.results
103
+ context.report('ok', `${SERP_PROFILES[engine].label} returned ${results.length} result(s)`, {
104
+ resultCount: results.length,
105
+ })
106
+ return outcome
107
+ }
108
+ lastFailure = outcome
109
+ context.report(
110
+ outcome.status === 'empty' ? 'empty' : 'blocked',
111
+ outcome.status === 'empty' ? `${SERP_PROFILES[engine].label} had no results` : outcome.reason,
112
+ )
113
+ }
114
+
115
+ return lastFailure ?? { status: 'empty' }
116
+ },
117
+ }
118
+ }
119
+
120
+ export const serpAdapterModule = defineAdapterModule({
121
+ id: 'serp-html',
122
+ kind: 'serp',
123
+ contractVersion: CONTRACT_VERSION,
124
+ optionsSchema: serpOptionsSchema,
125
+ createAdapter: createSerpAdapter,
126
+ })
package/src/extract.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { canonicalizeUrl, tokenizeHtml, type RawResult } from '@open-mercato/web-research'
2
+
3
+ export type SerpSelector = {
4
+ /** Class carried by each result's primary anchor. */
5
+ readonly linkClass: string
6
+ /** Class carried by each result's snippet element. */
7
+ readonly snippetClass?: string
8
+ }
9
+
10
+ function hasClass(attributes: ReadonlyMap<string, string>, wanted: string): boolean {
11
+ const raw = attributes.get('class')
12
+ if (!raw) return false
13
+ return raw.split(/\s+/).includes(wanted)
14
+ }
15
+
16
+ type Draft = {
17
+ url: string
18
+ title: string
19
+ snippet: string | null
20
+ }
21
+
22
+ /**
23
+ * Pulls results out of a search engine's HTML.
24
+ *
25
+ * Runs over the shared tokenizer rather than regex: SERP markup is deeply nested
26
+ * and attribute-heavy, which is exactly where a lazy-quantifier regex either
27
+ * mismatches or stalls.
28
+ */
29
+ export function extractSerpResults(
30
+ html: string,
31
+ selector: SerpSelector,
32
+ baseUrl: string,
33
+ limit: number,
34
+ ): RawResult[] {
35
+ const drafts: Draft[] = []
36
+ let linkDepth = 0
37
+ let pendingHref: string | null = null
38
+ let pendingTitle = ''
39
+ let snippetDepth = 0
40
+ let snippetTag: string | null = null
41
+ let pendingSnippet = ''
42
+
43
+ const finishLink = (): void => {
44
+ if (pendingHref === null) return
45
+ const title = pendingTitle.replace(/\s+/g, ' ').trim()
46
+ let resolved: string
47
+ try {
48
+ resolved = new URL(pendingHref, baseUrl).toString()
49
+ } catch {
50
+ pendingHref = null
51
+ pendingTitle = ''
52
+ return
53
+ }
54
+ // Engines wrap outbound links in their own redirector; canonicalizeUrl
55
+ // unwraps the known ones and rejects anything that is not http(s).
56
+ const canonical = canonicalizeUrl(resolved)
57
+ pendingHref = null
58
+ pendingTitle = ''
59
+ if (canonical === null) return
60
+ drafts.push({ url: canonical.url, title, snippet: null })
61
+ }
62
+
63
+ for (const token of tokenizeHtml(html)) {
64
+ if (drafts.length >= limit && linkDepth === 0 && snippetDepth === 0) break
65
+
66
+ if (token.kind === 'open') {
67
+ if (linkDepth > 0) {
68
+ if (token.name === 'a') linkDepth += 1
69
+ continue
70
+ }
71
+ if (snippetDepth > 0) {
72
+ if (token.name === snippetTag) snippetDepth += 1
73
+ continue
74
+ }
75
+ if (token.name === 'a' && hasClass(token.attributes, selector.linkClass)) {
76
+ const href = token.attributes.get('href')
77
+ if (href) {
78
+ pendingHref = href
79
+ pendingTitle = ''
80
+ linkDepth = 1
81
+ }
82
+ continue
83
+ }
84
+ if (selector.snippetClass && hasClass(token.attributes, selector.snippetClass)) {
85
+ snippetDepth = 1
86
+ snippetTag = token.name
87
+ pendingSnippet = ''
88
+ }
89
+ continue
90
+ }
91
+
92
+ if (token.kind === 'close') {
93
+ // Only the capturing element's own close ends a capture. Decrementing on
94
+ // any close tag would truncate at the first nested `<b>`/`<span>`, which
95
+ // real SERP markup is full of.
96
+ if (linkDepth > 0) {
97
+ if (token.name === 'a') {
98
+ linkDepth -= 1
99
+ if (linkDepth === 0) finishLink()
100
+ }
101
+ continue
102
+ }
103
+ if (snippetDepth > 0 && token.name === snippetTag) {
104
+ snippetDepth -= 1
105
+ if (snippetDepth === 0) {
106
+ const snippet = pendingSnippet.replace(/\s+/g, ' ').trim()
107
+ const target = drafts[drafts.length - 1]
108
+ if (target && target.snippet === null && snippet.length > 0) target.snippet = snippet
109
+ pendingSnippet = ''
110
+ }
111
+ }
112
+ continue
113
+ }
114
+
115
+ if (linkDepth > 0) pendingTitle += token.value
116
+ else if (snippetDepth > 0) pendingSnippet += token.value
117
+ }
118
+
119
+ return drafts.slice(0, limit).map((draft) => ({
120
+ url: draft.url,
121
+ title: draft.title.length > 0 ? draft.title : draft.url,
122
+ snippet: draft.snippet,
123
+ }))
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { createSerpAdapter, serpAdapterModule, serpOptionsSchema, type SerpOptions } from './adapter'
2
+ export { extractSerpResults, type SerpSelector } from './extract'
3
+ export { DEFAULT_ENGINES, SERP_PROFILES, type SerpEngineId, type SerpEngineProfile } from './profiles'
4
+
5
+ export { serpAdapterModule as default } from './adapter'
@@ -0,0 +1,68 @@
1
+ import type { SearchRequest } from '@open-mercato/web-research'
2
+ import type { SerpSelector } from './extract'
3
+
4
+ export type SerpEngineId = 'ddg-html' | 'ddg-lite'
5
+
6
+ export type SerpEngineProfile = {
7
+ readonly id: SerpEngineId
8
+ readonly label: string
9
+ readonly baseUrl: string
10
+ readonly selector: SerpSelector
11
+ buildUrl(request: SearchRequest): string
12
+ }
13
+
14
+ function buildQuery(request: SearchRequest): string {
15
+ const parts = [request.query]
16
+ if (request.site) parts.push(`site:${request.site}`)
17
+ return parts.join(' ')
18
+ }
19
+
20
+ /** DuckDuckGo maps a freshness window onto its `df` parameter. */
21
+ function freshnessParam(request: SearchRequest): string | null {
22
+ switch (request.freshness) {
23
+ case 'day':
24
+ return 'd'
25
+ case 'week':
26
+ return 'w'
27
+ case 'month':
28
+ return 'm'
29
+ case 'year':
30
+ return 'y'
31
+ default:
32
+ return null
33
+ }
34
+ }
35
+
36
+ function duckDuckGoUrl(base: string, request: SearchRequest): string {
37
+ const url = new URL(base)
38
+ url.searchParams.set('q', buildQuery(request))
39
+ const freshness = freshnessParam(request)
40
+ if (freshness) url.searchParams.set('df', freshness)
41
+ if (request.locale) url.searchParams.set('kl', request.locale.toLowerCase().replace('_', '-'))
42
+ return url.toString()
43
+ }
44
+
45
+ /**
46
+ * Engine markup is a contract nobody signed — it changes without notice. Keeping
47
+ * profiles as data means a break is a one-line selector edit plus a fixture, and
48
+ * the adapter's zero-results self-check turns a silent parser rot into a visible
49
+ * `blocked` diagnostic rather than an empty answer.
50
+ */
51
+ export const SERP_PROFILES: Readonly<Record<SerpEngineId, SerpEngineProfile>> = {
52
+ 'ddg-html': {
53
+ id: 'ddg-html',
54
+ label: 'DuckDuckGo (HTML)',
55
+ baseUrl: 'https://html.duckduckgo.com/html/',
56
+ selector: { linkClass: 'result__a', snippetClass: 'result__snippet' },
57
+ buildUrl: (request) => duckDuckGoUrl('https://html.duckduckgo.com/html/', request),
58
+ },
59
+ 'ddg-lite': {
60
+ id: 'ddg-lite',
61
+ label: 'DuckDuckGo (Lite)',
62
+ baseUrl: 'https://lite.duckduckgo.com/lite/',
63
+ selector: { linkClass: 'result-link', snippetClass: 'result-snippet' },
64
+ buildUrl: (request) => duckDuckGoUrl('https://lite.duckduckgo.com/lite/', request),
65
+ },
66
+ }
67
+
68
+ export const DEFAULT_ENGINES: readonly SerpEngineId[] = ['ddg-html', 'ddg-lite']
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "extends": "../../tsconfig.base.json",
4
+ "compilerOptions": {
5
+ "noEmit": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*"],
9
+ "exclude": ["node_modules", "dist", "**/__tests__/**"]
10
+ }
package/watch.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { dirname } from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { watch } from '../../scripts/watch.mjs'
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url))
6
+
7
+ watch(__dirname)