2026-06-21 12:50:31 +00:00
|
|
|
|
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
|
2026-06-21 16:28:26 +00:00
|
|
|
|
.filter((item) => item.url && (!threadsOnly || /threads\.(?:com|net)/i.test(item.url)))
|
2026-06-21 12:50:31 +00:00
|
|
|
|
.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<BraveExecuteResult> {
|
|
|
|
|
|
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<SearchResult[]>("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" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|