import { cacheGet, cacheSet } from "./cache"; import { getSearchConfig } from "./config"; import { extractThreadId } from "./dedupe"; import { logSearchEvent } from "./logger"; import { canUseDailyQuota, getDailyQuotaUsed, incrementDailyQuota } from "./quota"; import { buildBraveThreadsKeywordQuery } from "./query-builders"; import type { KeywordPriority, SearchResult } from "./types"; export interface BraveExecuteOptions { keyword: string; limit: number; /** 直接指定查詢字串(略過 keyword query builder) */ query?: string; priority?: KeywordPriority; /** 巡邏模式:檢查 high priority + 每日額度(預設 true) */ patrolMode?: boolean; /** 僅保留 threads.com 結果(預設 true;fact-check 等可設 false) */ threadsOnly?: boolean; } export interface BraveExecuteResult { results: SearchResult[]; status: "success" | "unavailable" | "skipped"; skipReason?: string; quotaUsed?: number; quotaLimit?: number; fromCache?: boolean; } function mapBraveItems( items: Array<{ title?: string; description?: string; url?: string }>, limit: number, threadsOnly: boolean ): SearchResult[] { return items .filter((item) => item.url && (!threadsOnly || /threads\.(?:com|net)/i.test(item.url))) .slice(0, limit) .map((item) => ({ title: item.title ?? "", snippet: item.description ?? "", url: item.url ?? "", author: "", source: "brave" as const, threadId: extractThreadId(item.url ?? ""), })); } export async function executeBraveSearch(opts: BraveExecuteOptions): Promise { const cfg = getSearchConfig().brave; const patrolMode = opts.patrolMode !== false; const threadsOnly = opts.threadsOnly !== false; const quotaLimit = cfg.dailyLimit; if (!cfg.enabled || !cfg.apiKey) { return { results: [], status: "unavailable" }; } if (patrolMode && opts.priority && opts.priority !== "high") { logSearchEvent({ kind: "provider", provider: "brave", status: "skipped", keyword: opts.keyword, reason: "keyword_priority_not_high", }); return { results: [], status: "skipped", skipReason: "keyword_priority_not_high", }; } if (patrolMode && !canUseDailyQuota("brave", quotaLimit)) { logSearchEvent({ kind: "provider", provider: "brave", status: "skipped", keyword: opts.keyword, reason: "daily_limit_reached", quotaUsed: getDailyQuotaUsed("brave"), quotaLimit, }); return { results: [], status: "skipped", skipReason: "daily_limit_reached", quotaUsed: getDailyQuotaUsed("brave"), quotaLimit, }; } const query = opts.query?.trim() || buildBraveThreadsKeywordQuery(opts.keyword); const cacheKey = `search:brave:${query}`; const cached = cacheGet("brave", cacheKey); if (cached) { logSearchEvent({ kind: "provider", provider: "brave", status: "skipped", keyword: opts.keyword, reason: "cache_hit", count: cached.length, quotaUsed: getDailyQuotaUsed("brave"), quotaLimit, }); return { results: cached.slice(0, opts.limit), status: "success", skipReason: "cache_hit", quotaUsed: getDailyQuotaUsed("brave"), quotaLimit, fromCache: true, }; } try { const url = new URL(cfg.baseUrl); url.searchParams.set("q", query); url.searchParams.set("count", String(Math.min(opts.limit, cfg.resultLimit, 20))); url.searchParams.set("country", "tw"); url.searchParams.set("search_lang", "zh-hant"); const res = await fetch(url.toString(), { headers: { Accept: "application/json", "X-Subscription-Token": cfg.apiKey, }, signal: AbortSignal.timeout(20_000), }); if (!res.ok) { logSearchEvent({ kind: "provider", provider: "brave", status: "unavailable", keyword: opts.keyword, }); return { results: [], status: "unavailable" }; } const data = (await res.json()) as { web?: { results?: Array<{ title?: string; description?: string; url?: string }> }; }; const results = mapBraveItems(data.web?.results ?? [], opts.limit, threadsOnly); const quotaUsed = patrolMode ? incrementDailyQuota("brave") : getDailyQuotaUsed("brave"); cacheSet("brave", cacheKey, results, cfg.cacheTtlMs); logSearchEvent({ kind: "provider", provider: "brave", status: "success", keyword: opts.keyword, count: results.length, quotaUsed, quotaLimit, }); return { results, status: "success", quotaUsed, quotaLimit, }; } catch { logSearchEvent({ kind: "provider", provider: "brave", status: "unavailable", keyword: opts.keyword, }); return { results: [], status: "unavailable" }; } }