/**
* The search engines this extension knows how to drive.
*
* Every selector and every URL parameter below was read off the real SERP in
* castle's Chrome, not recalled or inferred. That is not pedantry: Google's own
* Tools menu writes `tbs=qdr:*`, which renders an *empty* page on that profile,
* while the undocumented-looking `as_qdr=*` works. Anything in here that was not
* observed is a bug waiting to be reported as "no results".
*
* Two extraction modes, because the engines genuinely differ:
*
* - "items" — the SERP has a clean per-result container (`li.b_algo`,
* `.result`, `.snippet[data-type=web]`). Straightforward.
* - "headings" — Google has no stable result container, so results are found
* from each
outward. Kept as its own mode rather than
* forced into the item shape, because it is the one that has
* been through the most verification.
*/
export type EngineId = "google" | "duckduckgo" | "bing" | "brave";
export type Recency = "day" | "week" | "month" | "year";
export const RECENCY_VALUES = ["day", "week", "month", "year"] as const;
export const ENGINE_IDS: readonly EngineId[] = ["google", "duckduckgo", "bing", "brave"];
/** Aliases accepted from humans and from the model. */
const ENGINE_ALIASES: Record = {
google: "google",
g: "google",
duckduckgo: "duckduckgo",
ddg: "duckduckgo",
duck: "duckduckgo",
bing: "bing",
b: "bing",
brave: "brave",
};
/**
* How the in-page script should read one engine's results. Passed into the
* browser as JSON, so everything here must be plain data.
*/
export type ExtractionConfig = {
mode: "items" | "headings";
/** Results container candidates, first match wins. */
roots: string[];
/** "items" mode: one result. */
item?: string;
/** Anchor carrying the outbound link, relative to the item. */
link?: string;
/** Title element, relative to the item. Falls back to the anchor's text. */
title?: string;
/** Description element, relative to the item. */
snippet?: string;
/**
* "items" mode with no snippet selector: text from these is subtracted from
* the item's text to leave the description behind.
*/
subtract?: string[];
/** Items matching any of these are ads or non-web cards, and are skipped. */
exclude?: string[];
/** How outbound links are wrapped, if they are. */
unwrap: "none" | "ddg" | "bing";
/** Hosts belonging to the engine itself; links to them are not results. */
selfHostPattern: string;
};
export type EngineDefinition = {
readonly id: EngineId;
readonly label: string;
/** Human-facing note about what makes this engine worth choosing. */
readonly note: string;
/**
* Recency windows this engine can actually express, mapped to the parameter
* value. A window that is absent is one the engine does not support — never
* one that is silently dropped.
*/
readonly recency: Partial>;
readonly buildUrl: (query: string, numResults: number, recency: Recency | undefined) => string;
readonly extraction: ExtractionConfig;
};
const GOOGLE: EngineDefinition = {
id: "google",
label: "Google",
note: "best result quality; blocks aggressively under repeated automated queries",
recency: { day: "d", week: "w", month: "m", year: "y" },
buildUrl: (query, numResults, recency) => {
const params = new URLSearchParams({ q: query, num: String(numResults), hl: "en" });
// `udm=14` is the plain "Web" tab: no AI overview, no carousels. But any
// date restriction combined with it renders an empty page, and `tbs=qdr:*`
// renders an empty page on its own — both observed. So a time-limited
// search drops udm and uses the older as_qdr instead.
if (recency) params.set("as_qdr", GOOGLE.recency[recency] as string);
else params.set("udm", "14");
return `https://www.google.com/search?${params.toString()}`;
},
extraction: {
mode: "headings",
roots: ["#rso", "#search"],
unwrap: "none",
selfHostPattern: String.raw`^https?://(www\.)?google\.[a-z.]+/`,
},
};
const DUCKDUCKGO: EngineDefinition = {
id: "duckduckgo",
label: "DuckDuckGo",
note: "no-JS endpoint, the most stable markup here and the least likely to challenge",
// Read off DuckDuckGo's own