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 ;
} ) ;
} ) ;
2026-04-10 11:28:42 +08:00
chrome . runtime . onMessage . addListener ( ( message , _sender , sendResponse ) => {
if ( ! message ? . type ) {
2026-04-08 13:07:01 +08:00
return false ;
}
2026-04-10 11:28:42 +08:00
if ( message . type === "YFB_ANALYZE_CANDIDATE" ) {
void analyzeCandidate ( message . payload )
. then ( ( data ) => {
sendResponse ( { ok : true , data } ) ;
} )
. catch ( ( error ) => {
sendResponse ( {
ok : false ,
error : error instanceof Error ? error . message : "AI 分析失败"
} ) ;
2026-04-08 13:07:01 +08:00
} ) ;
2026-04-10 11:28:42 +08:00
return true ;
}
if ( message . type === "YFB_PROCESS_DETAIL_IN_HIDDEN_TAB" ) {
void processDetailInHiddenTab ( message . payload )
. then ( ( data ) => {
sendResponse ( { ok : true , data } ) ;
} )
. catch ( ( error ) => {
sendResponse ( {
ok : false ,
error : error instanceof Error ? error . message : "详情抓取失败"
} ) ;
} ) ;
return true ;
}
return false ;
2026-04-08 13:07:01 +08:00
} ) ;
2026-04-10 11:28:42 +08:00
async function processDetailInHiddenTab ( payload ) {
const detailUrl = String ( payload ? . detailUrl || "" ) . trim ( ) ;
const rowMeta = payload ? . rowMeta || { } ;
if ( ! detailUrl ) {
throw new Error ( "缺少详情页地址" ) ;
}
if ( ! isSupportedDetailUrl ( detailUrl ) ) {
throw new Error ( "详情页地址不受支持" ) ;
}
const tab = await chrome . tabs . create ( {
url : detailUrl ,
active : false
} ) ;
if ( ! tab ? . id ) {
throw new Error ( "隐藏详情标签页创建失败" ) ;
}
try {
await waitForTabComplete ( tab . id , 20000 ) ;
await waitForDetailWorkerReady ( tab . id , 12000 ) ;
const response = await sendMessageToTab ( tab . id , {
type : "YFB_RUN_DETAIL_EXTRACTION" ,
payload : {
rowMeta ,
detailUrl
}
} ) ;
if ( ! response ? . ok || ! response . data ) {
throw new Error ( response ? . error || "详情提取失败" ) ;
}
return response . data ;
} finally {
await closeTabQuietly ( tab . id ) ;
}
}
function isSupportedDetailUrl ( url ) {
try {
const parsed = new URL ( url ) ;
return /(^|\.)yfbzb\.com$/i . test ( parsed . hostname ) || /(^|\.)qianlima\.com$/i . test ( parsed . hostname ) ;
} catch ( error ) {
return false ;
}
}
async function waitForTabComplete ( tabId , timeoutMs ) {
const tab = await chrome . tabs . get ( tabId ) ;
if ( tab ? . status === "complete" ) {
return ;
}
await new Promise ( ( resolve , reject ) => {
const timeoutId = setTimeout ( ( ) => {
chrome . tabs . onUpdated . removeListener ( handleUpdated ) ;
reject ( new Error ( "详情页加载超时" ) ) ;
} , timeoutMs ) ;
const handleUpdated = ( updatedTabId , changeInfo ) => {
if ( updatedTabId !== tabId || changeInfo . status !== "complete" ) {
return ;
}
clearTimeout ( timeoutId ) ;
chrome . tabs . onUpdated . removeListener ( handleUpdated ) ;
resolve ( ) ;
} ;
chrome . tabs . onUpdated . addListener ( handleUpdated ) ;
} ) ;
}
async function waitForDetailWorkerReady ( tabId , timeoutMs ) {
const startTime = Date . now ( ) ;
while ( Date . now ( ) - startTime < timeoutMs ) {
const response = await sendMessageToTab ( tabId , { type : "YFB_DETAIL_WORKER_PING" } ) ;
if ( response ? . ok ) {
return ;
}
await delay ( 200 ) ;
}
throw new Error ( "详情页脚本未就绪" ) ;
}
async function sendMessageToTab ( tabId , message ) {
try {
return await chrome . tabs . sendMessage ( tabId , message ) ;
} catch ( error ) {
return {
ok : false ,
error : error instanceof Error ? error . message : "标签页通信失败"
} ;
}
}
async function closeTabQuietly ( tabId ) {
try {
await chrome . tabs . remove ( tabId ) ;
} catch ( error ) {
void error ;
}
}
async function delay ( ms ) {
return new Promise ( ( resolve ) => setTimeout ( resolve , ms ) ) ;
}
2026-04-08 13:07:01 +08:00
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 )
} ;
}