diff --git a/README.md b/README.md index de17d42..c8d926e 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - ⚡ **并行搜索** - 所有搜索引擎同时请求,快速返回结果 - 🛡️ **容错机制** - 单个引擎失败不影响其他引擎,自动标记无响应引擎 - ⏱️ **超时控制** - 可配置请求超时时间,避免长时间等待 +- 🔒 **Token 鉴权** - 支持 Token 认证,保护服务不被滥用 - 🌍 **CORS 支持** - 完整的跨域资源共享支持 - 🎨 **Web 界面** - 提供简洁的搜索界面,方便测试 - ⚡ **零成本运行** - Cloudflare Workers 免费版每天 10 万次请求 @@ -81,6 +82,9 @@ curl "https://$YOUR-DOMAIN/search?q=cloudflare" # 指定搜索引擎 curl "https://$YOUR-DOMAIN/search?q=cloudflare&engines=google,brave" + +# 使用 token 鉴权(如果配置了 TOKEN 环境变量) +curl "https://$YOUR-DOMAIN/search?q=cloudflare&token=$YOUR-TOKEN" ``` ### 方式 3: API 请求(POST) @@ -91,6 +95,7 @@ curl "https://$YOUR-DOMAIN/search?q=cloudflare&engines=google,brave" curl -X POST "https://$YOUR-DOMAIN/search" \ -d "q=cloudflare" \ -d "engines=google,brave" + -d "token=$YOUR-TOKEN" # 如果配置了 TOKEN 环境变量 ``` ## API 接口说明 @@ -101,10 +106,11 @@ curl -X POST "https://$YOUR-DOMAIN/search" \ #### 请求参数 -| 参数 | 类型 | 必填 | 说明 | 示例 | -| ------------- | -------- | ---- | ---------------------------- | -------------- | -| `q` / `query` | `string` | yes | 搜索关键词 | `cloudflare` | -| `engines` | `string` | no | 指定搜索引擎,多个用逗号分隔 | `google,brave` | +| 参数 | 类型 | 必填 | 说明 | 示例 | +| ------------- | -------- | ------ | ----------------------------------------- | -------------- | +| `q` / `query` | `string` | yes | 搜索关键词 | `cloudflare` | +| `engines` | `string` | no | 指定搜索引擎,多个用逗号分隔 | `google,brave` | +| `token` | `string` | no/yse | 访问令牌(当配置了 TOKEN 环境变量时必填) | `$YOUR-TOKEN` | **支持的搜索引擎**: @@ -185,12 +191,6 @@ curl -X POST "https://$YOUR-DOMAIN/search" \ 3. **结果聚合**:将所有成功返回的结果合并,标记来源引擎 4. **容错处理**:记录无响应的引擎,返回部分结果而不是完全失败 -### 搜索引擎选择建议 - -- **无需配置快速部署**:使用 `brave` + `duckduckgo` -- **高质量结果**:配置 Google API 并使用 `google` + `brave` -- **最大覆盖**:启用所有引擎 `google,brave,duckduckgo,bing` - ## 环境变量配置 ### 环境变量说明 @@ -200,8 +200,12 @@ curl -X POST "https://$YOUR-DOMAIN/search" \ | `DEFAULT_TIMEOUT` | `string` | `"3000"` | 单个搜索引擎的超时时间(毫秒) | | `GOOGLE_API_KEY` | `string` | `null` | https://console.cloud.google.com/apis/credentials | | `GOOGLE_CX` | `string` | `null` | https://programmablesearchengine.google.com/ | +| `TOKEN` | `string` | `null` | 访问令牌,配置后启用鉴权,保护服务不被滥用 | -**注意**:Google Custom Search API 免费版每天限制 100 次请求。 +**注意**: + +- Google Custom Search API 免费版每天限制 100 次请求 +- `TOKEN` 配置后,所有请求都需要提供有效的 token ### 配置方式 @@ -213,7 +217,8 @@ curl -X POST "https://$YOUR-DOMAIN/search" \ [vars] GOOGLE_API_KEY = "your-google-api-key" GOOGLE_CX = "your-google-custom-search-cx" -DEFAULT_TIMEOUT = "5000" +DEFAULT_TIMEOUT = "3000" +TOKEN = "your-secret-token-here" ``` #### 方式 2: Cloudflare Dashboard @@ -290,6 +295,29 @@ const byEngine = data.results.reduce((acc, result) => { - 可通过环境变量 `DEFAULT_TIMEOUT` 调整 - 建议不要设置过长,避免整体响应时间过长 +### 🔒 安全配置 + +#### 启用鉴权 + +1. 配置 `TOKEN` 环境变量 来保护你的服务不被滥用: + +- 利用 wrangler.toml 配置 TOKEN 环境变量 +- 通过 Cloudflare Worker Dashboard 配置 TOKEN 环境变量 + +2. 在请求时传入 token: + +```bash +# 访问首页 +https://$YOUR-DOMAIN?token=$YOUR-TOKEN + +# 使用 query/body 的 token 参数 请求 API +curl "https://$YOUR-DOMAIN/search?q=cloudflare&token=$YOUR-TOKEN" + +curl -X POST "https://$YOUR-DOMAIN/search" \ + -d "q=cloudflare" \ + -d "token=$YOUR-TOKEN" +``` + ## 常见问题 ### Q: 为什么有些搜索引擎返回结果为空? @@ -312,7 +340,17 @@ A: 建议: ### Q: Bing 搜索为什么默认禁用? -A: Bing 搜索结果目前不够稳定,经常返回空结果或格式不一致。如需使用,可以在请求时手动指定:`engines=bing` 或修改 `envs.js` 中的 `DEFAULT_ENGINES`。 +A: Bing 搜索结果目前不够稳定,出现内容与搜索关联度低的情况。如需使用,可以在请求时手动指定:`engines=bing` 或修改 `envs.js` 中的 `DEFAULT_ENGINES`。 + +### Q: 如何保护服务不被滥用? + +A: 建议配置 `TOKEN` 环境变量启用鉴权: + +1. 在 `wrangler.toml` 中设置 `TOKEN = "your-random-token"` +2. 或在 Cloudflare Dashboard 的 Environment Variables 中添加 +3. 配置后所有请求都需要提供有效的 token + +鉴权失败会返回 401 错误 ## 免责声明 diff --git a/envs.js b/envs.js index d5e805a..8ab2844 100644 --- a/envs.js +++ b/envs.js @@ -16,6 +16,11 @@ export const env = { // Google Custom Search API credentials GOOGLE_API_KEY: null, GOOGLE_CX: null, + + // API 访问令牌 - API access token for authentication + // 如果设置了 TOKEN,则所有 /search 请求都需要在 header 或 query 中提供此 token + // If TOKEN is set, all /search requests must provide this token in header or query + TOKEN: null, }; export const setEnv = (newEnv) => { diff --git a/index.d.ts b/index.d.ts index 2c4294a..c9b3ebf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -11,6 +11,7 @@ export type subSearch = (params: { language?: string; time_range?: TimeRange; pageno?: number; + signal?: AbortSignal; }) => Promise>; export type searchAll = (params: { @@ -21,5 +22,14 @@ export type searchAll = (params: { number_of_results: number; enabled_engines: string[]; unresponsive_engines: string[]; - results: Array; + results: Array; }>; + +export interface Env { + DEFAULT_TIMEOUT?: string; + SUPPORTED_ENGINES?: string[]; + DEFAULT_ENGINES?: string[]; + GOOGLE_API_KEY?: string; + GOOGLE_CX?: string; + TOKEN?: string; +} diff --git a/worker.js b/worker.js index 1b1e741..fd85126 100644 --- a/worker.js +++ b/worker.js @@ -25,7 +25,8 @@ function parseEngines(enginesParam) { .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; + if (e === "google" && !(env.GOOGLE_API_KEY && env.GOOGLE_CX)) + return false; return env.SUPPORTED_ENGINES.includes(e); }); } @@ -56,7 +57,7 @@ async function searchSingle(engineName, query) { clearTimeout(timeoutId); return result; } catch (error) { - if (error.name === 'AbortError') { + if (error.name === "AbortError") { console.error(`[${engineName}] Timeout after ${timeout}ms`); } else { console.error(`[${engineName}] Error:`, error.message); @@ -122,6 +123,22 @@ const CORS_HEADERS = { "Access-Control-Max-Age": "86400", }; +/** + * Verify authentication token + */ +function verifyToken(request, paramToken) { + // If TOKEN is not configured, skip authentication + if (!env.TOKEN) { + return true; + } + + const token = + request.headers.get("Authorization")?.replace(/^Bearer\s+/i, "") || + paramToken; + + return token === env.TOKEN; +} + /** * Main request handler */ @@ -141,6 +158,32 @@ async function handleRequest(request) { }); } + // 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()); + } + + // Verify authentication token + if (!verifyToken(request, params.token)) { + return new Response( + JSON.stringify({ + error: "Unauthorized", + message: "Invalid or missing authentication token", + }), + { + status: 401, + headers: { + "Content-Type": "application/json", + ...CORS_HEADERS, + }, + } + ); + } + // Root path: return HTML UI if (url.pathname === "/") { return new Response(getSearchHtml(), { @@ -153,15 +196,6 @@ async function handleRequest(request) { // /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) { diff --git a/wrangler.toml b/wrangler.toml index 4cbe011..cc0a80e 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -30,9 +30,15 @@ workers_dev = true # 环境变量(可选) # ============================================ [vars] +# Google Custom Search API 配置 GOOGLE_API_KEY = "" GOOGLE_CX = "" +# API 访问令牌(可选) +# 配置后,所有 /search 请求都需要提供此 token 进行鉴权 +# 可以通过 Authorization header (Bearer token) 或 query 参数 (?token=xxx) 传递 +TOKEN = "" + # ============================================ # 环境配置(可选) # ============================================