diff options
| author | Igor Soarez <igor@soarez.org> | 2026-08-03 21:43:56 +0100 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-08-03 21:43:56 +0100 |
| commit | db69207c06e8d5233bff4996e3ef5b43332d533f (patch) | |
| tree | 032ebbc3983a6eb4f9e3c4ac162c60a9cc1edc75 /test/search-url.test.ts | |
| parent | 495de0d5283dd3e4a6ef715b596c4a2892e95915 (diff) | |
Support DuckDuckGo, Bing and Brave, switchable like the CDP host
Engine resolution mirrors the browser target: CCS_SEARCH_ENGINE, then a
/search-engine choice persisted machine-wide, then Google. A per-call
`engine` parameter sits above both so the agent can fall back when one
engine starts serving captchas — the one case where the model, not the
operator, has to make the call. Unlike the CDP target there is no safety
argument for the environment winning: driving the wrong browser means
automating someone's signed-in Chrome, choosing a different index does not.
An unsupported recency window is refused, naming the engines that support
it, rather than dropped. Silently returning unfiltered results is
indistinguishable from success, which is the failure this whole design is
trying to avoid.
Extraction grows a second mode. DuckDuckGo, Bing and Brave have clean
per-result containers; Google does not, so its heading-walk stays as its
own path rather than being bent into the item shape. DuckDuckGo and Bing
route links through redirectors, unwrapped in the page.
Third silent-failure trap found, alongside Google's two: on Bing, `count`
cancels `filters`. With ex1:"ez1" alone every result is hours old; add
count in either order and months-old results return, looking perfectly
ordinary. Bing now drops count whenever a date filter is present.
Challenge detection widened to Brave's "Verifying you're not a bot" and
"Quick check before you continue searching", which the previous Google-
shaped matcher missed entirely — found by tripping it.
Diffstat (limited to 'test/search-url.test.ts')
| -rw-r--r-- | test/search-url.test.ts | 97 |
1 files changed, 75 insertions, 22 deletions
diff --git a/test/search-url.test.ts b/test/search-url.test.ts index 13f58d1..2425f76 100644 --- a/test/search-url.test.ts +++ b/test/search-url.test.ts @@ -1,35 +1,88 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import { RECENCY_VALUES, allEngines, getEngine, type Recency } from "../src/engines.ts"; import { buildSearchUrl } from "../src/search.ts"; -const paramsOf = (url: string): URLSearchParams => new URL(url).searchParams; +const urlFor = (id: Parameters<typeof getEngine>[0], recency?: Recency) => + new URL(buildSearchUrl(getEngine(id), { query: "sqlite wal mode", numResults: 7, recency })); -test("a plain query asks for the Web tab in English", () => { - const url = buildSearchUrl({ query: "typebox json schema", numResults: 10 }); - assert.equal(new URL(url).origin + new URL(url).pathname, "https://www.google.com/search"); - const p = paramsOf(url); - assert.equal(p.get("q"), "typebox json schema"); - assert.equal(p.get("num"), "10"); - assert.equal(p.get("hl"), "en"); - assert.equal(p.get("udm"), "14"); - assert.equal(p.get("tbs"), null); +test("google uses the Web tab, and swaps it for as_qdr when time-limited", () => { + const plain = urlFor("google"); + assert.equal(plain.origin + plain.pathname, "https://www.google.com/search"); + assert.equal(plain.searchParams.get("udm"), "14"); + assert.equal(plain.searchParams.get("num"), "7"); + assert.equal(plain.searchParams.get("as_qdr"), null); + + const dated = urlFor("google", "month"); + // Both findings in one assertion pair: tbs is never used, and udm must be + // dropped when a date filter is present or the page renders empty. + assert.equal(dated.searchParams.get("as_qdr"), "m"); + assert.equal(dated.searchParams.get("udm"), null); + assert.equal(dated.searchParams.get("tbs"), null); +}); + +test("duckduckgo uses the no-JS html endpoint and df=", () => { + const plain = urlFor("duckduckgo"); + assert.equal(plain.origin + plain.pathname, "https://html.duckduckgo.com/html/"); + assert.equal(plain.searchParams.get("q"), "sqlite wal mode"); + assert.equal(plain.searchParams.get("df"), null); + + for (const [recency, code] of [ + ["day", "d"], + ["week", "w"], + ["month", "m"], + ["year", "y"], + ] as const) { + assert.equal(urlFor("duckduckgo", recency).searchParams.get("df"), code, recency); + } +}); + +test("bing uses count=, and drops it when filtering because count cancels filters", () => { + const plain = urlFor("bing"); + assert.equal(plain.origin + plain.pathname, "https://www.bing.com/search"); + assert.equal(plain.searchParams.get("count"), "7"); + assert.equal(plain.searchParams.get("filters"), null); + + for (const [recency, code] of [ + ["day", "ez1"], + ["week", "ez2"], + ["month", "ez3"], + ] as const) { + const dated = urlFor("bing", recency); + assert.equal(dated.searchParams.get("filters"), `ex1:"${code}"`, recency); + // Observed: sending count alongside filters silently returns unfiltered + // results that look entirely plausible. This assertion is the regression. + assert.equal(dated.searchParams.get("count"), null, `${recency}: count must be dropped`); + } }); -test("recency uses as_qdr and drops udm — tbs and udm both render an empty page", () => { - const cases = { day: "d", week: "w", month: "m", year: "y" } as const; - for (const [recency, expected] of Object.entries(cases)) { - const url = buildSearchUrl({ query: "q", numResults: 5, recency: recency as keyof typeof cases }); - const p = paramsOf(url); - assert.equal(p.get("as_qdr"), expected, recency); - assert.equal(p.get("tbs"), null, `${recency}: tbs must not be used`); - assert.equal(p.get("udm"), null, `${recency}: udm must be dropped alongside a date filter`); +test("brave is a plain query — it advertises no time filter at all", () => { + const plain = urlFor("brave"); + assert.equal(plain.origin + plain.pathname, "https://search.brave.com/search"); + assert.equal(plain.searchParams.get("q"), "sqlite wal mode"); + assert.deepEqual(getEngine("brave").recency, {}); +}); + +test("every advertised recency window maps to a non-empty parameter value", () => { + // Guards the failure this whole design exists to prevent: an engine claiming + // support for a window it then silently drops from the URL. + for (const engine of allEngines()) { + for (const recency of RECENCY_VALUES) { + const code = engine.recency[recency]; + if (code === undefined) continue; + assert.ok(code.length > 0, `${engine.id}/${recency} maps to an empty value`); + const url = buildSearchUrl(engine, { query: "q", numResults: 5, recency }); + assert.ok(url.includes(encodeURIComponent(code)) || url.includes(code), `${engine.id}/${recency} lost its value`); + } } }); -test("queries with characters that would break a URL are encoded", () => { +test("queries with characters that would break a URL are encoded, on every engine", () => { const query = 'site:example.com "exact phrase" a&b?c=d #frag +plus/slash'; - const url = buildSearchUrl({ query, numResults: 3 }); - assert.equal(paramsOf(url).get("q"), query); - assert.ok(!url.includes(" "), "no raw spaces in the URL"); + for (const engine of allEngines()) { + const url = buildSearchUrl(engine, { query, numResults: 3 }); + assert.equal(new URL(url).searchParams.get("q"), query, engine.id); + assert.ok(!url.includes(" "), `${engine.id}: no raw spaces in the URL`); + } }); |
