Vendor OpenClaw source as Adolf fork baseline
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
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
This commit is contained in:
12
extensions/duckduckgo/index.ts
Normal file
12
extensions/duckduckgo/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Duckduckgo plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createDuckDuckGoWebSearchProvider } from "./src/ddg-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "duckduckgo",
|
||||
name: "DuckDuckGo Plugin",
|
||||
description: "Bundled DuckDuckGo web search plugin",
|
||||
register(api) {
|
||||
api.registerWebSearchProvider(createDuckDuckGoWebSearchProvider());
|
||||
},
|
||||
});
|
||||
39
extensions/duckduckgo/openclaw.plugin.json
Normal file
39
extensions/duckduckgo/openclaw.plugin.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"icon": "https://cdn.simpleicons.org/duckduckgo",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"uiHints": {
|
||||
"webSearch.region": {
|
||||
"label": "DuckDuckGo Region",
|
||||
"help": "Optional DuckDuckGo region code such as us-en, uk-en, or de-de."
|
||||
},
|
||||
"webSearch.safeSearch": {
|
||||
"label": "DuckDuckGo SafeSearch",
|
||||
"help": "SafeSearch level for DuckDuckGo results."
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"webSearchProviders": ["duckduckgo"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"region": {
|
||||
"type": "string"
|
||||
},
|
||||
"safeSearch": {
|
||||
"type": "string",
|
||||
"enum": ["strict", "moderate", "off"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
extensions/duckduckgo/package.json
Normal file
15
extensions/duckduckgo/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@openclaw/duckduckgo-plugin",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw DuckDuckGo plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
43
extensions/duckduckgo/src/config.ts
Normal file
43
extensions/duckduckgo/src/config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Duckduckgo helper module supports config behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const DEFAULT_DDG_SAFE_SEARCH = "moderate";
|
||||
|
||||
export type DdgSafeSearch = "strict" | "moderate" | "off";
|
||||
|
||||
type DdgPluginConfig = {
|
||||
webSearch?: {
|
||||
region?: string;
|
||||
safeSearch?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveDdgWebSearchConfig(
|
||||
config?: OpenClawConfig,
|
||||
): DdgPluginConfig["webSearch"] | undefined {
|
||||
const pluginConfig = config?.plugins?.entries?.duckduckgo?.config as DdgPluginConfig | undefined;
|
||||
const webSearch = pluginConfig?.webSearch;
|
||||
if (webSearch && typeof webSearch === "object" && !Array.isArray(webSearch)) {
|
||||
return webSearch;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveDdgRegion(config?: OpenClawConfig): string | undefined {
|
||||
const region = resolveDdgWebSearchConfig(config)?.region;
|
||||
if (typeof region !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = region.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
export function resolveDdgSafeSearch(config?: OpenClawConfig): DdgSafeSearch {
|
||||
const safeSearch = resolveDdgWebSearchConfig(config)?.safeSearch;
|
||||
const normalized = normalizeLowercaseStringOrEmpty(safeSearch);
|
||||
if (normalized === "strict" || normalized === "off") {
|
||||
return normalized;
|
||||
}
|
||||
return DEFAULT_DDG_SAFE_SEARCH;
|
||||
}
|
||||
254
extensions/duckduckgo/src/ddg-client.ts
Normal file
254
extensions/duckduckgo/src/ddg-client.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// 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 };
|
||||
25
extensions/duckduckgo/src/ddg-search-provider.shared.ts
Normal file
25
extensions/duckduckgo/src/ddg-search-provider.shared.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Duckduckgo provider module implements model/runtime integration.
|
||||
import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
const DUCKDUCKGO_ONBOARDING_SCOPES: Array<"text-inference"> = ["text-inference"];
|
||||
|
||||
export function createDuckDuckGoWebSearchProviderBase() {
|
||||
return {
|
||||
id: "duckduckgo",
|
||||
label: "DuckDuckGo Search (experimental)",
|
||||
hint: "Free web search fallback with no API key required",
|
||||
onboardingScopes: [...DUCKDUCKGO_ONBOARDING_SCOPES],
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(no key needed)",
|
||||
signupUrl: "https://duckduckgo.com/",
|
||||
docsUrl: "https://docs.openclaw.ai/tools/web",
|
||||
autoDetectOrder: 100,
|
||||
credentialPath: "",
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: "",
|
||||
searchCredential: { type: "scoped", scopeId: "duckduckgo" },
|
||||
selectionPluginId: "duckduckgo",
|
||||
}),
|
||||
};
|
||||
}
|
||||
284
extensions/duckduckgo/src/ddg-search-provider.test.ts
Normal file
284
extensions/duckduckgo/src/ddg-search-provider.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// Duckduckgo tests cover ddg search provider plugin behavior.
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { createDuckDuckGoWebSearchProvider as createDuckDuckGoWebSearchContractProvider } from "../web-search-contract-api.js";
|
||||
import { DEFAULT_DDG_SAFE_SEARCH, resolveDdgRegion, resolveDdgSafeSearch } from "./config.js";
|
||||
|
||||
const { runDuckDuckGoSearch } = vi.hoisted(() => ({
|
||||
runDuckDuckGoSearch: vi.fn(async (params: Record<string, unknown>) => params),
|
||||
}));
|
||||
|
||||
vi.mock("./ddg-client.js", () => ({
|
||||
runDuckDuckGoSearch,
|
||||
}));
|
||||
|
||||
describe("duckduckgo web search provider", () => {
|
||||
let createDuckDuckGoWebSearchProvider: typeof import("./ddg-search-provider.js").createDuckDuckGoWebSearchProvider;
|
||||
let ddgClientTesting: typeof import("./ddg-client.js").testing;
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./ddg-client.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js"));
|
||||
({ testing: ddgClientTesting } =
|
||||
await vi.importActual<typeof import("./ddg-client.js")>("./ddg-client.js"));
|
||||
await import("../index.js");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
runDuckDuckGoSearch.mockReset();
|
||||
runDuckDuckGoSearch.mockImplementation(async (params: Record<string, unknown>) => params);
|
||||
});
|
||||
|
||||
it("exposes keyless metadata and enables the plugin in config", () => {
|
||||
const provider = createDuckDuckGoWebSearchProvider();
|
||||
if (!provider.applySelectionConfig) {
|
||||
throw new Error("Expected applySelectionConfig to be defined");
|
||||
}
|
||||
const applied = provider.applySelectionConfig({});
|
||||
|
||||
expect(provider.id).toBe("duckduckgo");
|
||||
expect(provider.label).toBe("DuckDuckGo Search (experimental)");
|
||||
expect(provider.onboardingScopes).toEqual(["text-inference"]);
|
||||
expect(createDuckDuckGoWebSearchContractProvider().onboardingScopes).toEqual([
|
||||
"text-inference",
|
||||
]);
|
||||
expect(provider.requiresCredential).toBe(false);
|
||||
expect(provider.credentialPath).toBe("");
|
||||
const pluginEntry = applied.plugins?.entries?.duckduckgo;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected DuckDuckGo plugin entry");
|
||||
}
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("maps generic tool arguments into DuckDuckGo search params", async () => {
|
||||
const provider = createDuckDuckGoWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: { test: true },
|
||||
} as never);
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
const result = await tool.execute({
|
||||
query: "openclaw docs",
|
||||
count: 4,
|
||||
region: "us-en",
|
||||
safeSearch: "off",
|
||||
});
|
||||
|
||||
expect(runDuckDuckGoSearch).toHaveBeenCalledWith({
|
||||
config: { test: true },
|
||||
query: "openclaw docs",
|
||||
count: 4,
|
||||
region: "us-en",
|
||||
safeSearch: "off",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
config: { test: true },
|
||||
query: "openclaw docs",
|
||||
count: 4,
|
||||
region: "us-en",
|
||||
safeSearch: "off",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects fractional and out-of-range counts before searching", async () => {
|
||||
const provider = createDuckDuckGoWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: { test: true },
|
||||
} as never);
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "openclaw docs", count: 4.5 })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 10.",
|
||||
);
|
||||
await expect(tool.execute({ query: "openclaw docs", count: 11 })).rejects.toThrow(
|
||||
"count must be an integer from 1 to 10.",
|
||||
);
|
||||
expect(runDuckDuckGoSearch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "Content-Type": "text/html" },
|
||||
});
|
||||
const textSpy = vi.spyOn(streamed.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
|
||||
await expect(ddgClientTesting.readDuckDuckGoHtmlResponse(streamed.response)).rejects.toThrow(
|
||||
"DuckDuckGo search: text response exceeds 16777216 bytes",
|
||||
);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reads region from plugin config and normalizes empty values away", () => {
|
||||
expect(
|
||||
resolveDdgRegion({
|
||||
plugins: {
|
||||
entries: {
|
||||
duckduckgo: {
|
||||
config: {
|
||||
webSearch: {
|
||||
region: "de-de",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBe("de-de");
|
||||
|
||||
expect(
|
||||
resolveDdgRegion({
|
||||
plugins: {
|
||||
entries: {
|
||||
duckduckgo: {
|
||||
config: {
|
||||
webSearch: {
|
||||
region: " ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults safeSearch to moderate and accepts strict and off", () => {
|
||||
expect(resolveDdgSafeSearch(undefined)).toBe(DEFAULT_DDG_SAFE_SEARCH);
|
||||
|
||||
expect(
|
||||
resolveDdgSafeSearch({
|
||||
plugins: {
|
||||
entries: {
|
||||
duckduckgo: {
|
||||
config: {
|
||||
webSearch: {
|
||||
safeSearch: "strict",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBe("strict");
|
||||
|
||||
expect(
|
||||
resolveDdgSafeSearch({
|
||||
plugins: {
|
||||
entries: {
|
||||
duckduckgo: {
|
||||
config: {
|
||||
webSearch: {
|
||||
safeSearch: "off",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("decodes direct and redirect urls plus common html entities", () => {
|
||||
expect(
|
||||
ddgClientTesting.decodeDuckDuckGoUrl(
|
||||
"https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fsearch%3Fq%3Dclaw",
|
||||
),
|
||||
).toBe("https://example.com/search?q=claw");
|
||||
expect(ddgClientTesting.decodeDuckDuckGoUrl("https://example.com")).toBe("https://example.com");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Fish & Chips … 'ok'")).toBe(
|
||||
"Fish & Chips ... 'ok'",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves out-of-range numeric html entities intact instead of throwing", () => {
|
||||
expect(() => ddgClientTesting.decodeHtmlEntities("Result � end")).not.toThrow();
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Result � end")).toBe(
|
||||
"Result � end",
|
||||
);
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Hex � tail")).toBe("Hex � tail");
|
||||
// Surrogate-range entities would decode to lone UTF-16 surrogates; keep them intact.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Bad � end")).toBe("Bad � end");
|
||||
// A valid supplementary-plane entity still decodes.
|
||||
expect(ddgClientTesting.decodeHtmlEntities("Smile 😀")).toBe("Smile 😀");
|
||||
});
|
||||
|
||||
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 = `
|
||||
<a href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com" class="result__a">
|
||||
Example & Co
|
||||
</a>
|
||||
<a class="result__snippet">Fast search … with details</a>
|
||||
<a class="result__a" href="https://example.org/direct">Direct result</a>
|
||||
<a class="result__snippet">Second snippet</a>
|
||||
`;
|
||||
|
||||
expect(ddgClientTesting.parseDuckDuckGoHtml(html)).toEqual([
|
||||
{
|
||||
title: "Example & Co",
|
||||
url: "https://example.com",
|
||||
snippet: "Fast search ... with details",
|
||||
},
|
||||
{
|
||||
title: "Direct result",
|
||||
url: "https://example.org/direct",
|
||||
snippet: "Second snippet",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects bot challenge pages without flagging ordinary result snippets", () => {
|
||||
const challengeHtml = `
|
||||
<html>
|
||||
<body>
|
||||
<form>
|
||||
<h1>Are you a human?</h1>
|
||||
<div class="g-recaptcha">captcha</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
const normalHtml = `
|
||||
<a class="result__a" href="https://example.com/challenge">Coding Challenge</a>
|
||||
<a class="result__snippet">A fun coding challenge for interview prep.</a>
|
||||
`;
|
||||
|
||||
expect(ddgClientTesting.isBotChallenge(challengeHtml)).toBe(true);
|
||||
expect(ddgClientTesting.parseDuckDuckGoHtml(challengeHtml)).toStrictEqual([]);
|
||||
expect(ddgClientTesting.isBotChallenge(normalHtml)).toBe(false);
|
||||
expect(ddgClientTesting.parseDuckDuckGoHtml(normalHtml)).toEqual([
|
||||
{
|
||||
title: "Coding Challenge",
|
||||
url: "https://example.com/challenge",
|
||||
snippet: "A fun coding challenge for interview prep.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
57
extensions/duckduckgo/src/ddg-search-provider.ts
Normal file
57
extensions/duckduckgo/src/ddg-search-provider.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Duckduckgo provider module implements model/runtime integration.
|
||||
import { readPositiveIntegerParam, readStringParam } from "openclaw/plugin-sdk/param-readers";
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createDuckDuckGoWebSearchProviderBase } from "./ddg-search-provider.shared.js";
|
||||
|
||||
const loadDuckDuckGoClientModule = createLazyRuntimeModule(() => import("./ddg-client.js"));
|
||||
|
||||
const DuckDuckGoSearchSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "Search query string." },
|
||||
count: {
|
||||
type: "integer",
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
},
|
||||
region: {
|
||||
type: "string",
|
||||
description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.",
|
||||
},
|
||||
safeSearch: {
|
||||
type: "string",
|
||||
description: "SafeSearch level: strict, moderate, or off.",
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
} satisfies Record<string, unknown>;
|
||||
|
||||
export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createDuckDuckGoWebSearchProviderBase(),
|
||||
createTool: (ctx) => ({
|
||||
description:
|
||||
"Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.",
|
||||
parameters: DuckDuckGoSearchSchema,
|
||||
execute: async (args) => {
|
||||
const { runDuckDuckGoSearch } = await loadDuckDuckGoClientModule();
|
||||
return await runDuckDuckGoSearch({
|
||||
config: ctx.config,
|
||||
query: readStringParam(args, "query", { required: true }),
|
||||
count: readPositiveIntegerParam(args, "count", {
|
||||
max: 10,
|
||||
message: "count must be an integer from 1 to 10.",
|
||||
}),
|
||||
region: readStringParam(args, "region"),
|
||||
safeSearch: readStringParam(args, "safeSearch") as
|
||||
| "strict"
|
||||
| "moderate"
|
||||
| "off"
|
||||
| undefined,
|
||||
});
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
16
extensions/duckduckgo/tsconfig.json
Normal file
16
extensions/duckduckgo/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
10
extensions/duckduckgo/web-search-contract-api.ts
Normal file
10
extensions/duckduckgo/web-search-contract-api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Duckduckgo API module exposes the plugin public contract.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createDuckDuckGoWebSearchProviderBase } from "./src/ddg-search-provider.shared.js";
|
||||
|
||||
export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
...createDuckDuckGoWebSearchProviderBase(),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
2
extensions/duckduckgo/web-search-provider.ts
Normal file
2
extensions/duckduckgo/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Duckduckgo provider module implements model/runtime integration.
|
||||
export { createDuckDuckGoWebSearchProvider } from "./src/ddg-search-provider.js";
|
||||
Reference in New Issue
Block a user