init
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.wrangler
|
||||||
|
node_modules
|
||||||
23
envs.js
Normal file
23
envs.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export const env = {
|
||||||
|
// 默认超时时间(毫秒) - Default timeout in milliseconds
|
||||||
|
DEFAULT_TIMEOUT: "3000",
|
||||||
|
|
||||||
|
// 支持的搜索引擎列表 - Supported search engines
|
||||||
|
SUPPORTED_ENGINES: ["google", "brave", "duckduckgo", "bing"],
|
||||||
|
|
||||||
|
// 默认启用的搜索引擎 - Default enabled engines
|
||||||
|
DEFAULT_ENGINES: [
|
||||||
|
"google",
|
||||||
|
"brave",
|
||||||
|
"duckduckgo",
|
||||||
|
// "bing" // Bing 目前结果不稳定,默认禁用 - Bing results are currently unstable, disabled by default
|
||||||
|
],
|
||||||
|
|
||||||
|
// Google Custom Search API credentials
|
||||||
|
GOOGLE_API_KEY: null,
|
||||||
|
GOOGLE_CX: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setEnv = (newEnv) => {
|
||||||
|
Object.assign(env, newEnv);
|
||||||
|
};
|
||||||
25
index.d.ts
vendored
Normal file
25
index.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
type ResultItem = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TimeRange = "day" | "month" | "year";
|
||||||
|
|
||||||
|
export type subSearch = (params: {
|
||||||
|
query: string;
|
||||||
|
language?: string;
|
||||||
|
time_range?: TimeRange;
|
||||||
|
pageno?: number;
|
||||||
|
}) => Promise<Array<ResultItem>>;
|
||||||
|
|
||||||
|
export type searchAll = (params: {
|
||||||
|
query: string;
|
||||||
|
engines?: string[];
|
||||||
|
}) => Promise<{
|
||||||
|
query: string;
|
||||||
|
number_of_results: number;
|
||||||
|
enabled_engines: string[];
|
||||||
|
unresponsive_engines: string[];
|
||||||
|
results: Array<ResultItem>;
|
||||||
|
}>;
|
||||||
18
utils/dev.js
Normal file
18
utils/dev.js
Normal 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
375
utils/getHTML.js
Normal 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
6
utils/index.js
Normal 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
112
utils/searchBing.js
Normal 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 & need to be decoded first
|
||||||
|
const decodedUrl = bingUrl.replace(/&/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
70
utils/searchBrave.js
Normal 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
146
utils/searchDuckDuckGo.js
Normal 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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(///g, "/")
|
||||||
|
.replace(/'/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
34
utils/searchGoogle.js
Normal 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;
|
||||||
225
worker.js
Normal file
225
worker.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { env, setEnv } from "./envs.js";
|
||||||
|
import { getSearchHtml } from "./utils/getHTML.js";
|
||||||
|
import searchGoogle from "./utils/searchGoogle.js";
|
||||||
|
import searchBrave from "./utils/searchBrave.js";
|
||||||
|
import searchDuckDuckGo from "./utils/searchDuckDuckGo.js";
|
||||||
|
import searchBing from "./utils/searchBing.js";
|
||||||
|
|
||||||
|
const SEARCH_ENGINES = {
|
||||||
|
google: searchGoogle,
|
||||||
|
brave: searchBrave,
|
||||||
|
duckduckgo: searchDuckDuckGo,
|
||||||
|
bing: searchBing,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse engines parameter
|
||||||
|
* @param {string|undefined} enginesParam - Comma-separated engine names
|
||||||
|
* @returns {string[]} Array of valid engine names
|
||||||
|
*/
|
||||||
|
function parseEngines(enginesParam) {
|
||||||
|
if (!enginesParam) return env.DEFAULT_ENGINES || env.SUPPORTED_ENGINES;
|
||||||
|
|
||||||
|
return enginesParam
|
||||||
|
.split(",")
|
||||||
|
.map((e) => e.trim().toLowerCase())
|
||||||
|
.filter((e) => {
|
||||||
|
// Filter out google if not enabled
|
||||||
|
if (e === "google" && !(env.GOOGLE_API_KEY && env.GOOGLE_CX)) return false;
|
||||||
|
return env.SUPPORTED_ENGINES.includes(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search with a single engine
|
||||||
|
* @param {string} engineName - Engine name
|
||||||
|
* @param {string} query - Search query
|
||||||
|
* @returns {Promise<Array>} Search results
|
||||||
|
*/
|
||||||
|
async function searchSingle(engineName, query) {
|
||||||
|
const searchFn = SEARCH_ENGINES[engineName];
|
||||||
|
if (!searchFn) {
|
||||||
|
console.warn(`Unknown engine: ${engineName}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 AbortController 用于取消请求
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = parseInt(env.DEFAULT_TIMEOUT ?? "3000", 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 设置超时自动取消
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||||
|
|
||||||
|
const result = await searchFn({ query, signal: controller.signal });
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
console.error(`[${engineName}] Timeout after ${timeout}ms`);
|
||||||
|
} else {
|
||||||
|
console.error(`[${engineName}] Error:`, error.message);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search with all specified engines in parallel
|
||||||
|
* @param {Object} params - Search parameters
|
||||||
|
* @param {string} params.query - Search query
|
||||||
|
* @param {string[]} [params.engines] - Array of engine names
|
||||||
|
* @returns {Promise<Object>} Search response matching searchAll type
|
||||||
|
*/
|
||||||
|
async function searchAll({ query, engines }) {
|
||||||
|
const enabledEngines = parseEngines(engines?.join(","));
|
||||||
|
|
||||||
|
console.log(`[searchAll] query="${query}", engines=[${enabledEngines}]`);
|
||||||
|
|
||||||
|
// Execute all searches in parallel
|
||||||
|
const resultsArr = await Promise.allSettled(
|
||||||
|
enabledEngines.map((engine) => searchSingle(engine, query))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect resultsArr and track unresponsive engines
|
||||||
|
const results = [];
|
||||||
|
const unresponsive = [];
|
||||||
|
|
||||||
|
resultsArr.forEach((result, index) => {
|
||||||
|
const engineName = enabledEngines[index];
|
||||||
|
if (result.status === "fulfilled" && result.value.length > 0) {
|
||||||
|
results.push(
|
||||||
|
...result.value.map((item) => ({
|
||||||
|
...item,
|
||||||
|
engine: engineName,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
unresponsive.push(engineName);
|
||||||
|
if (result.status === "rejected") {
|
||||||
|
console.error(`[${engineName}] Rejected:`, result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
number_of_results: results.length,
|
||||||
|
enabled_engines: enabledEngines,
|
||||||
|
unresponsive_engines: unresponsive,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS headers
|
||||||
|
*/
|
||||||
|
const CORS_HEADERS = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "*",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main request handler
|
||||||
|
*/
|
||||||
|
async function handleRequest(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Handle CORS preflight
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return new Response(null, { headers: CORS_HEADERS });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only allow GET and POST
|
||||||
|
if (request.method !== "GET" && request.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", {
|
||||||
|
status: 405,
|
||||||
|
headers: CORS_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root path: return HTML UI
|
||||||
|
if (url.pathname === "/") {
|
||||||
|
return new Response(getSearchHtml(), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html; charset=utf-8",
|
||||||
|
...CORS_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// /search path: handle API requests
|
||||||
|
if (url.pathname === "/search") {
|
||||||
|
// Parse query parameters
|
||||||
|
let params = {};
|
||||||
|
if (request.method === "POST") {
|
||||||
|
const formData = await request.formData();
|
||||||
|
params = Object.fromEntries(formData.entries());
|
||||||
|
} else {
|
||||||
|
params = Object.fromEntries(url.searchParams.entries());
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = params.q || params.query;
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Missing query parameter",
|
||||||
|
message: "Please provide 'q' or 'query' parameter",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...CORS_HEADERS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse engines parameter (optional)
|
||||||
|
const engines = params.engines?.split(",").filter(Boolean) || undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await searchAll({ query, engines });
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(response, null, 2), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...CORS_HEADERS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[handleRequest] Error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Internal server error",
|
||||||
|
message: error.message,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...CORS_HEADERS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 for other paths
|
||||||
|
return new Response("Not Found", {
|
||||||
|
status: 404,
|
||||||
|
headers: CORS_HEADERS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request, env_param) {
|
||||||
|
setEnv(env_param);
|
||||||
|
return handleRequest(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
63
wrangler.toml
Normal file
63
wrangler.toml
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Cloudflare Worker 配置文件
|
||||||
|
# 更多配置选项: https://developers.cloudflare.com/workers/wrangler/configuration/
|
||||||
|
|
||||||
|
# Worker 名称(修改为你想要的名称)
|
||||||
|
name = "cloudflare-search"
|
||||||
|
|
||||||
|
# Worker 主文件
|
||||||
|
main = "worker.js"
|
||||||
|
|
||||||
|
# 兼容日期 - 锁定 Workers 运行时行为
|
||||||
|
# 使用你开发时的日期,避免未来行为变更影响你的代码
|
||||||
|
compatibility_date = "2024-12-04"
|
||||||
|
|
||||||
|
# 自动使用 workers.dev 子域名
|
||||||
|
# 设置为 true 会自动分配一个 xxx.workers.dev 域名
|
||||||
|
workers_dev = true
|
||||||
|
|
||||||
|
# 账号 ID (可选,部署时会自动填充)
|
||||||
|
# account_id = "your-account-id"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 路由配置(可选)
|
||||||
|
# ============================================
|
||||||
|
# 如果你想将 Worker 绑定到自定义域名,取消注释并配置:
|
||||||
|
# routes = [
|
||||||
|
# { pattern = "search.yourdomain.com/*", zone_name = "yourdomain.com" }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 环境变量(可选)
|
||||||
|
# ============================================
|
||||||
|
[vars]
|
||||||
|
GOOGLE_API_KEY = ""
|
||||||
|
GOOGLE_CX = ""
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 环境配置(可选)
|
||||||
|
# ============================================
|
||||||
|
# 生产环境配置
|
||||||
|
# [env.production]
|
||||||
|
# name = "cloudflare-search-prod"
|
||||||
|
# workers_dev = false
|
||||||
|
# routes = [
|
||||||
|
# { pattern = "search.yourdomain.com/*", zone_name = "yourdomain.com" }
|
||||||
|
# ]
|
||||||
|
|
||||||
|
# 开发环境配置
|
||||||
|
# [env.staging]
|
||||||
|
# name = "cloudflare-search-staging"
|
||||||
|
# workers_dev = true
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 资源限制(可选)
|
||||||
|
# ============================================
|
||||||
|
# 限制 CPU 时间(毫秒)
|
||||||
|
# limits = { cpu_ms = 50 }
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# 观察性配置(可选)
|
||||||
|
# ============================================
|
||||||
|
# 启用日志推送
|
||||||
|
# [observability]
|
||||||
|
# enabled = true
|
||||||
Reference in New Issue
Block a user