Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
255 lines
7.7 KiB
TypeScript
255 lines
7.7 KiB
TypeScript
// Duckduckgo plugin module implements ddg client behavior.
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
import { readProviderTextResponse } from "openclaw/plugin-sdk/provider-http";
|
|
import {
|
|
DEFAULT_CACHE_TTL_MINUTES,
|
|
DEFAULT_SEARCH_COUNT,
|
|
normalizeCacheKey,
|
|
readCache,
|
|
readResponseText,
|
|
resolveCacheTtlMs,
|
|
resolveSearchCount,
|
|
resolveSiteName,
|
|
resolveTimeoutSeconds,
|
|
withTrustedWebSearchEndpoint,
|
|
wrapWebContent,
|
|
writeCache,
|
|
} from "openclaw/plugin-sdk/provider-web-search";
|
|
import { resolveDdgRegion, resolveDdgSafeSearch, type DdgSafeSearch } from "./config.js";
|
|
|
|
const DDG_HTML_ENDPOINT = "https://html.duckduckgo.com/html";
|
|
const DEFAULT_TIMEOUT_SECONDS = 20;
|
|
const DDG_SAFE_SEARCH_PARAM: Record<DdgSafeSearch, string> = {
|
|
strict: "1",
|
|
moderate: "-1",
|
|
off: "-2",
|
|
};
|
|
|
|
const DDG_SEARCH_CACHE = new Map<
|
|
string,
|
|
{ value: Record<string, unknown>; insertedAt: number; expiresAt: number }
|
|
>();
|
|
|
|
type DuckDuckGoResult = {
|
|
title: string;
|
|
url: string;
|
|
snippet: string;
|
|
};
|
|
|
|
function isDecodableCodePoint(cp: number): boolean {
|
|
return Number.isInteger(cp) && cp >= 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff);
|
|
}
|
|
|
|
function decodeHtmlEntities(text: string): string {
|
|
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")) {
|
|
const codePoint = Number.parseInt(normalized.slice(3, -1), 16);
|
|
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
}
|
|
if (normalized.startsWith("&#")) {
|
|
const codePoint = Number.parseInt(normalized.slice(2, -1), 10);
|
|
return isDecodableCodePoint(codePoint) ? String.fromCodePoint(codePoint) : entity;
|
|
}
|
|
return entity;
|
|
},
|
|
);
|
|
}
|
|
|
|
function stripHtml(html: string): string {
|
|
return html
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function decodeDuckDuckGoUrl(rawUrl: string): string {
|
|
try {
|
|
const normalized = rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl;
|
|
const parsed = new URL(normalized);
|
|
const uddg = parsed.searchParams.get("uddg");
|
|
if (uddg) {
|
|
return uddg;
|
|
}
|
|
} catch {
|
|
// Keep the original value when DuckDuckGo already returns a direct link.
|
|
}
|
|
return rawUrl;
|
|
}
|
|
|
|
function readHrefAttribute(tagAttributes: string): string {
|
|
return /\bhref="([^"]*)"/i.exec(tagAttributes)?.[1] ?? "";
|
|
}
|
|
|
|
function isBotChallenge(html: string): boolean {
|
|
if (/class="[^"]*\bresult__a\b[^"]*"/i.test(html)) {
|
|
return false;
|
|
}
|
|
return /g-recaptcha|are you a human|id="challenge-form"|name="challenge"/i.test(html);
|
|
}
|
|
|
|
async function readDuckDuckGoHtmlResponse(response: Response): Promise<string> {
|
|
return await readProviderTextResponse(response, "DuckDuckGo search");
|
|
}
|
|
|
|
function parseDuckDuckGoHtml(html: string): DuckDuckGoResult[] {
|
|
const results: DuckDuckGoResult[] = [];
|
|
const resultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
|
|
const nextResultRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")[^>]*>/i;
|
|
const snippetRegex = /<a\b(?=[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*")[^>]*>([\s\S]*?)<\/a>/i;
|
|
|
|
for (const match of html.matchAll(resultRegex)) {
|
|
const rawAttributes = match[1] ?? "";
|
|
const rawTitle = match[2] ?? "";
|
|
const rawUrl = readHrefAttribute(rawAttributes);
|
|
const matchEnd = (match.index ?? 0) + match[0].length;
|
|
const trailingHtml = html.slice(matchEnd);
|
|
const nextResultIndex = trailingHtml.search(nextResultRegex);
|
|
const scopedTrailingHtml =
|
|
nextResultIndex >= 0 ? trailingHtml.slice(0, nextResultIndex) : trailingHtml;
|
|
const rawSnippet = snippetRegex.exec(scopedTrailingHtml)?.[1] ?? "";
|
|
const title = decodeHtmlEntities(stripHtml(rawTitle));
|
|
const url = decodeDuckDuckGoUrl(decodeHtmlEntities(rawUrl));
|
|
const snippet = decodeHtmlEntities(stripHtml(rawSnippet));
|
|
|
|
if (title && url) {
|
|
results.push({ title, url, snippet });
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export async function runDuckDuckGoSearch(params: {
|
|
config?: OpenClawConfig;
|
|
query: string;
|
|
count?: number;
|
|
region?: string;
|
|
safeSearch?: DdgSafeSearch;
|
|
timeoutSeconds?: number;
|
|
cacheTtlMinutes?: number;
|
|
}): Promise<Record<string, unknown>> {
|
|
const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT);
|
|
const region = params.region ?? resolveDdgRegion(params.config);
|
|
const safeSearch =
|
|
params.safeSearch === "strict" ||
|
|
params.safeSearch === "moderate" ||
|
|
params.safeSearch === "off"
|
|
? params.safeSearch
|
|
: resolveDdgSafeSearch(params.config);
|
|
const timeoutSeconds = resolveTimeoutSeconds(params.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS);
|
|
const cacheTtlMs = resolveCacheTtlMs(params.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES);
|
|
const cacheKey = normalizeCacheKey(
|
|
JSON.stringify({
|
|
provider: "duckduckgo",
|
|
query: params.query,
|
|
count,
|
|
region: region ?? "",
|
|
safeSearch,
|
|
}),
|
|
);
|
|
const cached = readCache(DDG_SEARCH_CACHE, cacheKey);
|
|
if (cached) {
|
|
return { ...cached.value, cached: true };
|
|
}
|
|
|
|
const url = new URL(DDG_HTML_ENDPOINT);
|
|
url.searchParams.set("q", params.query);
|
|
if (region) {
|
|
url.searchParams.set("kl", region);
|
|
}
|
|
url.searchParams.set("kp", DDG_SAFE_SEARCH_PARAM[safeSearch]);
|
|
|
|
const startedAt = Date.now();
|
|
const results = await withTrustedWebSearchEndpoint(
|
|
{
|
|
url: url.toString(),
|
|
timeoutSeconds,
|
|
init: {
|
|
method: "GET",
|
|
headers: {
|
|
"User-Agent":
|
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
},
|
|
},
|
|
},
|
|
async (response) => {
|
|
if (!response.ok) {
|
|
const detail = (await readResponseText(response, { maxBytes: 64_000 })).text;
|
|
throw new Error(
|
|
`DuckDuckGo search error (${response.status}): ${detail || response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const html = await readDuckDuckGoHtmlResponse(response);
|
|
if (isBotChallenge(html)) {
|
|
throw new Error("DuckDuckGo returned a bot-detection challenge.");
|
|
}
|
|
return parseDuckDuckGoHtml(html).slice(0, count);
|
|
},
|
|
);
|
|
|
|
const payload = {
|
|
query: params.query,
|
|
provider: "duckduckgo",
|
|
count: results.length,
|
|
tookMs: Date.now() - startedAt,
|
|
externalContent: {
|
|
untrusted: true,
|
|
source: "web_search",
|
|
provider: "duckduckgo",
|
|
wrapped: true,
|
|
},
|
|
results: results.map((result) => ({
|
|
title: wrapWebContent(result.title, "web_search"),
|
|
url: result.url,
|
|
snippet: result.snippet ? wrapWebContent(result.snippet, "web_search") : "",
|
|
siteName: resolveSiteName(result.url) || undefined,
|
|
})),
|
|
} satisfies Record<string, unknown>;
|
|
|
|
writeCache(DDG_SEARCH_CACHE, cacheKey, payload, cacheTtlMs);
|
|
return payload;
|
|
}
|
|
|
|
export const testing = {
|
|
decodeDuckDuckGoUrl,
|
|
decodeHtmlEntities,
|
|
isBotChallenge,
|
|
parseDuckDuckGoHtml,
|
|
readDuckDuckGoHtmlResponse,
|
|
};
|
|
export { testing as __testing };
|