diff --git a/packages/coding-agent/src/web/search/providers/duckduckgo.ts b/packages/coding-agent/src/web/search/providers/duckduckgo.ts index db2195d0a..48090f987 100644 --- a/packages/coding-agent/src/web/search/providers/duckduckgo.ts +++ b/packages/coding-agent/src/web/search/providers/duckduckgo.ts @@ -2,7 +2,7 @@ import type { AuthStorage } from "@oh-my-pi/pi-ai"; import type { SearchResponse, SearchSource } from "../../../web/search/types"; import { SearchProviderError } from "../../../web/search/types"; import type { QuerySyntax } from "../query"; -import { formatScraperQuery } from "../query"; +import { formatScraperQuery, parseSearchQuery } from "../query"; import { clampNumResults, dateToAgeSeconds } from "../utils"; import type { SearchParams } from "./base"; import { SearchProvider } from "./base"; @@ -181,10 +181,119 @@ const DDG_QUERY_SYNTAX: QuerySyntax = { filetype: true, }; +/** + * DuckDuckGo's documented `kl` values. + * + * The codes resemble `region-language` locales but contain provider-specific + * identifiers (`jp-jp`, `tw-tzh`, `uk-en`) that cannot be derived mechanically. + */ +const DDG_KL_CODES = new Set([ + "xa-ar", + "xa-en", + "ar-es", + "au-en", + "at-de", + "be-fr", + "be-nl", + "br-pt", + "bg-bg", + "ca-en", + "ca-fr", + "ct-ca", + "cl-es", + "cn-zh", + "co-es", + "hr-hr", + "cz-cs", + "dk-da", + "ee-et", + "fi-fi", + "fr-fr", + "de-de", + "gr-el", + "hk-tzh", + "hu-hu", + "in-en", + "id-id", + "id-en", + "ie-en", + "il-he", + "it-it", + "jp-jp", + "kr-kr", + "lv-lv", + "lt-lt", + "xl-es", + "my-ms", + "my-en", + "mx-es", + "nl-nl", + "nz-en", + "no-no", + "pe-es", + "ph-en", + "ph-tl", + "pl-pl", + "pt-pt", + "ro-ro", + "ru-ru", + "sg-en", + "sk-sk", + "sl-sl", + "za-en", + "es-es", + "se-sv", + "ch-de", + "ch-fr", + "ch-it", + "tw-tzh", + "th-th", + "tr-tr", + "ua-uk", + "uk-en", + "us-en", + "ue-es", + "ve-es", + "vn-vi", + "wt-wt", +]); + +/** BCP 47 locales whose DDG code does not follow a simple component swap. */ +const DDG_LOCALE_ALIASES: Record = { + "ca-es": "ct-ca", + "en-gb": "uk-en", + "es-419": "xl-es", + "es-us": "ue-es", + "ja-jp": "jp-jp", + "ko-kr": "kr-kr", + "zh-hk": "hk-tzh", + "zh-tw": "tw-tzh", +}; + +/** + * Map a parsed `lang:` locale onto DuckDuckGo's documented `kl` values. + * + * Shared queries use `language-region` order while DDG generally uses + * `region-language`. Provider-specific exceptions resolve through + * {@link DDG_LOCALE_ALIASES}; all other values must survive the documented + * allowlist after swapping or the caller keeps its default region. + */ +export function localeToKl(lang: string | undefined): string | undefined { + if (!lang) return undefined; + const locale = lang.toLowerCase().replaceAll("_", "-"); + const alias = DDG_LOCALE_ALIASES[locale]; + if (alias) return alias; + const match = /^([a-z]{2})-([a-z]{2})$/.exec(locale); + if (!match) return undefined; + const candidate = `${match[2]}-${match[1]}`; + return DDG_KL_CODES.has(candidate) ? candidate : undefined; +} + function createDuckDuckGoForm(params: SearchParams): URLSearchParams { + const parsed = params.parsedQuery ?? parseSearchQuery(params.query); const form = new URLSearchParams({ - q: formatScraperQuery(params.query, params.parsedQuery, DDG_QUERY_SYNTAX), - kl: "us-en", + q: formatScraperQuery(params.query, parsed, DDG_QUERY_SYNTAX), + kl: localeToKl(parsed.lang) ?? "us-en", }); const df = params.recency ? RECENCY_TO_DDG_DF[params.recency] : undefined; if (df) form.set("df", df); diff --git a/packages/coding-agent/test/web/search/duckduckgo.test.ts b/packages/coding-agent/test/web/search/duckduckgo.test.ts new file mode 100644 index 000000000..6ab1ad4ec --- /dev/null +++ b/packages/coding-agent/test/web/search/duckduckgo.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "bun:test"; +import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai"; +import { localeToKl, searchDuckDuckGo } from "@oh-my-pi/pi-coding-agent/web/search/providers/duckduckgo"; +import { parseSearchQuery } from "@oh-my-pi/pi-coding-agent/web/search/query"; + +describe("localeToKl", () => { + it("maps standard region-qualified locales to documented DDG codes", () => { + expect(localeToKl("de-de")).toBe("de-de"); + expect(localeToKl("fr-fr")).toBe("fr-fr"); + expect(localeToKl("en-us")).toBe("us-en"); + expect(localeToKl("pt-br")).toBe("br-pt"); + expect(localeToKl("zh-cn")).toBe("cn-zh"); + }); + + it("maps locales with provider-specific DDG codes", () => { + expect(localeToKl("en-gb")).toBe("uk-en"); + expect(localeToKl("ja-jp")).toBe("jp-jp"); + expect(localeToKl("ko-kr")).toBe("kr-kr"); + expect(localeToKl("zh-hk")).toBe("hk-tzh"); + expect(localeToKl("zh-tw")).toBe("tw-tzh"); + }); + + it("normalizes case and underscore separators", () => { + expect(localeToKl("EN_US")).toBe("us-en"); + expect(localeToKl("De-DE")).toBe("de-de"); + }); + + it("returns undefined for unsupported, language-only, empty, or malformed values", () => { + expect(localeToKl(undefined)).toBeUndefined(); + expect(localeToKl("de")).toBeUndefined(); + expect(localeToKl("en")).toBeUndefined(); + expect(localeToKl("en-jp")).toBeUndefined(); + expect(localeToKl("zz-zz")).toBeUndefined(); + expect(localeToKl("english")).toBeUndefined(); + expect(localeToKl("")).toBeUndefined(); + }); +}); + +describe("searchDuckDuckGo kl parameter (integration)", () => { + const fakeAuthStorage = {} as unknown as AuthStorage; + + async function effectiveForm(query: string, opts: { withParsedQuery?: boolean } = {}): Promise { + const withParsedQuery = opts.withParsedQuery ?? true; + let body: string | undefined; + const fetchMock: FetchImpl = async (_input, init) => { + body = init?.body as string; + return new Response("", { + status: 200, + headers: { "Content-Type": "text/html" }, + }); + }; + await searchDuckDuckGo({ + query, + parsedQuery: withParsedQuery ? parseSearchQuery(query) : undefined, + systemPrompt: "", + authStorage: fakeAuthStorage, + fetch: fetchMock, + }); + return new URLSearchParams(body ?? ""); + } + + it("honors the lang: directive so distinct locales produce distinct requests", async () => { + const de = await effectiveForm("weather lang:de-de"); + const fr = await effectiveForm("weather lang:fr-fr"); + expect(de.get("q")).toBe("weather"); + expect(de.get("kl")).toBe("de-de"); + expect(fr.get("kl")).toBe("fr-fr"); + }); + + it("uses DDG's provider-specific locale codes", async () => { + const ja = await effectiveForm("news lang:ja-jp"); + const ko = await effectiveForm("news lang:ko-kr"); + const tw = await effectiveForm("news lang:zh-tw"); + expect(ja.get("kl")).toBe("jp-jp"); + expect(ko.get("kl")).toBe("kr-kr"); + expect(tw.get("kl")).toBe("tw-tzh"); + }); + + it("falls back to us-en when no lang: directive is supplied", async () => { + const form = await effectiveForm("weather"); + expect(form.get("kl")).toBe("us-en"); + }); + + it("falls back to us-en for language-only locales", async () => { + const form = await effectiveForm("weather lang:de"); + expect(form.get("kl")).toBe("us-en"); + }); + + it("parses lang: from the raw query when parsedQuery is omitted (direct call)", async () => { + const form = await effectiveForm("weather lang:de-de", { withParsedQuery: false }); + expect(form.get("q")).toBe("weather"); + expect(form.get("kl")).toBe("de-de"); + }); +});