(() => { if (window.__YFB_BID_ASSISTANT_LOADED__) { return; } window.__YFB_BID_ASSISTANT_LOADED__ = true; const CONFIG = globalThis.YFB_EXTENSION_CONFIG || {}; const STORAGE_KEY = "yfbBidAutomationState"; const PANEL_ID = "yfb-bid-assistant-panel"; const STYLE_ID = "yfb-bid-assistant-style"; const BANNER_ID = "yfb-bid-assistant-banner"; const KEYWORD_MARK_CLASS = "yfb-keyword-highlight"; const MAX_LOG_ENTRIES = Number(CONFIG.maxLogEntries) || 80; const LIST_ROW_SELECTOR = "tr.el-table__row"; const LIST_CARD_ROW_SELECTOR = ".list > div"; const LIST_TITLE_SELECTOR = ".color1879F7.pointer, .color1879F7.textEll.pointer"; const LIST_CARD_TITLE_SELECTOR = ".title-flex > div, .title-flex > a, .title-flex .pointer, a[href*='infoDetail']"; const NEXT_PAGE_SELECTOR = ".list-pagination-container .next-btn"; const SUBSCRIPTION_FILTER_SELECTOR = ".subscribe-product-filter .filter-header, .select-filters-wrap"; const SUBSCRIPTION_GROUP_TAB_SELECTOR = ".subscribe-product-filter .filter-header .right-tabs .tab-item.active"; const SUBSCRIPTION_ALL_ITEM_SELECTOR = ".subcategory-item, .subcategory-content > div"; const SUBSCRIPTION_CONFIRM_SELECTOR = "button.confirm-btn, .confirm-btn"; const DETAIL_TITLE_SELECTORS = [ ".long-project-name > div", "#printInfo-head .long-project-name > div", ".project-head-container .project-name" ]; const DETAIL_CONTENT_SELECTORS = [ ".project-detail-content", ".detail-content", ".notice-content", ".article-content", ".rich-text", ".ck-content", ".w-e-text", ".attachment-content" ]; const KNOWN_DIALOG_TITLES = [ "选择要关联的商机", "附件发送至邮箱", "系统提示", "信息纠错", "联系人电话" ]; const KEYWORDS = { institutions: [ "银行", "农商行", "农商银行", "农村商业银行", "农信", "信用社", "邮储", "邮政储蓄", "信用卡中心", "信托", "保险", "消费金融", "金融租赁", "融资租赁", "汽车金融", "资产管理公司", "AMC" ], legal: [ "法律服务", "律师库", "律师事务所", "律所", "律师", "外聘律所", "外聘律师", "诉讼", "非诉", "非诉讼", "司法催收", "法务催收", "逾期贷款" ], collection: [ "委外催收", "催收", "不良资产处置", "不良资产", "贷后管理", "清收服务", "欠款提醒", "逾期账户", "逾期贷款", "资产处置" ], mediation: [ "调解服务", "诉前调解", "委外调解", "调解", "纠纷调解" ] }; const CATEGORY_LABELS = { legal: "法律服务", collection: "催收/不良资产处置", mediation: "调解服务" }; const SUBSCRIPTION_GROUP_DEFINITIONS = [ { key: "group1", labels: ["分组1", "未命名(1)"], selectors: [ "div.filter-container div:nth-of-type(2) > i", "div.filter-container div:nth-of-type(2)", "div.select-filters-wrap div:nth-of-type(2)" ] }, { key: "group2", labels: ["分组2", "调解服务(4)"], selectors: [ "div.filter-container div:nth-of-type(3) > i", "div.filter-container div:nth-of-type(3)", "div.select-filters-wrap div:nth-of-type(3)" ] }, { key: "group3", labels: ["分组3", "催收业务(8)"], selectors: [ "div.filter-container div:nth-of-type(4) > i", "div.filter-container div:nth-of-type(4)", "div.select-filters-wrap div:nth-of-type(4)" ] } ]; const SUBSCRIPTION_POPUP_ALL_SELECTOR = "div.subcategory-content > div:nth-of-type(1)"; // 代表弹窗里的“全部” const SUBSCRIPTION_POPUP_CONFIRM_SELECTOR = "button.confirm-btn"; // 代表弹窗里的“确认” const state = { isRunning: false, stopRequested: false, panelCollapsed: false, panelHidden: false, statusText: "等待开始", settings: { maxPages: 3, delayMs: 300 }, stats: { scanned: 0, hits: 0, currentPage: 1, currentIndex: 0 }, results: [], rowStatusById: {}, logs: [] }; const ui = { panel: null, status: null, logContainer: null, scannedValue: null, hitsValue: null, pageValue: null, rowValue: null, maxPagesInput: null, delayInput: null, startButton: null, subscribeAllButton: null, stopButton: null, exportButton: null, clearButton: null, toggleButton: null }; let mutationObserver = null; let syncTimer = null; let bannerHideTimer = null; void initialize(); async function initialize() { if (!isSupportedHost()) { return; } injectStyles(); await restoreState(); mountPanel(); bindRuntimeMessages(); bindMutationObserver(); schedulePageSync(); refreshView(); } function isSupportedHost() { return /(^|\.)yfbzb\.com$/i.test(location.hostname) || /(^|\.)qianlima\.com$/i.test(location.hostname); } function injectStyles() { if (document.getElementById(STYLE_ID)) { return; } const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` #${PANEL_ID} { position: fixed; right: 18px; bottom: 18px; width: 388px; z-index: 2147483647; border-radius: 22px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(248, 250, 252, 0.98) 100%); color: #0f172a; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.16); border: 1px solid rgba(148, 163, 184, 0.24); backdrop-filter: blur(10px); font-family: "Microsoft YaHei", "PingFang SC", sans-serif; overflow: hidden; } #${PANEL_ID}.is-hidden { display: none; } #${PANEL_ID} .yfb-panel-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px 14px; border-bottom: 1px solid rgba(226, 232, 240, 0.95); cursor: move; } #${PANEL_ID} .yfb-panel-title { display: flex; flex-direction: column; gap: 2px; } #${PANEL_ID} .yfb-panel-eyebrow { font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: #ea580c; } #${PANEL_ID} .yfb-panel-name { font-size: 17px; font-weight: 800; color: #0f172a; } #${PANEL_ID} .yfb-panel-subtitle { font-size: 12px; color: #64748b; } #${PANEL_ID} .yfb-panel-toggle { border: 0; background: linear-gradient(135deg, #fff7ed 0%, #ffedd5 100%); color: #c2410c; width: 34px; height: 34px; border-radius: 999px; cursor: pointer; font-size: 18px; font-weight: 700; box-shadow: inset 0 0 0 1px rgba(251, 146, 60, 0.18); } #${PANEL_ID}.is-collapsed .yfb-panel-body { display: none; } #${PANEL_ID}.is-collapsed { width: 64px; height: 64px; right: 16px; border-radius: 999px; box-shadow: 0 16px 38px rgba(15, 23, 42, 0.18); } #${PANEL_ID}.is-collapsed .yfb-panel-header { justify-content: center; padding: 0; height: 64px; border-bottom: 0; } #${PANEL_ID}.is-collapsed .yfb-panel-title { display: none; } #${PANEL_ID}.is-collapsed .yfb-panel-toggle { width: 64px; height: 64px; background: radial-gradient(circle at 30% 30%, #fff7ed 0%, #ffedd5 45%, #fed7aa 100%); box-shadow: none; } #${PANEL_ID} .yfb-panel-body { padding: 14px 16px 16px; } #${PANEL_ID} .yfb-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 14px; } #${PANEL_ID} .yfb-stat { border-radius: 16px; padding: 10px 10px 11px; background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%); border: 1px solid rgba(226, 232, 240, 0.95); } #${PANEL_ID} .yfb-stat-label { display: block; font-size: 11px; color: #64748b; margin-bottom: 4px; } #${PANEL_ID} .yfb-stat-value { font-size: 18px; font-weight: 800; color: #0f172a; } #${PANEL_ID} .yfb-field-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-bottom: 14px; } #${PANEL_ID} .yfb-field { display: flex; flex-direction: column; gap: 6px; } #${PANEL_ID} .yfb-field label { font-size: 12px; color: #475569; } #${PANEL_ID} .yfb-field input { border: 1px solid #dbe4ee; background: #ffffff; color: #0f172a; border-radius: 12px; height: 38px; padding: 0 12px; outline: none; } #${PANEL_ID} .yfb-field input:focus { border-color: #fb923c; box-shadow: 0 0 0 3px rgba(251, 146, 60, 0.16); } #${PANEL_ID} .yfb-button-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-bottom: 12px; } #${PANEL_ID} .yfb-button { border: 0; border-radius: 12px; min-height: 40px; font-weight: 700; cursor: pointer; } #${PANEL_ID} .yfb-button:disabled { opacity: 0.48; cursor: not-allowed; } #${PANEL_ID} .yfb-button-primary { background: linear-gradient(135deg, #f59e0b 0%, #ea580c 100%); color: #fff; } #${PANEL_ID} .yfb-button-secondary { background: #ffffff; color: #1e293b; border: 1px solid #dbe4ee; } #${PANEL_ID} .yfb-status { min-height: 44px; border-radius: 14px; padding: 10px 12px; font-size: 12px; line-height: 1.5; background: #fff7ed; color: #7c2d12; margin-bottom: 10px; } #${PANEL_ID} .yfb-hint { font-size: 11px; color: #64748b; margin-bottom: 10px; } #${PANEL_ID} .yfb-log { max-height: 220px; overflow-y: auto; padding: 10px; border-radius: 14px; background: #f8fafc; border: 1px solid #e2e8f0; } #${PANEL_ID} .yfb-log-entry { font-size: 12px; line-height: 1.45; margin-bottom: 8px; padding-left: 10px; border-left: 2px solid rgba(148, 163, 184, 0.45); color: #334155; } #${PANEL_ID} .yfb-log-entry.info { border-left-color: #60a5fa; } #${PANEL_ID} .yfb-log-entry.success { border-left-color: #34d399; } #${PANEL_ID} .yfb-log-entry.warning { border-left-color: #fbbf24; } #${PANEL_ID} .yfb-log-entry.error { border-left-color: #f87171; } ${LIST_ROW_SELECTOR}[data-yfb-status="hit"], ${LIST_CARD_ROW_SELECTOR}[data-yfb-status="hit"] { box-shadow: inset 4px 0 0 #f59e0b; background: rgba(245, 158, 11, 0.08); } ${LIST_ROW_SELECTOR}[data-yfb-status="skip"], ${LIST_CARD_ROW_SELECTOR}[data-yfb-status="skip"] { box-shadow: inset 4px 0 0 rgba(148, 163, 184, 0.7); } ${LIST_ROW_SELECTOR}[data-yfb-status="scanning"], ${LIST_CARD_ROW_SELECTOR}[data-yfb-status="scanning"] { box-shadow: inset 4px 0 0 #38bdf8; background: rgba(56, 189, 248, 0.08); } ${LIST_ROW_SELECTOR}[data-yfb-status="error"], ${LIST_CARD_ROW_SELECTOR}[data-yfb-status="error"] { box-shadow: inset 4px 0 0 #f87171; background: rgba(248, 113, 113, 0.08); } .yfb-row-badge { display: inline-flex; align-items: center; margin-left: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; line-height: 1.6; font-weight: 700; } .yfb-row-badge.hit { color: #7c2d12; background: rgba(245, 158, 11, 0.18); } .yfb-row-badge.skip { color: #cbd5e1; background: rgba(148, 163, 184, 0.16); } .yfb-row-badge.scanning { color: #082f49; background: rgba(56, 189, 248, 0.22); } .yfb-row-badge.error { color: #7f1d1d; background: rgba(248, 113, 113, 0.2); } #${BANNER_ID} { position: fixed; top: 18px; left: 18px; z-index: 2147483646; max-width: 560px; border-radius: 16px; padding: 14px 16px; background: rgba(251, 191, 36, 0.96); color: #3f2600; box-shadow: 0 18px 48px rgba(245, 158, 11, 0.32); font-family: "Microsoft YaHei", "PingFang SC", sans-serif; pointer-events: none; } #${BANNER_ID} .yfb-banner-title { font-size: 15px; font-weight: 800; margin-bottom: 4px; } #${BANNER_ID} .yfb-banner-meta { font-size: 12px; line-height: 1.45; } mark.${KEYWORD_MARK_CLASS} { background: linear-gradient(120deg, rgba(251, 191, 36, 0.85) 0%, rgba(249, 115, 22, 0.85) 100%); color: #111827; padding: 0 2px; border-radius: 4px; } `; document.documentElement.appendChild(style); } async function restoreState() { const saved = await storageGet(STORAGE_KEY); if (!saved) { return; } state.panelCollapsed = Boolean(saved.panelCollapsed); state.panelHidden = Boolean(saved.panelHidden); state.statusText = saved.statusText || state.statusText; state.settings = { ...state.settings, ...(saved.settings || {}) }; state.stats = { ...state.stats, ...(saved.stats || {}) }; state.results = Array.isArray(saved.results) ? saved.results : []; state.rowStatusById = saved.rowStatusById || {}; state.logs = Array.isArray(saved.logs) ? saved.logs.slice(-MAX_LOG_ENTRIES) : []; } function buildPanelMarkup() { return `
YFB AI 乙方宝招标筛选助手 自动切筛选、抓详情、AI 分析并导出 Excel
已扫描0
命中数0
当前页1
当前条0
开始前停留在筛选列表页即可。脚本会先把订阅产品右侧 3 个分组依次切到“全部”,再进入详情抽取标题、正文和 AI 分析结果。
`; } function mountPanel() { if (document.getElementById(PANEL_ID)) { return; } const panel = document.createElement("div"); panel.id = PANEL_ID; panel.innerHTML = buildPanelMarkup(); /*
AI Bid Filter 乙方宝招标筛选助手 当前列表页内自动扫描、命中高亮、导出 Excel
已扫描0
命中数0
当前页1
当前条0
建议先切到乙方宝招标列表页再启动。插件会优先抓标题、正文和附件名,再做 AI 判定。
*/; document.documentElement.appendChild(panel); ui.panel = panel; ui.status = panel.querySelector("[data-role='status']"); ui.logContainer = panel.querySelector("[data-role='logs']"); ui.scannedValue = panel.querySelector("[data-role='scanned']"); ui.hitsValue = panel.querySelector("[data-role='hits']"); ui.pageValue = panel.querySelector("[data-role='page']"); ui.rowValue = panel.querySelector("[data-role='row']"); ui.maxPagesInput = panel.querySelector("#yfb-max-pages"); ui.delayInput = panel.querySelector("#yfb-delay-ms"); ui.startButton = panel.querySelector("[data-role='start']"); ui.subscribeAllButton = panel.querySelector("[data-role='subscribe-all']"); ui.stopButton = panel.querySelector("[data-role='stop']"); ui.exportButton = panel.querySelector("[data-role='export']"); ui.clearButton = panel.querySelector("[data-role='clear']"); ui.toggleButton = panel.querySelector(".yfb-panel-toggle"); ui.maxPagesInput.value = String(state.settings.maxPages); ui.delayInput.value = String(state.settings.delayMs); ui.maxPagesInput.addEventListener("change", handleSettingsChange); ui.delayInput.addEventListener("change", handleSettingsChange); ui.startButton.addEventListener("click", () => { void startScan(); }); ui.subscribeAllButton.addEventListener("click", () => { void runSubscriptionOnly(); }); ui.stopButton.addEventListener("click", stopScan); ui.exportButton.addEventListener("click", exportResults); ui.clearButton.addEventListener("click", clearResults); ui.toggleButton.addEventListener("click", toggleCollapse); enableDrag(panel.querySelector(".yfb-panel-header"), panel); } function enableDrag(handle, target) { let offsetX = 0; let offsetY = 0; let dragging = false; handle.addEventListener("pointerdown", (event) => { if (event.target.closest("button")) { return; } const rect = target.getBoundingClientRect(); dragging = true; offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; event.preventDefault(); }); handle.addEventListener("pointermove", (event) => { if (!dragging) { return; } target.style.left = `${Math.max(8, event.clientX - offsetX)}px`; target.style.top = `${Math.max(8, event.clientY - offsetY)}px`; target.style.right = "auto"; target.style.bottom = "auto"; }); const stopDragging = () => { if (!dragging) { return; } dragging = false; void persistState(); }; handle.addEventListener("pointerup", stopDragging); handle.addEventListener("pointercancel", stopDragging); } function bindRuntimeMessages() { chrome.runtime.onMessage.addListener((message) => { if (!message) { return; } if (message.type === "YFB_TOGGLE_PANEL") { state.panelHidden = !state.panelHidden; refreshView(); void persistState(); } }); } async function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function doSubscribeAll() { if (state.isRunning) { return; } handleSettingsChange(); if (!isListPage()) { setStatus("请先打开乙方宝招标筛选列表页,再执行订阅分组。"); log("当前页面不是招标筛选列表页,已取消订阅分组测试。", "warning"); refreshView(); return; } state.isRunning = true; state.stopRequested = false; setStatus("正在处理订阅分组,请稍候..."); log("开始执行订阅分组全选测试。", "info"); refreshView(); try { await waitForUiSettled(); const groups = await prepareSubscriptionFilters({ strict: true }); setStatus(`订阅分组处理完成,已处理 ${groups.length} 个分组。`); log(`订阅分组处理完成,已处理 ${groups.length} 个分组。`, "success"); } catch (error) { const message = error instanceof Error ? error.message : "订阅分组处理失败"; if (message === "__YFB_STOPPED__") { setStatus("订阅分组任务已停止。"); log("订阅分组任务已手动停止。", "warning"); } else { setStatus(`订阅分组处理异常:${message}`); log(`订阅分组处理异常:${message}`, "error"); } } finally { state.isRunning = false; state.stopRequested = false; refreshView(); await persistState(); } } function bindMutationObserver() { if (mutationObserver) { return; } mutationObserver = new MutationObserver(() => { schedulePageSync(); }); mutationObserver.observe(document.body, { childList: true, subtree: true }); } function schedulePageSync() { clearTimeout(syncTimer); syncTimer = setTimeout(() => { if (isListPage()) { restoreListHighlights(); } else { restoreDetailHighlightIfPossible(); } }, 220); } function restoreDetailHighlightIfPossible() { const title = findTitleCandidate(""); if (!title) { return; } const matchedResult = state.results.find((item) => { return item.title === title || item.title.includes(title) || title.includes(item.title); }); if (!matchedResult) { return; } const currentBanner = document.getElementById(BANNER_ID); if (currentBanner && currentBanner.dataset.resultId === matchedResult.id) { return; } showDetailBanner(matchedResult); highlightKeywords(matchedResult.matchedKeywords || []); } function handleSettingsChange() { const maxPages = clampNumber(ui.maxPagesInput.value, 1, 200, state.settings.maxPages); const delayMs = clampNumber(ui.delayInput.value, 200, 10000, state.settings.delayMs); state.settings.maxPages = maxPages; state.settings.delayMs = delayMs; ui.maxPagesInput.value = String(maxPages); ui.delayInput.value = String(delayMs); void persistState(); } function toggleCollapse() { state.panelCollapsed = !state.panelCollapsed; refreshView(); void persistState(); } function stopScan() { if (!state.isRunning) { setStatus("当前没有正在运行的扫描任务。"); refreshView(); return; } state.stopRequested = true; log("收到停止指令,当前记录处理完后会终止任务。", "warning"); refreshView(); } async function startScan() { if (state.isRunning) { return; } handleSettingsChange(); if (!isListPage()) { setStatus("请先打开乙方宝招标列表页,再启动扫描。"); log("当前页面不是招标列表页,已取消启动。", "warning"); refreshView(); return; } // 默认执行一次订阅归零 await doSubscribeAll(); state.isRunning = true; state.stopRequested = false; setStatus(`准备开始扫描,最多 ${state.settings.maxPages} 页。`); log(`开始扫描,最多 ${state.settings.maxPages} 页,步进延迟 ${state.settings.delayMs}ms。`, "info"); refreshView(); try { await waitForListReady(); for (let pageOffset = 0; pageOffset < state.settings.maxPages; pageOffset += 1) { throwIfStopped(); state.stats.currentPage = getCurrentPageNumber() || pageOffset + 1; state.stats.currentIndex = 0; refreshView(); const rows = collectRows(); if (rows.length === 0) { throw new Error("列表页没有可扫描的数据。"); } log(`第 ${state.stats.currentPage} 页识别到 ${rows.length} 条记录。`, "info"); for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { throwIfStopped(); state.stats.currentIndex = rowIndex + 1; refreshView(); await processRow(rows[rowIndex].id, rowIndex); } if (pageOffset >= state.settings.maxPages - 1) { break; } const moved = await goToNextPage(); if (!moved) { log("已到最后一页,停止继续翻页。", "info"); break; } } setStatus(`扫描完成,命中 ${state.results.length} 条。`); log(`扫描完成,累计命中 ${state.results.length} 条。`, "success"); } catch (error) { const message = error instanceof Error ? error.message : "扫描失败"; if (message === "__YFB_STOPPED__") { setStatus("任务已停止。"); log("任务已手动停止。", "warning"); } else { setStatus(`扫描异常:${message}`); log(`扫描异常:${message}`, "error"); } } finally { state.isRunning = false; state.stopRequested = false; state.stats.currentIndex = 0; refreshView(); await persistState(); } } async function processRow(rowId, rowIndex) { const rowMeta = findRowById(rowId) || collectRows()[rowIndex]; if (!rowMeta || !rowMeta.titleEl) { log(`第 ${rowIndex + 1} 条记录在当前页已不可见,跳过。`, "warning"); return; } updateRowStatus(rowMeta.id, "scanning", "扫描中"); log(`正在处理:${rowMeta.title}`, "info"); await persistState(); if (shouldSkipRowByPreview(rowMeta)) { state.stats.scanned += 1; updateRowStatus(rowMeta.id, "skip", "列表预筛未命中"); log(`列表预筛跳过:${rowMeta.title}`, "info"); state.stats.hits = state.results.length; refreshView(); await persistState(); return; } await clickElement(rowMeta.titleEl); await sleep(state.settings.delayMs); await waitForDetailPage(); try { const detailRecord = extractDetailRecord(rowMeta); const decision = await analyzeRecord(detailRecord); state.stats.scanned += 1; if (decision.isRelevant) { addResult(detailRecord, decision); updateRowStatus(rowMeta.id, "hit", decision.category); showDetailBanner({ title: detailRecord.title, category: decision.category, confidence: decision.confidence, reason: decision.reason }); highlightKeywords(decision.matchedKeywords || []); log(`命中:${detailRecord.title} -> ${decision.category}`, "success"); } else { updateRowStatus(rowMeta.id, "skip", "未命中"); clearDetailHighlights(); log(`未命中:${detailRecord.title}`, "info"); } } catch (error) { const message = error instanceof Error ? error.message : "记录处理失败"; updateRowStatus(rowMeta.id, "error", "异常"); log(`处理失败:${rowMeta.title},${message}`, "error"); } finally { state.stats.hits = state.results.length; refreshView(); await persistState(); await navigateBackToList(); await sleep(state.settings.delayMs); restoreListHighlights(); } } function extractDetailRecord(rowMeta) { const title = findTitleCandidate(rowMeta.title); const detailText = collectDetailText(title); const attachmentNames = uniqueText( Array.from(document.querySelectorAll(".attachment-name")).map((element) => element.textContent || "") ); const combinedText = normalizeText( [title, rowMeta.type, rowMeta.region, rowMeta.publishTime, detailText, attachmentNames.join(" ")].join("\n") ); const keywordHints = collectKeywordHints(combinedText); return { id: rowMeta.id, title, type: rowMeta.type, region: rowMeta.region, publishTime: rowMeta.publishTime, detailUrl: location.href, sourceUrl: location.href, attachmentNames, detailText, keywordHints }; } async function analyzeRecord(detailRecord) { const fallbackCategory = inferCategoryFromHints(detailRecord.keywordHints); const fallbackSummary = buildSummary(detailRecord, detailRecord.keywordHints.all); if (detailRecord.keywordHints.totalHits === 0) { return { isRelevant: false, category: "不命中", institutionType: [], confidence: 0, titleSummary: fallbackSummary, reason: "标题、正文和附件名均未命中目标关键词。", matchedKeywords: [] }; } try { const response = await runtimeSendMessage({ type: "YFB_ANALYZE_CANDIDATE", payload: buildAiPayload(detailRecord) }); if (!response || !response.ok) { throw new Error(response?.error || "AI 分析失败"); } const aiResult = response.data; const normalizedCategory = aiResult.category && aiResult.category !== "不命中" ? aiResult.category : fallbackCategory; return { isRelevant: Boolean(aiResult.isRelevant) && normalizedCategory !== "不命中", category: normalizedCategory || "不命中", institutionType: Array.isArray(aiResult.institutionType) ? aiResult.institutionType : [], confidence: Number.isFinite(aiResult.confidence) ? aiResult.confidence : 0, titleSummary: aiResult.titleSummary || fallbackSummary, reason: aiResult.reason || "AI 未返回判断依据。", matchedKeywords: uniqueText([...(aiResult.matchedKeywords || []), ...detailRecord.keywordHints.all]) }; } catch (error) { const degradeRelevant = Boolean(detailRecord.keywordHints.institutions.length && fallbackCategory !== "不命中"); return { isRelevant: degradeRelevant, category: degradeRelevant ? fallbackCategory : "不命中", institutionType: detailRecord.keywordHints.institutions.slice(0, 3), confidence: degradeRelevant ? 58 : 20, titleSummary: fallbackSummary, reason: degradeRelevant ? `AI 调用失败,已按规则降级判定。${error instanceof Error ? error.message : ""}`.trim() : `AI 调用失败,且规则未形成有效命中。${error instanceof Error ? error.message : ""}`.trim(), matchedKeywords: detailRecord.keywordHints.all }; } } async function navigateBackToList() { if (isListPage()) { return; } history.back(); await waitFor(() => isListPage(), 15000, 220, "返回列表页超时", false); await waitForListReady(false); } async function goToNextPage() { const nextButton = document.querySelector(NEXT_PAGE_SELECTOR); if (!nextButton || nextButton.disabled || nextButton.classList.contains("is-disabled")) { return false; } const previousFirstRowId = collectRows()[0]?.id || ""; await clickElement(nextButton); await sleep(state.settings.delayMs); await waitFor( () => { const currentRows = collectRows(); return currentRows.length > 0 && currentRows[0].id !== previousFirstRowId; }, 15000, 220, "翻页超时" ); await waitForListReady(); restoreListHighlights(); return true; } async function waitForListReady(respectStop = true) { await waitFor(() => collectRows().length > 0, 15000, 220, "列表数据加载超时", respectStop); } async function waitForDetailPage() { await waitFor(() => detectDetailPage(), 15000, 220, "详情页加载超时"); } function detectDetailPage() { if (document.querySelector(".see_source_url, .attachment-content, .attachment-name")) { return true; } return !isListPage() && (location.href.includes("detail") || location.hash.includes("detail")); } function isListPage() { return collectRows().length > 0; } function collectRows() { const cardRows = collectCardRows(); if (cardRows.length > 0) { return cardRows; } return collectTableRows(); } function collectTableRows() { return Array.from(document.querySelectorAll(LIST_ROW_SELECTOR)) .map((rowEl) => { const cells = rowEl.querySelectorAll("td"); const titleEl = rowEl.querySelector(LIST_TITLE_SELECTOR) || rowEl.querySelector(".pointer"); const title = normalizeText(titleEl?.textContent || cells[0]?.innerText || ""); const type = normalizeText(cells[1]?.innerText || ""); const region = normalizeText(cells[2]?.innerText || ""); const publishTime = normalizeText(cells[3]?.innerText || ""); return buildRowMeta(rowEl, titleEl, title, type, region, publishTime); }) .filter((item) => item.title && item.titleEl); } function collectCardRows() { return Array.from(document.querySelectorAll(LIST_CARD_ROW_SELECTOR)) .filter((rowEl) => rowEl instanceof HTMLElement && isElementVisible(rowEl)) .map((rowEl) => { const titleEl = rowEl.querySelector(LIST_CARD_TITLE_SELECTOR) || rowEl.querySelector(LIST_TITLE_SELECTOR); const title = normalizeText(titleEl?.textContent || ""); const tokens = uniqueText( Array.from(rowEl.querySelectorAll("span, em, small, p, div")) .map((element) => normalizeText(element.textContent || "")) .filter((text) => text && text.length <= 24) ); const type = tokens.find((text) => isLikelyNoticeType(text)) || ""; const publishTime = tokens.find((text) => isLikelyPublishTime(text)) || ""; const region = tokens.find((text) => text !== type && text !== publishTime && isLikelyRegionText(text)) || ""; return buildRowMeta(rowEl, titleEl, title, type, region, publishTime); }) .filter((item) => item.title && item.titleEl); } function buildRowMeta(rowEl, titleEl, title, type, region, publishTime) { const previewText = limitLength( removeBoilerplateText(normalizeText(rowEl?.innerText || ""), title), Number(CONFIG.maxListPreviewChars) || 400 ); return { id: buildRowId(title, type, region, publishTime || limitLength(previewText, 36)), rowEl, titleEl, title, type, region, publishTime, previewText, previewKeywordHints: collectKeywordHints([title, type, region, publishTime, previewText].join("\n")) }; } function isLikelyNoticeType(text) { return /(公告|采购|招标|中标|商机|项目)/.test(text) && text.length <= 12; } function isLikelyPublishTime(text) { return /^(\d{4}[./-]\d{1,2}[./-]\d{1,2}|\d{1,2}[./-]\d{1,2}|今天|昨日|刚刚|\d+分钟前)$/.test(text); } function isLikelyRegionText(text) { if (!text || text.length > 18) { return false; } return /省|市|区|县|自治区|自治州|旗|盟|镇|乡/.test(text) || text.includes(".") || text.includes("·"); } function findRowById(rowId) { return collectRows().find((item) => item.id === rowId) || null; } function buildRowId(title, type, region, publishTime) { return [title, type, region, publishTime].map((item) => normalizeText(item)).join(" | "); } function getCurrentPageNumber() { const activePage = document.querySelector(".page-item.active"); return clampNumber(activePage?.textContent || "1", 1, 9999, 1); } function findTitleCandidate(fallbackTitle) { const candidates = Array.from( document.querySelectorAll("h1, h2, h3, [class*='title'], [class*='name'], [class*='headline']") ) .filter((element) => !element.closest(`#${PANEL_ID}`)) .map((element) => { const text = normalizeText(element.textContent || ""); const rect = element.getBoundingClientRect(); const fontSize = Number.parseFloat(window.getComputedStyle(element).fontSize || "0"); const score = (rect.top >= 0 && rect.top < 520 ? 80 : 0) + fontSize + Math.max(0, 140 - text.length); return { text, score }; }) .filter((item) => item.text.length >= 8 && item.text.length <= 140) .sort((left, right) => right.score - left.score); if (candidates.length > 0) { return candidates[0].text; } return fallbackTitle; } function collectDetailText(title) { const selectors = [ ".detail-content", ".notice-content", ".article-content", ".rich-text", ".ck-content", ".w-e-text", ".attachment-content", "[class*='detail']", "[class*='content']", "[class*='article']", "main", "article", ".el-main" ]; const texts = []; for (const selector of selectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { if (element.closest(`#${PANEL_ID}`)) { continue; } const text = normalizeText(element.innerText || ""); if (text.length < 80) { continue; } texts.push(text); } } let merged = uniqueText(texts).join("\n\n"); if (!merged) { merged = normalizeText(document.body.innerText || ""); } return limitLength(removeBoilerplateText(merged, title), Number(CONFIG.maxBodyChars) || 12000); } function removeBoilerplateText(text, title) { const boilerplateList = ["乙方宝", "查看全部消息", "营业执照", "ICP经营许可证", "发送到邮箱", "查看源网址"]; let result = normalizeText(text); for (const item of boilerplateList) { result = result.replaceAll(item, ""); } if (title) { result = result.replace(title + title, title); } return normalizeText(result); } function collectKeywordHints(text) { const normalized = normalizeText(text); const institutions = collectHits(normalized, KEYWORDS.institutions); const legal = collectHits(normalized, KEYWORDS.legal); const collection = collectHits(normalized, KEYWORDS.collection); const mediation = collectHits(normalized, KEYWORDS.mediation); const all = uniqueText([...institutions, ...legal, ...collection, ...mediation]); return { institutions, legal, collection, mediation, all, totalHits: all.length }; } function collectHits(text, keywordList) { return keywordList.filter((keyword) => text.includes(keyword)); } function inferCategoryFromHints(keywordHints) { const scores = [ { key: "legal", score: keywordHints.legal.length }, { key: "collection", score: keywordHints.collection.length }, { key: "mediation", score: keywordHints.mediation.length } ].sort((left, right) => right.score - left.score); if (!scores[0] || scores[0].score === 0) { return "不命中"; } return CATEGORY_LABELS[scores[0].key] || "不命中"; } function buildSummary(detailRecord, matchedKeywords) { const candidateLines = detailRecord.detailText .split(/[\n。;]/) .map((line) => normalizeText(line)) .filter((line) => line && line !== detailRecord.title && line.length >= 16); if (candidateLines.length > 0) { return limitLength(candidateLines[0], 60); } if (matchedKeywords.length > 0) { return limitLength(`命中关键词:${matchedKeywords.join("、")}`, 60); } return limitLength(detailRecord.title, 60); } function buildAiPayload(detailRecord) { return { title: detailRecord.title, type: detailRecord.type, region: detailRecord.region, publishTime: detailRecord.publishTime, attachmentNames: Array.isArray(detailRecord.attachmentNames) ? detailRecord.attachmentNames.slice(0, 6) : [], detailText: limitLength( detailRecord.announcementContent || detailRecord.detailText || "", Number(CONFIG.maxAiChars) || 1800 ), keywordHints: { institutions: detailRecord.keywordHints?.institutions?.slice(0, 6) || [], legal: detailRecord.keywordHints?.legal?.slice(0, 6) || [], collection: detailRecord.keywordHints?.collection?.slice(0, 6) || [], mediation: detailRecord.keywordHints?.mediation?.slice(0, 6) || [], all: detailRecord.keywordHints?.all?.slice(0, 12) || [], totalHits: detailRecord.keywordHints?.totalHits || 0 } }; } function addResult(detailRecord, decision) { const result = { id: detailRecord.id, title: detailRecord.title, summary: decision.titleSummary || buildSummary(detailRecord, decision.matchedKeywords || []), category: decision.category || "不命中", institutionType: uniqueText(decision.institutionType || []), matchedKeywords: uniqueText(decision.matchedKeywords || []), confidence: Number(decision.confidence) || 0, reason: decision.reason || "", region: detailRecord.region, publishTime: detailRecord.publishTime, detailUrl: detailRecord.detailUrl, sourceUrl: detailRecord.sourceUrl, attachmentNames: detailRecord.attachmentNames }; const existingIndex = state.results.findIndex((item) => item.id === result.id); if (existingIndex >= 0) { state.results.splice(existingIndex, 1, result); } else { state.results.push(result); } } function showDetailBanner(result) { clearDetailBanner(); const banner = document.createElement("div"); banner.id = BANNER_ID; banner.dataset.resultId = result.id || ""; banner.innerHTML = `
命中 ${escapeHtml(result.category || "目标类型")}
标题:${escapeHtml(result.title || "-")}
置信度:${escapeHtml(String(result.confidence || 0))}
说明:${escapeHtml(result.reason || "无")}
`; document.documentElement.appendChild(banner); } function clearDetailBanner() { const banner = document.getElementById(BANNER_ID); if (banner) { banner.remove(); } } function highlightKeywords(keywords) { clearKeywordHighlights(); if (!Array.isArray(keywords) || keywords.length === 0) { return; } const keywordList = uniqueText(keywords) .filter((item) => item.length >= 2) .sort((left, right) => right.length - left.length) .slice(0, 12); if (keywordList.length === 0) { return; } const root = pickHighlightRoot(); if (!root) { return; } const pattern = new RegExp(`(${keywordList.map(escapeRegExp).join("|")})`, "g"); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) { return NodeFilter.FILTER_REJECT; } const parent = node.parentElement; if (!parent) { return NodeFilter.FILTER_REJECT; } if ( parent.closest(`#${PANEL_ID}`) || parent.closest(`#${BANNER_ID}`) || parent.closest("mark") || ["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"].includes(parent.tagName) ) { return NodeFilter.FILTER_REJECT; } pattern.lastIndex = 0; return pattern.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); const textNodes = []; while (walker.nextNode()) { textNodes.push(walker.currentNode); } textNodes.forEach((node) => { const text = node.nodeValue || ""; pattern.lastIndex = 0; if (!pattern.test(text)) { return; } pattern.lastIndex = 0; const fragment = document.createDocumentFragment(); let lastIndex = 0; let match = pattern.exec(text); while (match) { const matchText = match[0]; const index = match.index; if (index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, index))); } const mark = document.createElement("mark"); mark.className = KEYWORD_MARK_CLASS; mark.textContent = matchText; fragment.appendChild(mark); lastIndex = index + matchText.length; match = pattern.exec(text); } if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } node.parentNode.replaceChild(fragment, node); }); } function pickHighlightRoot() { const candidates = [ ".detail-content", ".notice-content", ".article-content", ".rich-text", ".ck-content", ".w-e-text", "[class*='detail']", "[class*='content']", "main", "article" ]; for (const selector of candidates) { const element = document.querySelector(selector); if (element && !element.closest(`#${PANEL_ID}`)) { return element; } } return document.body; } function clearKeywordHighlights() { document.querySelectorAll(`mark.${KEYWORD_MARK_CLASS}`).forEach((mark) => { const textNode = document.createTextNode(mark.textContent || ""); mark.replaceWith(textNode); }); } function clearDetailHighlights() { clearDetailBanner(); clearKeywordHighlights(); } function updateRowStatus(rowId, status, label) { state.rowStatusById[rowId] = { status, label }; restoreListHighlights(); } function restoreListHighlights() { collectRows().forEach((rowMeta) => { const stateEntry = state.rowStatusById[rowMeta.id]; if (!stateEntry) { return; } rowMeta.rowEl.dataset.yfbStatus = stateEntry.status; upsertRowBadge(rowMeta.titleEl, stateEntry.status, stateEntry.label); }); } function upsertRowBadge(titleElement, status, label) { const parent = titleElement.parentElement || titleElement; let badge = parent.querySelector(".yfb-row-badge"); if (!badge) { badge = document.createElement("span"); badge.className = "yfb-row-badge"; parent.appendChild(badge); } badge.className = `yfb-row-badge ${status}`; badge.textContent = label || status; } function exportResults() { if (typeof window.XLSX === "undefined") { setStatus("XLSX 库未加载,无法导出。"); log("XLSX 库未加载。", "error"); refreshView(); return; } if (state.results.length === 0) { setStatus("当前没有命中结果可导出。"); log("结果为空,跳过导出。", "warning"); refreshView(); return; } const rows = state.results.map((item) => ({ 标题: item.title, AI分类: item.category, 置信度分数: item.confidence || 0 })); const workbook = window.XLSX.utils.book_new(); const worksheet = window.XLSX.utils.json_to_sheet(rows); window.XLSX.utils.book_append_sheet(workbook, worksheet, "命中结果"); const fileBuffer = window.XLSX.write(workbook, { bookType: "xlsx", type: "array" }); const blob = new Blob([fileBuffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); const fileName = `乙方宝-招标筛选-${formatTimestamp(new Date())}.xlsx`; const downloadLink = document.createElement("a"); downloadLink.href = URL.createObjectURL(blob); downloadLink.download = fileName; downloadLink.click(); setTimeout(() => { URL.revokeObjectURL(downloadLink.href); }, 1000); setStatus(`已导出 ${state.results.length} 条结果到 Excel。`); log(`导出完成:${fileName}`, "success"); refreshView(); } function clearResults() { if (state.isRunning) { setStatus("请先停止任务,再清空结果。"); refreshView(); return; } state.results = []; state.rowStatusById = {}; state.stats = { scanned: 0, hits: 0, currentPage: 1, currentIndex: 0 }; state.logs = []; clearDetailHighlights(); collectRows().forEach((rowMeta) => { rowMeta.rowEl.removeAttribute("data-yfb-status"); }); document.querySelectorAll(".yfb-row-badge").forEach((badge) => badge.remove()); setStatus("结果与标记已清空。"); refreshView(); void persistState(); } function setStatus(text) { state.statusText = text; } function log(message, level = "info") { state.logs.push({ time: new Date().toLocaleTimeString(), message, level }); if (state.logs.length > MAX_LOG_ENTRIES) { state.logs = state.logs.slice(-MAX_LOG_ENTRIES); } refreshView(); } function refreshView() { if (!ui.panel) { return; } ui.panel.classList.toggle("is-collapsed", state.panelCollapsed); ui.panel.classList.toggle("is-hidden", state.panelHidden); ui.toggleButton.textContent = state.panelCollapsed ? "▸" : "▾"; ui.scannedValue.textContent = String(state.stats.scanned); ui.hitsValue.textContent = String(state.results.length); ui.pageValue.textContent = String(state.stats.currentPage); ui.rowValue.textContent = String(state.stats.currentIndex); ui.status.textContent = state.statusText; ui.startButton.disabled = state.isRunning; if (ui.subscribeAllButton) { ui.subscribeAllButton.disabled = state.isRunning; } ui.stopButton.disabled = !state.isRunning; ui.logContainer.innerHTML = state.logs .map((entry) => `
[${escapeHtml(entry.time)}] ${escapeHtml(entry.message)}
`) .join(""); ui.logContainer.scrollTop = ui.logContainer.scrollHeight; } function buildPanelMarkup() { return `
YFB AI 乙方宝招标筛选助手 自动切筛选、抓详情、AI 分析并导出 Excel
已扫描0
命中数0
当前页1
当前条0
开始前停留在筛选列表页即可。脚本会先把订阅产品右侧 3 个分组依次切到“全部”,再进入详情抽取标题、正文和 AI 分析结果。
`; } function mountPanel() { if (document.getElementById(PANEL_ID)) { return; } const panel = document.createElement("div"); panel.id = PANEL_ID; panel.innerHTML = buildPanelMarkup(); document.documentElement.appendChild(panel); ui.panel = panel; ui.status = panel.querySelector("[data-role='status']"); ui.logContainer = panel.querySelector("[data-role='logs']"); ui.scannedValue = panel.querySelector("[data-role='scanned']"); ui.hitsValue = panel.querySelector("[data-role='hits']"); ui.pageValue = panel.querySelector("[data-role='page']"); ui.rowValue = panel.querySelector("[data-role='row']"); ui.maxPagesInput = panel.querySelector("#yfb-max-pages"); ui.delayInput = panel.querySelector("#yfb-delay-ms"); ui.startButton = panel.querySelector("[data-role='start']"); ui.subscribeAllButton = panel.querySelector("[data-role='subscribe-all']"); ui.stopButton = panel.querySelector("[data-role='stop']"); ui.exportButton = panel.querySelector("[data-role='export']"); ui.clearButton = panel.querySelector("[data-role='clear']"); ui.toggleButton = panel.querySelector(".yfb-panel-toggle"); ui.maxPagesInput.value = String(state.settings.maxPages); ui.delayInput.value = String(state.settings.delayMs); ui.maxPagesInput.addEventListener("change", handleSettingsChange); ui.delayInput.addEventListener("change", handleSettingsChange); ui.startButton.addEventListener("click", () => { void startScan(); }); if (ui.subscribeAllButton) { ui.subscribeAllButton.addEventListener("click", () => { void doSubscribeAll(); }); } ui.stopButton.addEventListener("click", stopScan); ui.exportButton.addEventListener("click", exportResults); ui.clearButton.addEventListener("click", clearResults); ui.toggleButton.addEventListener("click", toggleCollapse); enableDrag(panel.querySelector(".yfb-panel-header"), panel); } async function startScan() { if (state.isRunning) { return; } handleSettingsChange(); if (!isListPage()) { setStatus("请先打开乙方宝招标筛选列表页,再启动扫描。"); log("当前页面不是招标筛选列表页,已取消启动。", "warning"); refreshView(); return; } state.isRunning = true; state.stopRequested = false; setStatus(`准备开始扫描,最多 ${state.settings.maxPages} 页。`); log(`开始扫描,最多 ${state.settings.maxPages} 页,步进延迟 ${state.settings.delayMs}ms。`, "info"); refreshView(); try { await waitForListReady(); await prepareSubscriptionFilters(); await waitForListReady(); for (let pageOffset = 0; pageOffset < state.settings.maxPages; pageOffset += 1) { throwIfStopped(); state.stats.currentPage = getCurrentPageNumber() || pageOffset + 1; state.stats.currentIndex = 0; refreshView(); const rows = collectRows(); if (rows.length === 0) { throw new Error("列表页没有可扫描的数据。"); } log(`第 ${state.stats.currentPage} 页识别到 ${rows.length} 条记录。`, "info"); for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { throwIfStopped(); state.stats.currentIndex = rowIndex + 1; refreshView(); await processRow(rows[rowIndex].id, rowIndex); } if (pageOffset >= state.settings.maxPages - 1) { break; } const moved = await goToNextPage(); if (!moved) { log("已经到最后一页,停止继续翻页。", "info"); break; } } setStatus(`扫描完成,命中 ${state.results.length} 条。`); log(`扫描完成,累计命中 ${state.results.length} 条。`, "success"); } catch (error) { const message = error instanceof Error ? error.message : "扫描失败"; if (message === "__YFB_STOPPED__") { setStatus("任务已停止。"); log("任务已手动停止。", "warning"); } else { setStatus(`扫描异常:${message}`); log(`扫描异常:${message}`, "error"); } } finally { state.isRunning = false; state.stopRequested = false; state.stats.currentIndex = 0; refreshView(); await persistState(); } } function getSubscriptionGroupTabs() { const resolvedGroups = []; const usedElements = new Set(); for (const definition of SUBSCRIPTION_GROUP_DEFINITIONS) { const element = findSubscriptionGroupElement(definition); if (!(element instanceof HTMLElement) || usedElements.has(element)) { continue; } usedElements.add(element); resolvedGroups.push({ key: definition.key, label: readSubscriptionGroupLabel(element, definition.labels[0] || definition.key), element }); } if (resolvedGroups.length > 0) { return resolvedGroups; } return collectGenericSubscriptionGroupCandidates() .slice(0, 3) .map((element, index) => ({ key: `fallback-${index + 1}`, label: readSubscriptionGroupLabel(element, `分组${index + 1}`), element })); } async function prepareSubscriptionFilters(options = {}) { const strict = Boolean(options.strict); const tabs = getSubscriptionGroupTabs(); if (tabs.length === 0) { const message = "未识别到订阅产品分组,请确认筛选面板已展开。"; if (strict) { throw new Error(message); } log(`${message} 已跳过自动预处理。`, "warning"); return []; } const missingGroups = SUBSCRIPTION_GROUP_DEFINITIONS .filter((definition) => !tabs.some((tab) => tab.key === definition.key)); if (strict && missingGroups.length > 0) { throw new Error(`仅识别到 ${tabs.length} 个分组,缺少:${missingGroups.map((item) => item.labels[0]).join("、")}`); } if (missingGroups.length > 0) { log(`订阅分组未全部识别,当前缺少:${missingGroups.map((item) => item.labels[0]).join("、")}。`, "warning"); } setStatus("正在把订阅产品右侧 3 个分组切到“全部”…"); refreshView(); for (const [index, group] of tabs.entries()) { throwIfStopped(); await applyAllOptionToGroupTab(group, index); } setStatus("订阅产品筛选已切到“全部”,开始扫描列表。"); refreshView(); return tabs; } async function applyAllOptionToGroupTab(group, groupIndex) { if (!group?.element) { return; } const tabLabel = normalizeText(group.label || group.element.textContent || "").replace(/\(\d+\)/g, ""); await openSubscriptionGroupTab(group.element); const allItem = findVisibleAllSubcategoryItem(); if (!allItem) { log(`分组“${tabLabel || groupIndex + 1}”未找到“全部”选项。`, "warning"); return; } await clickElement(allItem); await sleep(120); const confirmButton = findVisibleConfirmButton(); if (confirmButton) { await clickElement(confirmButton); } else { log(`分组“${tabLabel || groupIndex + 1}”未找到确认按钮,已按自动生效继续。`, "warning"); } await waitForUiSettled(); log(`分组“${tabLabel || groupIndex + 1}”已切到“全部”。`, "info"); } function findSubscriptionGroupElement(definition) { const candidates = collectGenericSubscriptionGroupCandidates(); for (const element of candidates) { const label = readSubscriptionGroupLabel(element); if (definition.labels.some((item) => label.includes(item))) { return element; } } for (const selector of definition.selectors) { const fallback = Array.from(document.querySelectorAll(selector)) .find((element) => element instanceof HTMLElement && isElementVisible(element)); if (fallback instanceof HTMLElement) { return fallback; } } return null; } function collectGenericSubscriptionGroupCandidates() { const selectors = [ SUBSCRIPTION_GROUP_TAB_SELECTOR, ".subscribe-product-filter .filter-header .right-tabs .tab-item", ".filter-container > div", ".select-filters-wrap > div", ".select-filters-wrap > div > span" ]; const seenTexts = new Set(); const candidates = []; for (const selector of selectors) { for (const element of document.querySelectorAll(selector)) { if (!(element instanceof HTMLElement) || !isElementVisible(element)) { continue; } const text = readSubscriptionGroupLabel(element); if (!text || text.startsWith("全部") || text.includes("订阅词管理") || text.includes("选择地区")) { continue; } if (!/\(\d+\)/.test(text)) { continue; } if (seenTexts.has(text)) { continue; } seenTexts.add(text); candidates.push(element); } } return candidates; } function readSubscriptionGroupLabel(element, fallback = "") { const text = normalizeText(element?.textContent || ""); return text || fallback; } async function openSubscriptionGroupTab(tab) { const clickTargets = [ tab.querySelector(".tab-item-icon"), tab.querySelector("i"), tab.querySelector("span"), tab ].filter((element, index, list) => element instanceof HTMLElement && list.indexOf(element) === index); for (const target of clickTargets) { await clickElement(target); await sleep(Math.min(220, state.settings.delayMs)); if (findVisibleAllSubcategoryItem()) { return; } } } function findVisibleAllSubcategoryItem() { const subcategoryContainer = getVisibleSubcategoryContainer(); const scope = subcategoryContainer || document; return Array.from(scope.querySelectorAll(SUBSCRIPTION_ALL_ITEM_SELECTOR)) .filter((item) => isElementVisible(item)) .find((item) => normalizeText(item.textContent || "").startsWith("全部")) || null; } function findVisibleConfirmButton() { const subcategoryContainer = getVisibleSubcategoryContainer(); const scope = subcategoryContainer?.parentElement || document; return Array.from(scope.querySelectorAll(SUBSCRIPTION_CONFIRM_SELECTOR)) .find((button) => isElementVisible(button) && normalizeText(button.textContent || "").includes("确认")) || null; } function getVisibleSubcategoryContainer() { return Array.from(document.querySelectorAll(".subcategory-content")) .filter((element) => element instanceof HTMLElement && isElementVisible(element)) .sort((left, right) => right.getBoundingClientRect().height - left.getBoundingClientRect().height)[0] || null; } async function processRow(rowId, rowIndex) { const rowMeta = findRowById(rowId) || collectRows()[rowIndex]; if (!rowMeta || !rowMeta.titleEl) { log(`第 ${rowIndex + 1} 条记录在当前页已不可见,跳过。`, "warning"); return; } updateRowStatus(rowMeta.id, "scanning", "扫描中"); log(`正在处理:${rowMeta.title}`, "info"); await persistState(); if (shouldSkipRowByPreview(rowMeta)) { state.stats.scanned += 1; updateRowStatus(rowMeta.id, "skip", "列表预筛未命中"); log(`列表预筛跳过:${rowMeta.title}`, "info"); state.stats.hits = state.results.length; refreshView(); await persistState(); return; } await clickElement(rowMeta.titleEl); await sleep(state.settings.delayMs); await waitForDetailPage(); await waitForUiSettled(); dismissKnownDialogs(); await sleep(120); try { const detailRecord = extractDetailRecord(rowMeta); const decision = await analyzeRecord(detailRecord); state.stats.scanned += 1; if (decision.isRelevant) { addResult(detailRecord, decision); updateRowStatus(rowMeta.id, "hit", decision.category); showDetailBanner({ id: detailRecord.id, title: detailRecord.title, category: decision.category, confidence: decision.confidence, reason: decision.reason }); highlightKeywords(decision.matchedKeywords || []); log(`命中:${detailRecord.title} -> ${decision.category}`, "success"); } else { updateRowStatus(rowMeta.id, "skip", "未命中"); clearDetailHighlights(); log(`未命中:${detailRecord.title}`, "info"); } } catch (error) { const message = error instanceof Error ? error.message : "记录处理失败"; updateRowStatus(rowMeta.id, "error", "异常"); log(`处理失败:${rowMeta.title},${message}`, "error"); } finally { state.stats.hits = state.results.length; refreshView(); await persistState(); await navigateBackToList(); await sleep(state.settings.delayMs); restoreListHighlights(); } } function extractDetailRecord(rowMeta) { const title = findTitleCandidate(rowMeta.title); const detailMeta = collectDetailMeta(); const summaryFields = collectSummaryFields(detailMeta); const announcementContent = collectAnnouncementContent(title); const attachmentNames = uniqueText( Array.from(document.querySelectorAll(".attachment-name")).map((element) => element.textContent || "") ); const detailText = collectDetailText(title, { type: detailMeta.type || rowMeta.type, region: detailMeta.region || rowMeta.region, publishTime: detailMeta.publishTime || rowMeta.publishTime, summaryFields, announcementContent, attachmentNames }); const combinedText = normalizeText( [ title, detailMeta.type || rowMeta.type, detailMeta.region || rowMeta.region, detailMeta.publishTime || rowMeta.publishTime, summaryFields.projectNumber, summaryFields.bidder, summaryFields.agency, summaryFields.signupDeadline, summaryFields.bidDeadline, detailText, attachmentNames.join(" ") ].join("\n") ); const keywordHints = collectKeywordHints(combinedText); return { id: rowMeta.id, title, type: detailMeta.type || rowMeta.type, region: detailMeta.region || rowMeta.region, publishTime: detailMeta.publishTime || rowMeta.publishTime, detailUrl: location.href, sourceUrl: readSourceUrl(), attachmentNames, announcementContent, detailText, projectNumber: summaryFields.projectNumber, estimatedAmount: summaryFields.estimatedAmount, bidder: summaryFields.bidder, agency: summaryFields.agency, signupDeadline: summaryFields.signupDeadline, bidDeadline: summaryFields.bidDeadline, keywordHints }; } function collectDetailMeta() { const propertyItems = Array.from(document.querySelectorAll(".long-project-property-left span")) .map((element) => normalizeText(element.textContent || "")) .filter(Boolean); return { type: propertyItems[0] || "", region: propertyItems[1] || "", publishTime: propertyItems[2] || "" }; } function shouldSkipRowByPreview(rowMeta) { return Boolean(rowMeta?.previewKeywordHints && rowMeta.previewKeywordHints.totalHits === 0); } function collectSummaryFields(detailMeta = {}) { const summaryCard = findDetailCardByTitle("正文摘要"); const fieldMap = {}; if (summaryCard) { Array.from(summaryCard.querySelectorAll(".table-line")).forEach((line) => { const cells = Array.from(line.children).filter((element) => normalizeText(element.textContent || "")); for (let index = 0; index < cells.length; index += 2) { const label = normalizeText(cells[index]?.textContent || ""); const value = sanitizeExtractedText(extractFieldValue(cells[index + 1])); if (label) { fieldMap[label] = value; } } }); } return { projectNumber: mapSummaryField(fieldMap, ["项目编号"]), estimatedAmount: mapSummaryField(fieldMap, ["预估金额"]), bidder: mapSummaryField(fieldMap, ["招标单位", "采购单位", "业主单位"]), agency: mapSummaryField(fieldMap, ["代理单位", "招标代理"]), signupDeadline: mapSummaryField(fieldMap, ["报名截止时间", "报名截止"]), bidDeadline: mapSummaryField(fieldMap, ["投标截止时间", "开标时间", "开标日期"]), noticeType: detailMeta.type || "", region: detailMeta.region || "", publishTime: detailMeta.publishTime || "" }; } function mapSummaryField(fieldMap, labels) { for (const label of labels) { const value = normalizeText(fieldMap[label] || ""); if (value) { return value; } } return ""; } function findDetailCardByTitle(title) { const titleElements = Array.from(document.querySelectorAll(".card-title")); const matchedTitle = titleElements.find((element) => normalizeText(element.textContent || "") === title); return matchedTitle?.closest(".card-layout") || null; } function extractFieldValue(element) { if (!(element instanceof HTMLElement)) { return ""; } const clone = element.cloneNode(true); clone.querySelectorAll( [ ".el-popover", ".el-dialog__wrapper", ".el-tooltip__popper", ".m-notification-wrap", "button", "svg", "img", "i", "script", "style", "mark" ].join(", ") ).forEach((node) => node.remove()); clone.querySelectorAll("[style*='display: none']").forEach((node) => node.remove()); return clone.innerText || clone.textContent || ""; } function sanitizeExtractedText(text) { return normalizeText( String(text || "") .replaceAll("监控", "") .replaceAll("点击查看", "") .replaceAll("查看联系人详情", "") ); } function collectAnnouncementContent(title) { const texts = []; for (const selector of DETAIL_CONTENT_SELECTORS) { document.querySelectorAll(selector).forEach((element) => { if (!(element instanceof HTMLElement) || !isElementVisible(element)) { return; } if (element.closest(`#${PANEL_ID}`) || element.closest(".el-dialog__wrapper")) { return; } const text = sanitizeExtractedText(extractFieldValue(element)); if (text.length >= 40) { texts.push(text); } }); } const content = uniqueText(texts).sort((left, right) => right.length - left.length)[0] || ""; return limitLength(removeBoilerplateText(content, title), Number(CONFIG.maxBodyChars) || 12000); } function readSourceUrl() { const sourceLink = document.querySelector(".see_source_url a[href], a[href].see_source_url"); return sourceLink instanceof HTMLAnchorElement ? sourceLink.href : location.href; } async function waitForUiSettled(respectStop = true) { await sleep(120); try { await waitFor(() => !hasVisibleLoadingMask(), 10000, 120, "页面加载超时", respectStop); } catch (error) { log("页面加载等待超时,继续按当前页面处理。", "warning"); } await sleep(120); } function hasVisibleLoadingMask() { return Array.from(document.querySelectorAll(".el-loading-mask")).some((element) => isElementVisible(element)); } function dismissKnownDialogs() { Array.from(document.querySelectorAll(".el-dialog__wrapper")) .filter((wrapper) => isElementVisible(wrapper)) .forEach((wrapper) => { const title = normalizeText(wrapper.querySelector(".dialog-title, .el-dialog__title")?.textContent || ""); if (!title || !KNOWN_DIALOG_TITLES.some((item) => title.includes(item))) { return; } const closeButton = wrapper.querySelector(".el-dialog__headerbtn, .close-btn, [aria-label='Close']"); if (closeButton instanceof HTMLElement) { closeButton.click(); } else { wrapper.style.display = "none"; } }); } async function waitForListReady(respectStop = true) { await waitFor(() => collectRows().length > 0, 15000, 220, "列表数据加载超时", respectStop); await waitForUiSettled(respectStop); } async function waitForDetailPage() { await waitFor(() => detectDetailPage(), 15000, 220, "详情页加载超时"); } function detectDetailPage() { return Boolean( document.querySelector(DETAIL_TITLE_SELECTORS.join(", ")) || document.querySelector(DETAIL_CONTENT_SELECTORS.join(", ")) || document.querySelector(".project-detail-content") ); } function findTitleCandidate(fallbackTitle) { for (const selector of DETAIL_TITLE_SELECTORS) { const element = Array.from(document.querySelectorAll(selector)).find((item) => isElementVisible(item)); const text = normalizeText(element?.textContent || ""); if (text && !KNOWN_DIALOG_TITLES.some((item) => text.includes(item))) { return text; } } const candidates = Array.from( document.querySelectorAll("h1, h2, h3, [class*='title'], [class*='name'], [class*='headline']") ) .filter((element) => !element.closest(`#${PANEL_ID}`) && !element.closest(".el-dialog__wrapper")) .filter((element) => isElementVisible(element)) .map((element) => { const text = normalizeText(element.textContent || ""); const rect = element.getBoundingClientRect(); const fontSize = Number.parseFloat(window.getComputedStyle(element).fontSize || "0"); const score = (rect.top >= 0 && rect.top < 520 ? 80 : 0) + fontSize + Math.max(0, 140 - text.length); return { text, score }; }) .filter((item) => item.text.length >= 8 && item.text.length <= 160) .filter((item) => !KNOWN_DIALOG_TITLES.some((title) => item.text.includes(title))) .sort((left, right) => right.score - left.score); if (candidates.length > 0) { return candidates[0].text; } return fallbackTitle; } function collectDetailText(title, detailData = {}) { const lines = [ title, detailData.type ? `公告类型:${detailData.type}` : "", detailData.region ? `地区:${detailData.region}` : "", detailData.publishTime ? `发布时间:${detailData.publishTime}` : "", detailData.summaryFields?.projectNumber ? `项目编号:${detailData.summaryFields.projectNumber}` : "", detailData.summaryFields?.estimatedAmount ? `预估金额:${detailData.summaryFields.estimatedAmount}` : "", detailData.summaryFields?.bidder ? `招标单位:${detailData.summaryFields.bidder}` : "", detailData.summaryFields?.agency ? `代理单位:${detailData.summaryFields.agency}` : "", detailData.summaryFields?.signupDeadline ? `报名截止时间:${detailData.summaryFields.signupDeadline}` : "", detailData.summaryFields?.bidDeadline ? `投标截止时间:${detailData.summaryFields.bidDeadline}` : "", detailData.announcementContent || "", Array.isArray(detailData.attachmentNames) && detailData.attachmentNames.length ? `附件:${detailData.attachmentNames.join(";")}` : "" ].filter(Boolean); return limitLength( removeBoilerplateText(lines.join("\n"), title), Number(CONFIG.maxBodyChars) || 12000 ); } function buildSummary(detailRecord, matchedKeywords) { const candidateText = detailRecord.announcementContent || detailRecord.detailText || detailRecord.title; const candidateLines = candidateText .split(/[\n。;]/) .map((line) => normalizeText(line)) .filter((line) => { if (!line || line === detailRecord.title) { return false; } return ![ "项目编号", "预估金额", "招标单位", "代理单位", "报名截止时间", "投标截止时间" ].some((prefix) => line.startsWith(prefix)); }) .filter((line) => line.length >= 12); if (candidateLines.length > 0) { return limitLength(candidateLines[0], 60); } if (matchedKeywords.length > 0) { return limitLength(`命中关键词:${matchedKeywords.join("、")}`, 60); } return limitLength(detailRecord.title, 60); } function addResult(detailRecord, decision) { const result = { id: detailRecord.id, title: detailRecord.title, summary: decision.titleSummary || buildSummary(detailRecord, decision.matchedKeywords || []), category: decision.category || "未命中", institutionType: uniqueText(decision.institutionType || []), matchedKeywords: uniqueText(decision.matchedKeywords || []), confidence: Number(decision.confidence) || 0, reason: decision.reason || "", type: detailRecord.type, region: detailRecord.region, publishTime: detailRecord.publishTime, detailUrl: detailRecord.detailUrl, sourceUrl: detailRecord.sourceUrl, attachmentNames: detailRecord.attachmentNames, announcementContent: detailRecord.announcementContent, projectNumber: detailRecord.projectNumber, estimatedAmount: detailRecord.estimatedAmount, bidder: detailRecord.bidder, agency: detailRecord.agency, signupDeadline: detailRecord.signupDeadline, bidDeadline: detailRecord.bidDeadline }; const existingIndex = state.results.findIndex((item) => item.id === result.id); if (existingIndex >= 0) { state.results.splice(existingIndex, 1, result); } else { state.results.push(result); } } function showDetailBanner(result) { clearDetailBanner(); const banner = document.createElement("div"); banner.id = BANNER_ID; banner.dataset.resultId = result.id || ""; banner.innerHTML = `
命中 ${escapeHtml(result.category || "目标类型")}
标题:${escapeHtml(result.title || "-")}
置信度:${escapeHtml(String(result.confidence || 0))}
说明:${escapeHtml(result.reason || "无")}
`; document.documentElement.appendChild(banner); bannerHideTimer = window.setTimeout(() => { clearDetailBanner(); }, 4200); } function clearDetailBanner() { if (bannerHideTimer) { clearTimeout(bannerHideTimer); bannerHideTimer = null; } const banner = document.getElementById(BANNER_ID); if (banner) { banner.remove(); } } function highlightKeywords(keywords) { clearKeywordHighlights(); if (!Array.isArray(keywords) || keywords.length === 0) { return; } const keywordList = uniqueText(keywords) .filter((item) => item.length >= 2) .sort((left, right) => right.length - left.length) .slice(0, 12); if (keywordList.length === 0) { return; } const root = pickHighlightRoot(); if (!root) { return; } const pattern = new RegExp(`(${keywordList.map(escapeRegExp).join("|")})`, "g"); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.nodeValue || !node.nodeValue.trim()) { return NodeFilter.FILTER_REJECT; } const parent = node.parentElement; if (!parent) { return NodeFilter.FILTER_REJECT; } if ( parent.closest(`#${PANEL_ID}`) || parent.closest(`#${BANNER_ID}`) || parent.closest(".el-dialog__wrapper") || parent.closest(".el-popover") || parent.closest(".m-notification-wrap") || parent.closest("mark") || ["SCRIPT", "STYLE", "NOSCRIPT", "TEXTAREA", "INPUT"].includes(parent.tagName) ) { return NodeFilter.FILTER_REJECT; } pattern.lastIndex = 0; return pattern.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); const textNodes = []; while (walker.nextNode()) { textNodes.push(walker.currentNode); } textNodes.forEach((node) => { const text = node.nodeValue || ""; pattern.lastIndex = 0; if (!pattern.test(text)) { return; } pattern.lastIndex = 0; const fragment = document.createDocumentFragment(); let lastIndex = 0; let match = pattern.exec(text); while (match) { const matchText = match[0]; const index = match.index; if (index > lastIndex) { fragment.appendChild(document.createTextNode(text.slice(lastIndex, index))); } const mark = document.createElement("mark"); mark.className = KEYWORD_MARK_CLASS; mark.textContent = matchText; fragment.appendChild(mark); lastIndex = index + matchText.length; match = pattern.exec(text); } if (lastIndex < text.length) { fragment.appendChild(document.createTextNode(text.slice(lastIndex))); } node.parentNode.replaceChild(fragment, node); }); } function pickHighlightRoot() { const selectors = [ "#printInfo", ".project-detail", ".info-detail-container", ".project-detail-content", ".project-body" ]; for (const selector of selectors) { const element = document.querySelector(selector); if (element && !element.closest(`#${PANEL_ID}`)) { return element; } } return document.body; } function exportResults() { if (typeof window.XLSX === "undefined") { setStatus("XLSX 库未加载,无法导出。"); log("XLSX 库未加载。", "error"); refreshView(); return; } if (state.results.length === 0) { setStatus("当前没有命中结果可导出。"); log("结果为空,跳过导出。", "warning"); refreshView(); return; } const headers = ["标题", "AI分类", "置信度分数"]; const rows = state.results.map((item) => ({ 标题: item.title, AI分类: item.category || "", 置信度分数: item.confidence || 0 })); const worksheet = window.XLSX.utils.json_to_sheet(rows, { header: headers }); worksheet["!cols"] = [ { wch: 44 }, { wch: 18 }, { wch: 12 } ]; const workbook = window.XLSX.utils.book_new(); window.XLSX.utils.book_append_sheet(workbook, worksheet, "招标结果"); const fileName = `乙方宝-招标筛选-${formatTimestamp(new Date())}.xlsx`; window.XLSX.writeFile(workbook, fileName, { compression: true }); setStatus(`已导出 ${state.results.length} 条结果到 Excel。`); log(`导出完成:${fileName}`, "success"); refreshView(); } function refreshView() { if (!ui.panel) { return; } ui.panel.classList.toggle("is-collapsed", state.panelCollapsed); ui.panel.classList.toggle("is-hidden", state.panelHidden); ui.toggleButton.textContent = state.panelCollapsed ? "+" : "-"; ui.scannedValue.textContent = String(state.stats.scanned); ui.hitsValue.textContent = String(state.results.length); ui.pageValue.textContent = String(state.stats.currentPage); ui.rowValue.textContent = String(state.stats.currentIndex); ui.status.textContent = state.statusText; ui.startButton.disabled = state.isRunning; if (ui.subscribeAllButton) { ui.subscribeAllButton.disabled = state.isRunning; } ui.stopButton.disabled = !state.isRunning; ui.logContainer.innerHTML = state.logs .map((entry) => `
[${escapeHtml(entry.time)}] ${escapeHtml(entry.message)}
`) .join(""); ui.logContainer.scrollTop = ui.logContainer.scrollHeight; } function isElementVisible(element) { if (!(element instanceof HTMLElement) || !element.isConnected) { return false; } const style = window.getComputedStyle(element); if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) { return false; } const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0; } function normalizeText(text) { return String(text || "") .replace(/[\u00a0\u200b-\u200d\ufeff]/g, " ") .replace(/\s+/g, " ") .trim(); } async function persistState() { await storageSet({ [STORAGE_KEY]: { panelCollapsed: state.panelCollapsed, panelHidden: state.panelHidden, statusText: state.statusText, settings: state.settings, stats: state.stats, results: state.results, rowStatusById: state.rowStatusById, logs: state.logs.slice(-MAX_LOG_ENTRIES) } }); } function runtimeSendMessage(message) { return new Promise((resolve) => { chrome.runtime.sendMessage(message, (response) => { if (chrome.runtime.lastError) { resolve({ ok: false, error: chrome.runtime.lastError.message }); return; } resolve(response); }); }); } function storageGet(key) { return new Promise((resolve) => { chrome.storage.local.get([key], (result) => { resolve(result[key]); }); }); } function storageSet(value) { return new Promise((resolve) => { chrome.storage.local.set(value, () => { resolve(); }); }); } async function clickElement(element) { element.scrollIntoView({ behavior: "smooth", block: "center" }); await sleep(120); element.click(); } async function waitFor(check, timeoutMs, intervalMs, errorMessage, respectStop = true) { const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { if (respectStop) { throwIfStopped(); } if (check()) { return; } await sleep(intervalMs); } throw new Error(errorMessage); } async function sleep(durationMs) { return new Promise((resolve) => { setTimeout(resolve, durationMs); }); } function throwIfStopped() { if (state.stopRequested) { throw new Error("__YFB_STOPPED__"); } } function clampNumber(value, min, max, fallback) { const numeric = Number(value); if (!Number.isFinite(numeric)) { return fallback; } return Math.max(min, Math.min(max, Math.round(numeric))); } function normalizeText(text) { return String(text || "").replace(/\s+/g, " ").trim(); } function uniqueText(values) { return Array.from(new Set(values.map((item) => normalizeText(item)).filter(Boolean))); } function limitLength(text, maxLength) { const normalized = normalizeText(text); if (normalized.length <= maxLength) { return normalized; } return normalized.slice(0, maxLength - 1) + "…"; } function formatTimestamp(date) { const parts = [ date.getFullYear(), String(date.getMonth() + 1).padStart(2, "0"), String(date.getDate()).padStart(2, "0"), "-", String(date.getHours()).padStart(2, "0"), String(date.getMinutes()).padStart(2, "0"), String(date.getSeconds()).padStart(2, "0") ]; return parts.join(""); } function escapeRegExp(text) { return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function escapeHtml(text) { return String(text || "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll("\"", """) .replaceAll("'", "'"); } })();