haixunMaster/lib/search/brave-execute.ts

174 lines
4.8 KiB
TypeScript
Raw Normal View History

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 結果(預設 truefact-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" };
}
}