174 lines
4.8 KiB
TypeScript
174 lines
4.8 KiB
TypeScript
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<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" };
|
||
}
|
||
} |