@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.
- package/.turbo/turbo-build.log +2 -0
- package/build.mjs +10 -0
- package/dist/adapter.js +102 -0
- package/dist/adapter.js.map +7 -0
- package/dist/extract.js +90 -0
- package/dist/extract.js.map +7 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +7 -0
- package/dist/profiles.js +49 -0
- package/dist/profiles.js.map +7 -0
- package/jest.config.cjs +28 -0
- package/package.json +59 -0
- package/src/__tests__/serp.test.ts +154 -0
- package/src/adapter.ts +126 -0
- package/src/extract.ts +124 -0
- package/src/index.ts +5 -0
- package/src/profiles.ts +68 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +7 -0
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
|
+
})
|
package/dist/adapter.js
ADDED
|
@@ -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
|
+
}
|
package/dist/extract.js
ADDED
|
@@ -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
|
+
}
|
package/dist/profiles.js
ADDED
|
@@ -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
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -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&rut=x">First & 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'
|
package/src/profiles.ts
ADDED
|
@@ -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