This commit is contained in:
Yrobot
2025-12-07 00:13:06 +08:00
parent d8e667106a
commit fec8d0cbe0
12 changed files with 1099 additions and 0 deletions

18
utils/dev.js Normal file
View File

@@ -0,0 +1,18 @@
// HTTPS_PROXY=http://localhost:7777 bun ./output/cloudflare-search/utils/dev.js
import searchBrave from "./searchBrave.js";
import searchGoogle from "./searchGoogle.js";
import searchDuckDuckGo from "./searchDuckDuckGo.js";
import searchBing from "./searchBing.js";
const query = "yrobot 博客";
(async () => {
const results = await searchBing({
query,
});
console.log({ results });
})().catch((err) => {
console.error(err);
process.exit(1);
});

375
utils/getHTML.js Normal file
View File

@@ -0,0 +1,375 @@
import { env } from "../envs.js";
// ============================================
// HTML 界面 - HTML UI
// ============================================
export function getSearchHtml() {
const GOOGLE_ENABLED = env.GOOGLE_API_KEY && env.GOOGLE_CX;
const DEFAULT_ENGINES = env.DEFAULT_ENGINES || [];
const handlerEngineDefaultChecked = (engine) =>
DEFAULT_ENGINES.includes(engine) ? "checked" : "";
return `<!DOCTYPE html>
<html lang="zh-CN" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>搜索聚合服务 - SearXNG Compatible</title>
<meta name="description" content="基于 Cloudflare Workers 的多引擎搜索聚合服务,兼容 SearXNG API">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔍</text></svg>">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a1a1aa',
500: '#71717a',
600: '#52525b',
700: '#3f3f46',
800: '#27272a',
900: '#18181b',
},
blue: {
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
}
}
}
}
}
</script>
<style>
:root {
--bg-primary: theme('colors.zinc.50');
--bg-secondary: theme('colors.white');
--text-primary: theme('colors.zinc.800');
--text-secondary: theme('colors.zinc.600');
--border-color: theme('colors.zinc.100');
--accent-color: theme('colors.blue.500');
}
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: theme('colors.black');
--bg-secondary: theme('colors.zinc.900');
--text-primary: theme('colors.zinc.100');
--text-secondary: theme('colors.zinc.400');
--border-color: rgba(63, 63, 70, 0.4);
--accent-color: theme('colors.blue.400');
}
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
}
</style>
</head>
<body class="flex h-full flex-col">
<div class="flex w-full flex-col">
<!-- 主内容区域 -->
<div class="relative flex w-full flex-col bg-white ring-1 ring-zinc-100 dark:bg-zinc-900 dark:ring-zinc-300/20">
<main class="flex-auto">
<div class="sm:px-8 mt-16 sm:mt-32">
<div class="mx-auto w-full max-w-7xl lg:px-8">
<div class="relative px-4 sm:px-8 lg:px-12">
<div class="mx-auto max-w-2xl lg:max-w-5xl">
<!-- 标题区域 -->
<div class="max-w-2xl">
<div class="text-6xl mb-6">🔍</div>
<h1 class="text-4xl font-bold tracking-tight text-zinc-800 sm:text-5xl dark:text-zinc-100">
搜索聚合服务
</h1>
<p class="mt-6 text-base text-zinc-600 dark:text-zinc-400">
基于 Cloudflare Workers 的多引擎搜索聚合服务,支持 Google、DuckDuckGo、Bing、Brave Search,兼容 SearXNG API 规范。
</p>
</div>
<!-- 搜索表单 -->
<div class="mt-16 rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40">
<form id="searchForm" class="space-y-4">
<div>
<label for="searchQuery" class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
搜索内容
</label>
<input
type="text"
id="searchQuery"
placeholder="输入搜索关键词..."
required
class="w-full rounded-md bg-white px-4 py-2 text-sm text-zinc-900 shadow-sm ring-1 ring-inset ring-zinc-300 placeholder:text-zinc-400 focus:ring-2 focus:ring-blue-500 dark:bg-zinc-800 dark:text-zinc-100 dark:ring-zinc-700 dark:placeholder:text-zinc-500"
>
</div>
<div>
<label class="block text-sm font-medium text-zinc-900 dark:text-zinc-100 mb-2">
选择搜索引擎
</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center space-x-2 ${
GOOGLE_ENABLED
? "cursor-pointer"
: "cursor-not-allowed opacity-50"
}" ${
!GOOGLE_ENABLED ? 'title="Google 引擎需要配置 API Key"' : ""
}>
<input type="checkbox" name="engine" value="google" ${
GOOGLE_ENABLED
? handlerEngineDefaultChecked("google")
: "disabled"
} class="rounded text-blue-500 focus:ring-blue-500 ${
!GOOGLE_ENABLED ? "cursor-not-allowed" : ""
}">
<span class="text-sm text-zinc-700 dark:text-zinc-300">
Google
${
!GOOGLE_ENABLED
? '<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1">(未配置)</span>'
: ""
}
</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="engine" value="duckduckgo" ${handlerEngineDefaultChecked(
"duckduckgo"
)} class="rounded text-blue-500 focus:ring-blue-500">
<span class="text-sm text-zinc-700 dark:text-zinc-300">DuckDuckGo</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer" title="结果质量不稳定,不建议开启">
<input type="checkbox" name="engine" value="bing" ${handlerEngineDefaultChecked(
"bing"
)} class="rounded text-blue-500 focus:ring-blue-500">
<span class="text-sm text-zinc-700 dark:text-zinc-300">
Bing
<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1">(不稳定)</span>
</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" name="engine" value="brave" ${handlerEngineDefaultChecked(
"brave"
)} class="rounded text-blue-500 focus:ring-blue-500">
<span class="text-sm text-zinc-700 dark:text-zinc-300">Brave</span>
</label>
</div>
</div>
<button
type="submit"
id="searchBtn"
class="w-full rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900 dark:bg-blue-500 dark:hover:bg-blue-400"
>
开始搜索
</button>
</form>
</div>
<!-- 搜索结果区域 -->
<div id="resultsSection" class="mt-8 hidden">
<div class="rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
搜索结果 <span id="resultCount" class="text-sm font-normal text-zinc-500"></span>
</h2>
<button id="clearBtn" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100">
清除结果
</button>
</div>
<div id="results" class="space-y-4"></div>
</div>
</div>
<!-- API 使用方式 -->
<div class="mt-16 rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
API 调用方式
</h2>
<div class="space-y-4 text-sm text-zinc-600 dark:text-zinc-400">
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
<div class="font-medium text-zinc-900 dark:text-zinc-100 mb-2">GET 请求</div>
<code class="text-xs text-blue-600 dark:text-blue-400 break-all" id="apiExample1"></code>
</div>
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
<div class="font-medium text-zinc-900 dark:text-zinc-100 mb-2">POST 请求</div>
<code class="text-xs text-blue-600 dark:text-blue-400 break-all" id="apiExample2"></code>
</div>
</div>
</div>
<!-- 支持的搜索引擎 -->
<div class="mt-16 rounded-2xl border border-zinc-100 p-6 dark:border-zinc-700/40">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-4">
支持的搜索引擎
</h2>
<div class="grid grid-cols-2 gap-4">
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50 ${
!GOOGLE_ENABLED ? "opacity-50" : ""
}">
<div class="font-medium text-zinc-900 dark:text-zinc-100">
Google
${
!GOOGLE_ENABLED
? '<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1">(未配置)</span>'
: ""
}
</div>
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">
全球最大的搜索引擎${
!GOOGLE_ENABLED ? ",需配置 API Key" : ""
}
</p>
</div>
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
<div class="font-medium text-zinc-900 dark:text-zinc-100">DuckDuckGo</div>
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">注重隐私保护的搜索引擎</p>
</div>
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
<div class="font-medium text-zinc-900 dark:text-zinc-100">
Bing
<span class="text-xs text-zinc-400 dark:text-zinc-500 ml-1">(不稳定)</span>
</div>
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">微软的搜索引擎,目前结果质量尚不稳定</p>
</div>
<div class="rounded-lg bg-zinc-50 p-4 dark:bg-zinc-800/50">
<div class="font-medium text-zinc-900 dark:text-zinc-100">Brave Search</div>
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1">独立的搜索引擎</p>
</div>
</div>
</div>
<!-- 功能特性 -->
<div class="mt-16 grid grid-cols-2 gap-4">
<div class="flex items-center text-sm text-zinc-600 dark:text-zinc-400">
<svg class="w-5 h-5 mr-2 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
多引擎聚合
</div>
<div class="flex items-center text-sm text-zinc-600 dark:text-zinc-400">
<svg class="w-5 h-5 mr-2 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
SearXNG 兼容
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 页脚 -->
<footer class="mt-32">
<div class="sm:px-8">
<div class="mx-auto w-full max-w-7xl lg:px-8">
<div class="border-t border-zinc-100 pt-10 pb-16 dark:border-zinc-700/40">
<div class="relative px-4 sm:px-8 lg:px-12">
<div class="mx-auto max-w-2xl lg:max-w-5xl">
<div class="flex flex-col items-center justify-between gap-6 sm:flex-row">
<p class="text-sm text-zinc-400 dark:text-zinc-500">
Powered by Cloudflare Workers
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</footer>
</div>
</div>
<script>
// 获取当前域名
const currentOrigin = window.location.origin;
// 填充 API 示例
document.getElementById('apiExample1').textContent = currentOrigin + '/search?q=yrobot';
document.getElementById('apiExample2').textContent = 'curl -X POST "' + currentOrigin + '/search" -d "q=yrobot&engines=duckduckgo,bing"';
// 搜索表单提交
document.getElementById('searchForm').addEventListener('submit', async function(event) {
event.preventDefault();
const query = document.getElementById('searchQuery').value.trim();
if (!query) return;
// 获取选中的搜索引擎 (非必填)
const engines = Array.from(document.querySelectorAll('input[name="engine"]:checked:not(:disabled)'))
.map(cb => cb.value)
.join(',');
// 显示加载状态
const searchBtn = document.getElementById('searchBtn');
const originalText = searchBtn.textContent;
searchBtn.textContent = '搜索中...';
searchBtn.disabled = true;
try {
// 调用搜索 API
const url = \`\${currentOrigin}/search?q=\${encodeURIComponent(query)}\`;
const response = await fetch(engines ? \`\${url}&engines=\${engines}\` : url);
const data = await response.json();
// 显示结果
displayResults(data);
} catch (error) {
alert('搜索失败: ' + error.message);
} finally {
searchBtn.textContent = originalText;
searchBtn.disabled = false;
}
});
// 显示搜索结果
function displayResults(data) {
const resultsSection = document.getElementById('resultsSection');
const resultsContainer = document.getElementById('results');
const resultCount = document.getElementById('resultCount');
resultsSection.classList.remove('hidden');
resultCount.textContent = \`(共 \${data.number_of_results} 条)\`;
if (data.results && data.results.length > 0) {
resultsContainer.innerHTML = data.results.map((result, index) => \`
<div class="rounded-lg bg-zinc-50 p-4 overflow-scroll dark:bg-zinc-800/50 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition">
<div class="flex items-start justify-between">
<div class="flex-1 overflow-hidden">
<a href="\${result.url}" target="_blank" class="text-base font-medium text-blue-600 dark:text-blue-400 hover:underline">
\${result.title || '无标题'}
</a>
<p class="text-xs text-zinc-500 dark:text-zinc-500 mt-1">\${result.url}</p>
<p class="text-sm text-zinc-700 dark:text-zinc-300 mt-2">\${result.description || '暂无描述'}</p>
</div>
<span class="ml-4 text-xs text-zinc-500 dark:text-zinc-500 bg-zinc-200 dark:bg-zinc-700 px-2 py-1 rounded">\${result.engine}</span>
</div>
</div>
\`).join('');
} else {
resultsContainer.innerHTML = '<p class="text-center text-zinc-500 dark:text-zinc-400">没有找到相关结果</p>';
}
// 滚动到结果区域
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// 清除结果
document.getElementById('clearBtn').addEventListener('click', function() {
document.getElementById('resultsSection').classList.add('hidden');
document.getElementById('results').innerHTML = '';
});
</script>
</body>
</html>`;
}

