yfb-plugin/background.js

271 lines
8.9 KiB
JavaScript
Raw Normal View History

2026-04-08 13:07:01 +08:00
importScripts("config.js");
const CONFIG = self.YFB_EXTENSION_CONFIG || {};
const CHAT_ENDPOINT = CONFIG.chatEndpoint || "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
const REQUEST_TIMEOUT_MS = Number(CONFIG.requestTimeoutMs) || 45000;
chrome.runtime.onInstalled.addListener(() => {
console.log("乙方宝招标筛选助手已安装");
});
chrome.action.onClicked.addListener((tab) => {
if (!tab || !tab.id) {
return;
}
chrome.tabs.sendMessage(tab.id, { type: "YFB_TOGGLE_PANEL" }, () => {
void chrome.runtime.lastError;
});
});
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "YFB_ANALYZE_CANDIDATE") {
return false;
}
void analyzeCandidate(message.payload)
.then((data) => {
sendResponse({ ok: true, data });
})
.catch((error) => {
sendResponse({
ok: false,
error: error instanceof Error ? error.message : "AI 分析失败"
});
});
return true;
});
async function analyzeCandidate(payload) {
if (!CONFIG.apiKey) {
throw new Error("缺少 DASHSCOPE_API_KEY请先执行 npm.cmd run build。");
}
const response = await fetchWithTimeout(CHAT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${CONFIG.apiKey}`
},
body: JSON.stringify({
model: CONFIG.model || "qwen-plus",
temperature: 0.1,
max_tokens: 120,
messages: buildMessages(payload)
})
});
if (!response.ok) {
const errorText = await safeReadText(response);
throw new Error(`AI 接口返回 ${response.status}: ${errorText || "未知错误"}`);
}
const data = await response.json();
const rawContent = extractMessageContent(data);
const parsed = parseAiJson(rawContent);
return normalizeAiResult(parsed, payload);
}
async function fetchWithTimeout(url, options) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(url, {
...options,
signal: controller.signal
});
} catch (error) {
if (error && error.name === "AbortError") {
throw new Error("AI 请求超时");
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
function buildMessages(payload) {
const compactInput = buildCompactInput(payload);
return [
{
role: "system",
content:
'判断是否属于金融机构相关招标。仅当服务对象明确是银行/信托/保险/消费金融/金融租赁/汽车金融等金融机构,且业务属于"法律服务"、"催收/不良资产处置"、"调解服务"之一时命中。只返回 JSON{"category":"法律服务|催收/不良资产处置|调解服务|不命中","confidence":0-100}。'
},
{
role: "user",
content: JSON.stringify(compactInput)
}
];
}
function buildCompactInput(payload) {
const keywordHints = payload?.keywordHints || {};
return {
title: String(payload?.title || ""),
type: String(payload?.type || ""),
region: String(payload?.region || ""),
publishTime: String(payload?.publishTime || ""),
detailText: limitLength(String(payload?.detailText || ""), Number(CONFIG.maxAiChars) || 1800),
attachmentNames: Array.isArray(payload?.attachmentNames) ? payload.attachmentNames.slice(0, 6) : [],
keywordHints: {
institutions: Array.isArray(keywordHints.institutions) ? keywordHints.institutions.slice(0, 6) : [],
legal: Array.isArray(keywordHints.legal) ? keywordHints.legal.slice(0, 6) : [],
collection: Array.isArray(keywordHints.collection) ? keywordHints.collection.slice(0, 6) : [],
mediation: Array.isArray(keywordHints.mediation) ? keywordHints.mediation.slice(0, 6) : [],
all: Array.isArray(keywordHints.all) ? keywordHints.all.slice(0, 12) : []
}
};
}
function extractMessageContent(apiResponse) {
const content = apiResponse?.choices?.[0]?.message?.content;
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
return part?.text || "";
})
.join("\n");
}
return "";
}
function parseAiJson(text) {
const trimmed = String(text || "").trim();
if (!trimmed) {
throw new Error("AI 返回为空");
}
try {
return JSON.parse(trimmed);
} catch (error) {
const startIndex = trimmed.indexOf("{");
const endIndex = trimmed.lastIndexOf("}");
if (startIndex === -1 || endIndex === -1 || endIndex <= startIndex) {
throw new Error("AI 返回不是有效 JSON");
}
return JSON.parse(trimmed.slice(startIndex, endIndex + 1));
}
}
function normalizeAiResult(result, payload) {
const category = normalizeCategory(result?.category);
const institutionType = Array.isArray(result?.institutionType)
? result.institutionType.filter(Boolean).map((item) => String(item).trim())
: Array.isArray(payload?.keywordHints?.institutions)
? payload.keywordHints.institutions.slice(0, 3)
: [];
const matchedKeywords = Array.isArray(result?.matchedKeywords)
? result.matchedKeywords.filter(Boolean).map((item) => String(item).trim())
: Array.isArray(payload?.keywordHints?.all)
? payload.keywordHints.all
: [];
return {
isRelevant: (typeof result?.isRelevant === "boolean" ? result.isRelevant : category !== "不命中") && category !== "不命中",
category,
institutionType,
confidence: normalizeConfidence(result?.confidence),
2026-04-08 14:27:39 +08:00
titleSummary: limitLength(result?.titleSummary || "", 60),
2026-04-08 13:07:01 +08:00
reason: limitLength(result?.reason || "", 120),
matchedKeywords: dedupe(matchedKeywords)
};
}
function normalizeCategory(category) {
const value = String(category || "").trim();
if (value === "法律服务" || value === "催收/不良资产处置" || value === "调解服务") {
return value;
}
return "不命中";
}
function normalizeConfidence(confidence) {
const numeric = Number(confidence);
if (!Number.isFinite(numeric)) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(numeric)));
}
function limitLength(text, maxLength) {
const normalized = String(text || "").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return normalized.slice(0, maxLength - 1) + "…";
}
function dedupe(list) {
return Array.from(new Set(list.filter(Boolean)));
}
async function safeReadText(response) {
try {
return await response.text();
} catch (error) {
return "";
}
}
2026-04-08 14:27:39 +08:00
function buildMessages(payload) {
const compactInput = buildCompactInput(payload);
return [
{
role: "system",
content: [
"Analyze whether this procurement belongs to a financial-institution-related bidding case.",
'Only hit when the service target is clearly a financial institution such as a bank, trust, insurance, consumer finance, financial leasing, or auto finance, and the business belongs to one of these categories: "法律服务", "催收/不良资产处置", "调解服务".',
'Return JSON only with keys: {"category":"法律服务|催收/不良资产处置|调解服务|不命中","confidence":0-100,"summary":"...","reason":"..."}',
'The "summary" must come from the announcement body, not the title.',
'Do not repeat the title or start with the title.',
'Do not include metadata such as "发布时间", "项目编号", "招标单位", "采购单位", "代理单位", "报名截止时间", or "投标截止时间".',
'Keep "summary" around 50 Chinese characters, ideally within 30 to 50 Chinese characters.',
'If the body does not provide a valid summary, return an empty string for "summary".',
'Keep "reason" brief and concrete.'
].join(" ")
},
{
role: "user",
content: JSON.stringify(compactInput)
}
];
}
function normalizeAiResult(result, payload) {
const category = normalizeCategory(result?.category);
const summary = limitLength(result?.summary || result?.titleSummary || "", 60);
const institutionType = Array.isArray(result?.institutionType)
? result.institutionType.filter(Boolean).map((item) => String(item).trim())
: Array.isArray(payload?.keywordHints?.institutions)
? payload.keywordHints.institutions.slice(0, 3)
: [];
const matchedKeywords = Array.isArray(result?.matchedKeywords)
? result.matchedKeywords.filter(Boolean).map((item) => String(item).trim())
: Array.isArray(payload?.keywordHints?.all)
? payload.keywordHints.all
: [];
return {
isRelevant: (typeof result?.isRelevant === "boolean" ? result.isRelevant : category !== "不命中") && category !== "不命中",
category,
institutionType,
confidence: normalizeConfidence(result?.confidence),
summary,
titleSummary: summary,
reason: limitLength(result?.reason || "", 120),
matchedKeywords: dedupe(matchedKeywords)
};
}