aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--castle-search/src/engines.ts13
-rw-r--r--castle-search/src/extract.ts9
-rw-r--r--castle-search/src/search.ts12
-rw-r--r--castle-search/test/extraction-config.test.ts16
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");
+});