6
utils/index.js Normal file
View File

@@ -0,0 +1,6 @@
export const normalizeResults = (results) =>
results.map((result) => ({
title: result.title || result.name || "",
url: result.url || result.link || result.href || "",
description: result.description || result.content || result.snippet || "",
}));

112
utils/searchBing.js Normal file
View File

@@ -0,0 +1,112 @@
import { normalizeResults } from "./index.js";
/**
* Extract real URL from Bing redirect URL
* Bing uses URLs like https://www.bing.com/ck/a?...&u=a1<base64>...
* The 'u' parameter contains 'a1' + base64 encoded real URL
*/
function extractRealUrl(bingUrl) {
// Return as-is if not a Bing redirect URL
if (!bingUrl.includes("bing.com/ck/a?")) {
return bingUrl;
}
try {
// HTML entities like &amp; need to be decoded first
const decodedUrl = bingUrl.replace(/&amp;/g, "&");
const url = new URL(decodedUrl);
const uParam = url.searchParams.get("u");
if (!uParam) {
return bingUrl;
}
// The 'u' parameter starts with 'a1' followed by base64 encoded URL
if (uParam.startsWith("a1")) {
const base64Part = uParam.substring(2); // Remove 'a1' prefix
// Decode from base64 (use Buffer in Node.js, atob in browser)
const decoded =
typeof Buffer !== "undefined"
? Buffer.from(base64Part, "base64").toString("utf-8")
: atob(base64Part);
return decoded;
}
return bingUrl;
} catch (error) {
// If extraction fails, return original URL
return bingUrl;
}
}
// type subSearch under index.d.ts
// TODO: support language, time_range, pageno
async function searchBing({ query, language, time_range, pageno, signal }) {
const searchUrl = `https://www.bing.com/search?q=${encodeURIComponent(
query
)}&form=QBLH&sp=-1&lq=0&pq=&sc=0-0&qs=n&sk=&cvid=&ghsh=0&ghacc=0&ghpl=`;
const response = await fetch(searchUrl, {
signal,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7",
priority: "u=0, i",
"sec-ch-ua":
'"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
},
});
if (!response.ok) {
console.error(`Bing search failed: ${response.status}`);
return [];
}
const html = await response.text();
if (!html) throw new Error("html is empty");
const results = [];
// Parse Bing search results from HTML
// Bing uses <li class="b_algo"> for organic search results
// Updated regex to match current Bing HTML structure where <h2> and <a> are siblings
const resultRegex =
/<li class="b_algo"[^>]*>[\s\S]*?<h2[^>]*><a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a><\/h2>[\s\S]*?<div class="b_caption"[^>]*>[\s\S]*?<p[^>]*>([\s\S]*?)<\/p>/g;
let match;
while ((match = resultRegex.exec(html)) !== null) {
let url = match[1];
// Remove HTML tags from title
const title = match[2].replace(/<[^>]+>/g, "");
// Remove HTML tags from description
const description = match[3].replace(/<[^>]+>/g, "");
// Extract real URL from Bing redirect URL
url = extractRealUrl(url);
results.push({
title: title.trim(),
url: url.trim(),
description: description.trim(),
});
}
if (results.length === 0) {
throw new Error("can't find results in html");
}
return normalizeResults(results);
}
export default searchBing;

