yfb-plugin/content.js

2882 lines
96 KiB
JavaScript
Raw Normal View History

2026-04-08 13:07:01 +08:00
(() => {
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;
2026-04-08 14:27:39 +08:00
const DEFAULT_MAX_PAGES = 10;
const LEGACY_DEFAULT_MAX_PAGES = 3;
2026-04-08 13:07:01 +08:00
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,
2026-04-08 14:27:39 +08:00
hasCustomMaxPages: false,
2026-04-08 13:07:01 +08:00
statusText: "等待开始",
settings: {
2026-04-08 14:27:39 +08:00
maxPages: DEFAULT_MAX_PAGES,
2026-04-08 13:07:01 +08:00
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);
2026-04-08 14:27:39 +08:00
state.hasCustomMaxPages = Boolean(saved.hasCustomMaxPages);
2026-04-08 13:07:01 +08:00
state.statusText = saved.statusText || state.statusText;
state.settings = { ...state.settings, ...(saved.settings || {}) };
2026-04-08 14:27:39 +08:00
if (
!state.hasCustomMaxPages &&
(!Number.isFinite(Number(state.settings.maxPages)) || Number(state.settings.maxPages) === LEGACY_DEFAULT_MAX_PAGES)
) {
state.settings.maxPages = DEFAULT_MAX_PAGES;
}
2026-04-08 13:07:01 +08:00
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 `
<div class="yfb-panel-header">
<div class="yfb-panel-title">
<span class="yfb-panel-eyebrow">YFB AI</span>
<span class="yfb-panel-name">乙方宝招标筛选助手</span>
<span class="yfb-panel-subtitle">自动切筛选抓详情AI 分析并导出 Excel</span>
</div>
<button type="button" class="yfb-panel-toggle" aria-label="收起或展开">-</button>
</div>
<div class="yfb-panel-body">
<div class="yfb-stats">
<div class="yfb-stat"><span class="yfb-stat-label">已扫描</span><strong class="yfb-stat-value" data-role="scanned">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">命中数</span><strong class="yfb-stat-value" data-role="hits">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前页</span><strong class="yfb-stat-value" data-role="page">1</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前条</span><strong class="yfb-stat-value" data-role="row">0</strong></div>
</div>
<div class="yfb-field-grid">
<div class="yfb-field">
<label for="yfb-max-pages">最大页数</label>
<input id="yfb-max-pages" type="number" min="1" max="200" />
</div>
<div class="yfb-field">
<label for="yfb-delay-ms">步进延迟(ms)</label>
<input id="yfb-delay-ms" type="number" min="200" max="10000" step="100" />
</div>
</div>
<div class="yfb-button-row">
<button type="button" class="yfb-button yfb-button-primary" data-role="start">开始扫描</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="subscribe-all">订阅全部分组</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="stop">停止</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="export">导出 Excel</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="clear">清空结果</button>
</div>
<div class="yfb-status" data-role="status"></div>
<div class="yfb-hint">开始前停留在筛选列表页即可脚本会先把订阅产品右侧 3 个分组依次切到全部再进入详情抽取标题正文和 AI 分析结果</div>
<div class="yfb-log" data-role="logs"></div>
</div>
`;
}
function mountPanel() {
if (document.getElementById(PANEL_ID)) {
return;
}
const panel = document.createElement("div");
panel.id = PANEL_ID;
panel.innerHTML = buildPanelMarkup(); /*
<div class="yfb-panel-header">
<div class="yfb-panel-title">
<span class="yfb-panel-eyebrow">AI Bid Filter</span>
<span class="yfb-panel-name">乙方宝招标筛选助手</span>
<span class="yfb-panel-subtitle">当前列表页内自动扫描命中高亮导出 Excel</span>
</div>
<button type="button" class="yfb-panel-toggle" aria-label="收起或展开"></button>
</div>
<div class="yfb-panel-body">
<div class="yfb-stats">
<div class="yfb-stat"><span class="yfb-stat-label">已扫描</span><strong class="yfb-stat-value" data-role="scanned">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">命中数</span><strong class="yfb-stat-value" data-role="hits">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前页</span><strong class="yfb-stat-value" data-role="page">1</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前条</span><strong class="yfb-stat-value" data-role="row">0</strong></div>
</div>
<div class="yfb-field-grid">
<div class="yfb-field">
<label for="yfb-max-pages">最大扫描页数</label>
<input id="yfb-max-pages" type="number" min="1" max="200" />
</div>
<div class="yfb-field">
<label for="yfb-delay-ms">步进延迟(ms)</label>
<input id="yfb-delay-ms" type="number" min="200" max="10000" step="100" />
</div>
</div>
<div class="yfb-button-row">
<button type="button" class="yfb-button yfb-button-primary" data-role="start">开始扫描</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="subscribe-all">订阅全部分组</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="stop">停止</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="export">导出 Excel</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="clear">清空结果</button>
</div>
<div class="yfb-status" data-role="status"></div>
<div class="yfb-hint">建议先切到乙方宝招标列表页再启动插件会优先抓标题正文和附件名再做 AI 判定</div>
<div class="yfb-log" data-role="logs"></div>
</div>
*/;
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() {
2026-04-08 14:27:39 +08:00
const previousMaxPages = state.settings.maxPages;
2026-04-08 13:07:01 +08:00
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;
2026-04-08 14:27:39 +08:00
state.hasCustomMaxPages = state.hasCustomMaxPages || previousMaxPages !== maxPages;
2026-04-08 13:07:01 +08:00
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);
}
2026-04-08 14:27:39 +08:00
function normalizeSummaryCompareText(text) {
return normalizeText(text).replace(/[\s,,。;、:\-()[\]【】"'“”‘’《》]/g, "");
}
function sanitizeSummaryCandidate(text, detailRecord) {
let result = normalizeText(removeBoilerplateText(String(text || ""), detailRecord?.title || ""));
if (!result) {
return "";
}
const title = normalizeText(detailRecord?.title || "");
if (title && result.startsWith(title)) {
result = normalizeText(result.slice(title.length));
}
const prefixPatterns = [
/^(发布时间|发布日期|公告时间|时间|地区|项目编号|项目名称|项目概况|项目简介|招标编号|采购编号|预算金额|预估金额|招标单位|招标人|采购单位|业主单位|代理单位|代理机构|报名截止时间|投标截止时间|开标时间|开标日期|公告类型)\s*[:]?\s*/i,
/^(\d{4}[./-]\d{1,2}[./-]\d{1,2}|\d{1,2}[./-]\d{1,2})\s*/,
/^([一二三四五六七八九十]+、|\(?[一二三四五六七八九十]+\)|[0-9]+[、.])\s*/
];
let previous = "";
while (result && result !== previous) {
previous = result;
prefixPatterns.forEach((pattern) => {
result = normalizeText(result.replace(pattern, ""));
});
}
return result;
}
function isValidSummaryCandidate(text, detailRecord) {
const candidate = normalizeText(text);
if (!candidate || candidate.length < 12) {
return false;
}
const candidateComparable = normalizeSummaryCompareText(candidate);
const titleComparable = normalizeSummaryCompareText(detailRecord?.title || "");
if (!candidateComparable || candidateComparable === titleComparable) {
return false;
}
if (/^[\d\s,,。;、:./\-]+$/.test(candidate)) {
return false;
}
return !/^(发布时间|发布日期|公告时间|时间|地区|项目编号|项目名称|项目概况|项目简介|招标编号|采购编号|预算金额|预估金额|招标单位|招标人|采购单位|业主单位|代理单位|代理机构|报名截止时间|投标截止时间|开标时间|开标日期|公告类型)\b/i.test(candidate);
}
function buildFieldFallbackSummary(detailRecord) {
const parts = [
detailRecord?.bidder ? `招标单位:${detailRecord.bidder}` : "",
detailRecord?.agency ? `代理单位:${detailRecord.agency}` : "",
detailRecord?.signupDeadline ? `报名截止:${detailRecord.signupDeadline}` : "",
detailRecord?.bidDeadline ? `投标截止:${detailRecord.bidDeadline}` : ""
].filter(Boolean);
return parts.length > 0 ? limitLength(parts.join(""), 60) : "";
}
2026-04-08 13:07:01 +08:00
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 = `
<div class="yfb-banner-title">命中 ${escapeHtml(result.category || "目标类型")}</div>
<div class="yfb-banner-meta">
<div>标题${escapeHtml(result.title || "-")}</div>
<div>置信度${escapeHtml(String(result.confidence || 0))}</div>
<div>说明${escapeHtml(result.reason || "无")}</div>
</div>
`;
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
}));
2026-04-08 14:27:39 +08:00
const exportRows = state.results.map((item) => ({
["标题"]: item.title,
["简述"]: item.summary || "",
["AI分类"]: item.category || "",
["置信度"]: item.confidence || 0
}));
2026-04-08 13:07:01 +08:00
const workbook = window.XLSX.utils.book_new();
2026-04-08 14:27:39 +08:00
const worksheet = window.XLSX.utils.json_to_sheet(exportRows);
2026-04-08 13:07:01 +08:00
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) => `<div class="yfb-log-entry ${escapeHtml(entry.level)}">[${escapeHtml(entry.time)}] ${escapeHtml(entry.message)}</div>`)
.join("");
ui.logContainer.scrollTop = ui.logContainer.scrollHeight;
}
function buildPanelMarkup() {
return `
<div class="yfb-panel-header">
<div class="yfb-panel-title">
<span class="yfb-panel-eyebrow">YFB AI</span>
<span class="yfb-panel-name">乙方宝招标筛选助手</span>
<span class="yfb-panel-subtitle">自动切筛选抓详情AI 分析并导出 Excel</span>
</div>
<button type="button" class="yfb-panel-toggle" aria-label="收起或展开">-</button>
</div>
<div class="yfb-panel-body">
<div class="yfb-stats">
<div class="yfb-stat"><span class="yfb-stat-label">已扫描</span><strong class="yfb-stat-value" data-role="scanned">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">命中数</span><strong class="yfb-stat-value" data-role="hits">0</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前页</span><strong class="yfb-stat-value" data-role="page">1</strong></div>
<div class="yfb-stat"><span class="yfb-stat-label">当前条</span><strong class="yfb-stat-value" data-role="row">0</strong></div>
</div>
<div class="yfb-field-grid">
<div class="yfb-field">
<label for="yfb-max-pages">最大页数</label>
<input id="yfb-max-pages" type="number" min="1" max="200" />
</div>
<div class="yfb-field">
<label for="yfb-delay-ms">步进延迟(ms)</label>
<input id="yfb-delay-ms" type="number" min="200" max="10000" step="100" />
</div>
</div>
<div class="yfb-button-row">
<button type="button" class="yfb-button yfb-button-primary" data-role="start">开始扫描</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="subscribe-all">订阅全部分组</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="stop">停止</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="export">导出 Excel</button>
<button type="button" class="yfb-button yfb-button-secondary" data-role="clear">清空结果</button>
</div>
<div class="yfb-status" data-role="status"></div>
<div class="yfb-hint">开始前停留在筛选列表页即可脚本会先把订阅产品右侧 3 个分组依次切到全部再进入详情抽取标题正文和 AI 分析结果</div>
<div class="yfb-log" data-role="logs"></div>
</div>
`;
}
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 = `
<div class="yfb-banner-title">命中 ${escapeHtml(result.category || "目标类型")}</div>
<div class="yfb-banner-meta">
<div>标题${escapeHtml(result.title || "-")}</div>
<div>置信度${escapeHtml(String(result.confidence || 0))}</div>
<div>说明${escapeHtml(result.reason || "无")}</div>
</div>
`;
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
}));
2026-04-08 14:27:39 +08:00
const exportHeaders = ["标题", "简述", "AI分类", "置信度"];
const exportRows = state.results.map((item) => ({
["标题"]: item.title,
["简述"]: item.summary || "",
["AI分类"]: item.category || "",
["置信度"]: item.confidence || 0
}));
const worksheet = window.XLSX.utils.json_to_sheet(exportRows, { header: exportHeaders });
2026-04-08 13:07:01 +08:00
worksheet["!cols"] = [
{ wch: 44 },
2026-04-08 14:27:39 +08:00
{ wch: 60 },
2026-04-08 13:07:01 +08:00
{ 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) => `<div class="yfb-log-entry ${escapeHtml(entry.level)}">[${escapeHtml(entry.time)}] ${escapeHtml(entry.message)}</div>`)
.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,
2026-04-08 14:27:39 +08:00
hasCustomMaxPages: state.hasCustomMaxPages,
2026-04-08 13:07:01 +08:00
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("");
}
2026-04-08 14:27:39 +08:00
function isValidSummaryCandidate(text, detailRecord) {
const candidate = normalizeText(text);
if (!candidate || candidate.length < 12) {
return false;
}
const title = normalizeText(detailRecord?.title || "");
if (title && candidate.startsWith(title)) {
return false;
}
const candidateComparable = normalizeSummaryCompareText(candidate);
const titleComparable = normalizeSummaryCompareText(title);
if (!candidateComparable || candidateComparable === titleComparable) {
return false;
}
if (/^[\d\s,,。;、:./\-]+$/.test(candidate)) {
return false;
}
return !/^(发布时间|发布日期|公告时间|时间|地区|项目编号|项目名称|项目概况|项目简介|招标编号|采购编号|预算金额|预估金额|招标单位|招标人|采购单位|业主单位|代理单位|代理机构|报名截止时间|投标截止时间|开标时间|开标日期|公告类型)\b/i.test(candidate);
}
function buildLocalSummary(detailRecord) {
const candidateText = [detailRecord.announcementContent, detailRecord.detailText].filter(Boolean).join("\n");
const candidateLines = candidateText
.split(/[\n。]/)
.map((line) => sanitizeSummaryCandidate(line, detailRecord))
.filter((line) => isValidSummaryCandidate(line, detailRecord));
return candidateLines.length > 0 ? limitLength(candidateLines[0], 60) : "";
}
function buildSummary(detailRecord, matchedKeywords = [], aiSummary = "") {
const normalizedAiSummary = normalizeText(removeBoilerplateText(String(aiSummary || ""), detailRecord?.title || ""));
if (isValidSummaryCandidate(normalizedAiSummary, detailRecord)) {
return limitLength(normalizedAiSummary, 60);
}
const localSummary = buildLocalSummary(detailRecord);
if (localSummary) {
return localSummary;
}
const fieldSummary = buildFieldFallbackSummary(detailRecord);
if (fieldSummary) {
return fieldSummary;
}
return limitLength(detailRecord.title, 60);
}
function addResult(detailRecord, decision) {
const result = {
id: detailRecord.id,
title: detailRecord.title,
summary: buildSummary(detailRecord, decision.matchedKeywords || [], decision.summary || decision.titleSummary || ""),
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);
}
}
2026-04-08 13:07:01 +08:00
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHtml(text) {
return String(text || "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#39;");
}
})();