From f0be8e7b6ee8618f5a8155e59274db489c7dc582 Mon Sep 17 00:00:00 2001 From: ly-wang19 <94427531+ly-wang19@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:35:27 +0800 Subject: [PATCH] fix(duckduckgo): decode & last in decodeHtmlEntities to avoid double-decoding (#96348) * fix(duckduckgo): decode & last in decodeHtmlEntities to avoid double-decoding decodeHtmlEntities decoded & FIRST, so result text that literally contains an entity (e.g. a page title 'How to escape < in HTML', which DuckDuckGo returns double-encoded as '&lt;') was re-decoded into markup: '&lt;' became '<' instead of the literal '<', corrupting the titles, snippets, and URLs the web-search tool returns to the model. Reorder so & is decoded last, matching the established convention elsewhere in the codebase (msteams/inbound.ts, openai-transport-stream.ts, launchd-plist.ts, doctor-session-snapshots.ts all decode & last). Behavior-preserving for all singly-encoded input. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(duckduckgo): decode html entities in one pass --------- Co-authored-by: ly-wang19 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Vincent Koc --- extensions/duckduckgo/src/ddg-client.ts | 58 ++++++++++++++----- .../src/ddg-search-provider.test.ts | 11 ++++ 2 files changed, 54 insertions(+), 15 deletions(-) diff --git a/extensions/duckduckgo/src/ddg-client.ts b/extensions/duckduckgo/src/ddg-client.ts index bcecbc7b4b0a..b8cd6ba3a90b 100644 --- a/extensions/duckduckgo/src/ddg-client.ts +++ b/extensions/duckduckgo/src/ddg-client.ts @@ -36,21 +36,49 @@ type DuckDuckGoResult = { }; function decodeHtmlEntities(text: string): string { - return text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(/'/g, "'") - .replace(///g, "/") - .replace(/ /g, " ") - .replace(/–/g, "-") - .replace(/—/g, "--") - .replace(/…/g, "...") - .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) - .replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(Number.parseInt(code, 16))); + return text.replace( + /&(?:lt|gt|quot|apos|#39|#x27|#x2F|nbsp|ndash|mdash|hellip|amp|#\d+|#x[0-9a-f]+);/gi, + (entity) => { + const normalized = entity.toLowerCase(); + if (normalized === "<") { + return "<"; + } + if (normalized === ">") { + return ">"; + } + if (normalized === """) { + return '"'; + } + if (normalized === "'" || normalized === "'" || normalized === "'") { + return "'"; + } + if (normalized === "/") { + return "/"; + } + if (normalized === " ") { + return " "; + } + if (normalized === "–") { + return "-"; + } + if (normalized === "—") { + return "--"; + } + if (normalized === "…") { + return "..."; + } + if (normalized === "&") { + return "&"; + } + if (normalized.startsWith("&#x")) { + return String.fromCodePoint(Number.parseInt(normalized.slice(3, -1), 16)); + } + if (normalized.startsWith("&#")) { + return String.fromCodePoint(Number.parseInt(normalized.slice(2, -1), 10)); + } + return entity; + }, + ); } function stripHtml(html: string): string { diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 2728f3ca9a43..b86f48e5fc14 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -186,6 +186,17 @@ describe("duckduckgo web search provider", () => { ); }); + it("does not double-decode escaped entities (decodes & last)", () => { + // A result whose text literally shows "<" arrives double-encoded as + // "&lt;". Decoding & first would re-decode it into "<", corrupting + // the snippet; & must be decoded last. + expect(ddgClientTesting.decodeHtmlEntities("How to escape &lt; in HTML")).toBe( + "How to escape < in HTML", + ); + expect(ddgClientTesting.decodeHtmlEntities("a&#39;b")).toBe("a'b"); + expect(ddgClientTesting.decodeHtmlEntities("a&amp;b")).toBe("a&b"); + }); + it("parses results when href appears before class", () => { const html = `