70
utils/searchBrave.js Normal file
View File

@@ -0,0 +1,70 @@
import { normalizeResults } from "./index.js";
// type subSearch under index.d.ts
// TODO: support language, time_range, pageno
async function searchBrave({ query, language, time_range, pageno, signal }) {
const searchUrl = `https://search.brave.com/search?q=${encodeURIComponent(
query
)}&spellcheck=0&source=web&summary=0`;
const response = await fetch(searchUrl, {
signal,
headers: {
accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,zh-TW;q=0.7",
priority: "u=0, i",
"sec-ch-ua":
'"Chromium";v="142", "Google Chrome";v="142", "Not_A Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "document",
"sec-fetch-mode": "navigate",
"sec-fetch-site": "none",
"sec-fetch-user": "?1",
"upgrade-insecure-requests": "1",
cookie:
"ui_lang=zh; country=all; safesearch=off; useLocation=0; __Secure-sku#brave-search-captcha=eyJ0eXBlIjoic2luZ2xlLXVzZSIsInZlcnNpb24iOjEsInNrdSI6ImJyYXZlLXNlYXJjaC1jYXB0Y2hhIiwicHJlc2VudGF0aW9uIjoiZXlKcGMzTjFaWElpT2lKaWNtRjJaUzVqYjIwL2MydDFQV0p5WVhabExYTmxZWEpqYUMxallYQjBZMmhoSWl3aWMybG5ibUYwZFhKbElqb2lPRFY2YVU1S1JVMXFTbXhvTTI5TE5UUXhTMFJtTlZkdU4yWjJiall2VmtVMVVHTmFkMlIzV2poMGFreDBTR0V2Tm1SVVMyVjRZWFpSZVVKSFNIcHRVR0ZLUWtSbVFUTkxlV3gzUjNaSVkyZ3ZUVXhvTTFFOVBTSXNJblFpT2lKM1dVZzJTVTFVTlRsTGMzRnpaM1ptTWpVMVRDOHpOVlZrYzFVNU1sRnlSSGxGZVdoWVlXSXdOa2x3TVhscU1HMUlUa0ozUjNGVVpYTlliWGh1TlVGREx5OXdLMUpJUjJzemJrTTJVWHBQWW1aUFpVOHlVVDA5SW4wPSJ9",
},
referrer: "https://search.brave.com/search?source=web",
});
if (!response.ok) {
console.error(`Brave search failed: ${response.status}`);
return [];
}
const html = await response.text();
if (!html) throw new Error("html is empty");
let data;
html.split("\n").forEach((line) => {
if (data) return;
if (line.trimStart().startsWith(`data: [{type:"data",`)) {
const pureLine = line.trim();
const jsonStr = pureLine.slice(
"data: ".length,
pureLine.endsWith(",") ? -1 : undefined
);
data = eval("(" + jsonStr + ")");
}
});
if (!data) throw new Error("can't find data in html");
let result;
data.forEach((item) => {
if (result) return;
if (item?.data?.body?.response?.web?.results)
result = normalizeResults(item.data.body.response.web.results);
});
if (!result) throw new Error("can't find result in data");
if (!(result instanceof Array)) throw new Error("result is not an array");
return result;
}
export default searchBrave;

