diff options
| -rw-r--r-- | castle-search/src/engines.ts | 13 | ||||
| -rw-r--r-- | castle-search/src/extract.ts | 9 | ||||
| -rw-r--r-- | castle-search/src/search.ts | 12 | ||||
| -rw-r--r-- | castle-search/test/extraction-config.test.ts | 16 |
4 files changed, 49 insertions, 1 deletions
diff --git a/castle-search/src/engines.ts b/castle-search/src/engines.ts index 76716c6..fb07649 100644 --- a/castle-search/src/engines.ts +++ b/castle-search/src/engines.ts @@ -45,6 +45,12 @@ export type ExtractionConfig = { mode: "items" | "headings"; /** Results container candidates, first match wins. */ roots: string[]; + /** + * Present only on the engine's own "nothing matched" page. Without it a page + * that genuinely has zero hits is indistinguishable from broken markup, and + * the error blames the extractor for what is just an over-narrow query. + */ + emptyMarker?: string; /** "items" mode: one result. */ item?: string; /** Anchor carrying the outbound link, relative to the item. */ @@ -124,6 +130,9 @@ const DUCKDUCKGO: EngineDefinition = { extraction: { mode: "items", roots: [".results", "#links"], + // Seen 2026-11-06: `<div class="result results_links no-results">` — it is + // itself a `.result`, which is why the extractor finds a container and no hits. + emptyMarker: ".no-results", item: ".result", link: "a.result__a[href]", title: "a.result__a", @@ -187,7 +196,9 @@ const BRAVE: EngineDefinition = { }, extraction: { mode: "items", - roots: ["#results"], + // `#results` is gone from the live page (2026-11-06); `#main` and `main` + // wrap the results. Left first so the older layout still matches. + roots: ["#results", "#main", "main"], // data-type="web" excludes the AI summariser, video and news cards, which // share the .snippet class but are not ranked web results. item: '.snippet[data-type="web"]', diff --git a/castle-search/src/extract.ts b/castle-search/src/extract.ts index 653942b..0284bb7 100644 --- a/castle-search/src/extract.ts +++ b/castle-search/src/extract.ts @@ -65,6 +65,8 @@ export type PageProbe = { * and worth telling apart when diagnosing a zero-hit search. */ container: "found" | "none"; + /** True when the engine's own "no matches" marker is on the page. */ + empty: boolean; results: SearchResult[]; }; @@ -279,6 +281,12 @@ export const buildProbeScript = (config: ExtractionConfig): string => String.raw var challenge = null; var results = []; var container = "none"; + var empty = false; + try { + empty = !!(CFG.emptyMarker && document.querySelector(CFG.emptyMarker)); + } catch (e) { + empty = false; + } try { challenge = detectChallenge(); } catch (e) { @@ -298,6 +306,7 @@ export const buildProbeScript = (config: ExtractionConfig): string => String.raw readyState: document.readyState, challenge: challenge, container: container, + empty: empty, results: results, }; })()`; diff --git a/castle-search/src/search.ts b/castle-search/src/search.ts index 1c84392..c64735d 100644 --- a/castle-search/src/search.ts +++ b/castle-search/src/search.ts @@ -299,6 +299,18 @@ export const search = async ( }); } + if (probe.results.length === 0 && probe.empty) { + // The engine itself says nothing matched. That is the query's doing, not + // the extractor's or a rate limit's, and saying so stops the caller from + // switching engines for a query no index will answer. + throw new NoResultsError( + `${engine.label} reports no matches for this query (${probe.url || searchUrl}). The page loaded ` + + `normally and the engine says so itself, so this is not a block or a broken extractor — the query ` + + `is too narrow. Drop the quotes or some of the terms; retrying another engine with the same ` + + `over-specific query is unlikely to help.`, + ); + } + if (probe.results.length === 0) { // "container missing entirely" and "container present but empty" are // different faults, and saying which saves the next person a browser diff --git a/castle-search/test/extraction-config.test.ts b/castle-search/test/extraction-config.test.ts new file mode 100644 index 0000000..43d2108 --- /dev/null +++ b/castle-search/test/extraction-config.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getEngine } from "../src/engines.ts"; + +test("duckduckgo can tell a genuine zero-hit page from broken markup", () => { + // Its "no results" notice is itself a `.result`, so without a marker an empty + // search looks like an extractor failure. + assert.equal(getEngine("duckduckgo").extraction.emptyMarker, ".no-results"); +}); + +test("brave falls back to main when #results is absent", () => { + const roots = getEngine("brave").extraction.roots; + assert.ok(roots.includes("main")); + assert.equal(roots[0], "#results"); +}); |
