diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-06 03:18:21 +0000 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-06 03:18:21 +0000 |
| commit | 147b3b1507ebcdf5912fbd4c474c9dfb35265148 (patch) | |
| tree | 804ab62ee7266e450482e00d40a5c9c467a1ef69 /castle-search | |
| parent | 74ff9fdcb8b49a021b63ac2e6b0191e29754d0f3 (diff) | |
castle-search: read Brave again, and report genuine zero-hit searches as such
Brave: #results no longer exists on the live page; fall back to #main/main.
It was reported as 'no results area at all', i.e. a rate limit.
DuckDuckGo: its 'No results found' notice is itself a .result, so an empty
search looked like broken markup. emptyMarker lets the probe say the engine
itself reported no matches, and the error tells the caller to loosen the
query instead of switching engines.
Diffstat (limited to 'castle-search')
| -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"); +}); |