146
utils/searchDuckDuckGo.js Normal file
View File

@@ -0,0 +1,146 @@
import { normalizeResults } from "./index.js";
/**
* 从 HTML 响应中提取搜索结果
* DuckDuckGo 的 HTML 版本更稳定,不容易被反爬虫机制拦截
*/
function extractResultsFromHTML(html) {
const results = [];
// 匹配搜索结果容器 - DuckDuckGo 使用 class="result" 的 div
const resultRegex =
/<div[^>]*class="[^"]*result[^"]*"[^>]*>([\s\S]*?)<div class="clear"><\/div>/gi;
const matches = html.matchAll(resultRegex);
for (const match of matches) {
const resultHTML = match[1];
// 提取标题 - 在 <h2 class="result__title"> 中
const titleMatch = resultHTML.match(
/<h2[^>]*class="result__title"[^>]*>[\s\S]*?<a[^>]*>(.*?)<\/a>/i
);
const title = titleMatch
? titleMatch[1].replace(/<[^>]*>/g, "").trim()
: "";
// 提取 URL - DuckDuckGo 使用重定向链接,格式为 //duckduckgo.com/l/?uddg=真实URL
// 需要从 uddg 参数中解码真实 URL
const linkMatch = resultHTML.match(
/<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="\/\/duckduckgo\.com\/l\/\?uddg=([^"&]+)/i
);
let url = "";
if (linkMatch) {
try {
// 解码 URL
url = decodeURIComponent(linkMatch[1]);
} catch (e) {
console.error("[DuckDuckGo] Failed to decode URL:", e);
}
}
// 提取描述 - 在 <a class="result__snippet"> 中
const snippetMatch = resultHTML.match(
/<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/i
);
const description = snippetMatch
? snippetMatch[1].replace(/<[^>]*>/g, "").trim()
: "";
// 只添加有效的结果
if (title && url && !url.startsWith("javascript:")) {
results.push({
title: decodeHTMLEntities(title),
url: url,
content: decodeHTMLEntities(description),
});
}
}
return results;
}
/**
* 解码 HTML 实体
*/
function decodeHTMLEntities(text) {
if (!text) return "";
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#x27;/g, "'")
.replace(/&#x2F;/g, "/")
.replace(/&#39;/g, "'")
.replace(/<[^>]*>/g, ""); // 移除任何剩余的 HTML 标签
}
/**
* 搜索 DuckDuckGo
* 使用 HTML 版本,更稳定且不容易被反爬虫机制拦截
* @param {Object} params - 搜索参数
* @param {string} params.query - 搜索关键词
* @param {string} [params.language] - 语言设置
* @param {string} [params.time_range] - 时间范围
* @param {number} [params.pageno] - 页码 (默认: 0)
* @returns {Promise<Array>} 搜索结果数组
*/
async function searchDuckDuckGo({ query, language, time_range, pageno, signal }) {
try {
if (!query) throw new Error("Query cannot be empty!");
// 构建查询参数 - 使用 HTML 版本
const queryParams = {
q: query,
kl: language === "zh" ? "cn-zh" : "wt-wt", // 语言/区域
df: time_range || "", // 时间范围
s: String((pageno || 0) * 30), // 偏移量
};
const queryString = Object.entries(queryParams)
.filter(([_, value]) => value !== "")
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
// 使用 /html 端点
const searchUrl = `https://html.duckduckgo.com/html/?${queryString}`;
const response = await fetch(
searchUrl,
{
signal,
headers: {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
Referer: "https://duckduckgo.com/",
},
}
);
if (!response.ok) {
console.error(`[DuckDuckGo] Search failed: ${response.status}`);
return [];
}
const html = await response.text();
// 从 HTML 中提取结果
const results = extractResultsFromHTML(html);
if (results.length === 0) {
console.log(`[DuckDuckGo] No results found for query: ${query}`);
return [];
}
return normalizeResults(results);
} catch (error) {
console.error("[DuckDuckGo] Search error:", error.message);
return [];
}
}
export default searchDuckDuckGo;

34
utils/searchGoogle.js Normal file
View File

@@ -0,0 +1,34 @@
import { normalizeResults } from "./index.js";
import { env } from "../envs.js";
// type subSearch under index.d.ts
// TODO: support language, time_range, pageno
async function searchGoogle({ query, language, time_range, pageno, signal }) {
const searchUrl = `https://www.googleapis.com/customsearch/v1?key=${env.GOOGLE_API_KEY}&cx=${env.GOOGLE_CX}&q=${encodeURIComponent(
query
)}`;
const response = await fetch(searchUrl, { signal });
if (!response.ok) {
console.error(await response.text());
return [];
}
const data = await response.json();
const results = [];
if (data.items && Array.isArray(data.items)) {
for (const item of data.items) {
results.push({
title: item.title,
url: item.link,
content: item.snippet || "",
});
}
}
return normalizeResults(results);
}
export default searchGoogle;