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-10 11:28:42 +08:00
|
|
|
|
|
|
|
|
|
|
const DB_NAME = "YfbBidHistoryDB";
|
|
|
|
|
|
const STORE_NAME = "CrawlHistory";
|
|
|
|
|
|
|
|
|
|
|
|
function openDB() {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const request = indexedDB.open(DB_NAME, 1);
|
|
|
|
|
|
request.onerror = () => reject(request.error);
|
|
|
|
|
|
request.onsuccess = () => resolve(request.result);
|
|
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
|
|
|
|
const db = event.target.result;
|
|
|
|
|
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
|
|
|
|
const store = db.createObjectStore(STORE_NAME, { keyPath: "id" });
|
|
|
|
|
|
store.createIndex("publishTime", "publishTime", { unique: false });
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getHistoryRecord(id) {
|
|
|
|
|
|
if(!id) return null;
|
|
|
|
|
|
const db = await openDB();
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const tx = db.transaction(STORE_NAME, "readonly");
|
|
|
|
|
|
const store = tx.objectStore(STORE_NAME);
|
|
|
|
|
|
const req = store.get(id);
|
|
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
|
|
|
|
req.onerror = () => reject(req.error);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveHistoryRecord(record) {
|
|
|
|
|
|
if(!record.id) return;
|
|
|
|
|
|
const db = await openDB();
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
|
|
|
|
const store = tx.objectStore(STORE_NAME);
|
|
|
|
|
|
const req = store.put({
|
|
|
|
|
|
id: record.id,
|
|
|
|
|
|
url: record.url || record.id || "",
|
|
|
|
|
|
title: record.title || "",
|
|
|
|
|
|
publishTime: record.publishTime || "",
|
|
|
|
|
|
processedAt: Date.now()
|
|
|
|
|
|
});
|
|
|
|
|
|
req.onsuccess = () => resolve();
|
|
|
|
|
|
req.onerror = () => reject(req.error);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function clearOldHistory() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const db = await openDB();
|
|
|
|
|
|
const threeMonthsAgo = Date.now() - 3 * 30 * 24 * 60 * 60 * 1000;
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
|
|
|
|
const store = tx.objectStore(STORE_NAME);
|
|
|
|
|
|
const req = store.openCursor();
|
|
|
|
|
|
req.onsuccess = (e) => {
|
|
|
|
|
|
const cursor = e.target.result;
|
|
|
|
|
|
if (cursor) {
|
|
|
|
|
|
if (cursor.value.processedAt < threeMonthsAgo) {
|
|
|
|
|
|
cursor.delete();
|
|
|
|
|
|
}
|
|
|
|
|
|
cursor.continue();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resolve();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
req.onerror = () => reject(req.error);
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch(e) {
|
|
|
|
|
|
console.warn("清理历史记录失败", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parsePublishTimeToTs(timeStr) {
|
|
|
|
|
|
if (!timeStr) return 0;
|
|
|
|
|
|
timeStr = timeStr.trim();
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (timeStr.includes("刚刚")) return now;
|
|
|
|
|
|
if (timeStr.includes("分钟前")) {
|
|
|
|
|
|
const min = parseInt(timeStr.replace(/\D/g, "")) || 0;
|
|
|
|
|
|
return now - min * 60000;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (timeStr.includes("小时前")) {
|
|
|
|
|
|
const hr = parseInt(timeStr.replace(/\D/g, "")) || 0;
|
|
|
|
|
|
return now - hr * 3600000;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (timeStr.includes("今天")) return new Date(new Date().setHours(0,0,0,0)).getTime();
|
|
|
|
|
|
if (timeStr.includes("昨日") || timeStr.includes("昨天")) {
|
|
|
|
|
|
return new Date(new Date().setHours(0,0,0,0)).getTime() - 86400000;
|
|
|
|
|
|
}
|
|
|
|
|
|
const parsed = new Date(timeStr.replace(/\./g, "-")).getTime();
|
|
|
|
|
|
if (!isNaN(parsed)) return parsed;
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isTimeInRange(timeTs, range) {
|
|
|
|
|
|
if (range === "all" || !range) return true;
|
|
|
|
|
|
if (!timeTs) return true;
|
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const todayStart = new Date(new Date().setHours(0,0,0,0)).getTime();
|
|
|
|
|
|
if (range === "today") return timeTs >= todayStart;
|
|
|
|
|
|
if (range === "24h") return timeTs >= (now - 24 * 3600000);
|
|
|
|
|
|
if (range === "3d") return timeTs >= (now - 3 * 24 * 3600000);
|
|
|
|
|
|
if (range === "1w") return timeTs >= (now - 7 * 24 * 3600000);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
statusText: "等待开始",
|
|
|
|
|
|
settings: {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
maxPages: 3,
|
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);
|
|
|
|
|
|
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 `
|
|
|
|
|
|
<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>
|
2026-04-10 11:28:42 +08:00
|
|
|
|
<div class="yfb-field">
|
|
|
|
|
|
<label for="yfb-time-range">抓取范围</label>
|
|
|
|
|
|
<select id="yfb-time-range">
|
|
|
|
|
|
<option value="today">仅看今天</option>
|
|
|
|
|
|
<option value="24h">近 24 小时</option>
|
|
|
|
|
|
<option value="3d">近 3 天</option>
|
|
|
|
|
|
<option value="1w">近 1 周</option>
|
|
|
|
|
|
<option value="all" selected>全部(依赖页数)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-08 13:07:01 +08:00
|
|
|
|
<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>
|
2026-04-10 11:28:42 +08:00
|
|
|
|
<div class="yfb-field">
|
|
|
|
|
|
<label for="yfb-time-range">抓取范围</label>
|
|
|
|
|
|
<select id="yfb-time-range">
|
|
|
|
|
|
<option value="today">仅看今天</option>
|
|
|
|
|
|
<option value="24h">近 24 小时</option>
|
|
|
|
|
|
<option value="3d">近 3 天</option>
|
|
|
|
|
|
<option value="1w">近 1 周</option>
|
|
|
|
|
|
<option value="all" selected>全部(依赖页数)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-08 13:07:01 +08:00
|
|
|
|
<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");
|
2026-04-10 11:28:42 +08:00
|
|
|
|
ui.timeRangeInput = panel.querySelector("#yfb-time-range");
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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);
|
2026-04-10 11:28:42 +08:00
|
|
|
|
if(ui.timeRangeInput && state.settings.timeRange) { ui.timeRangeInput.value = state.settings.timeRange; }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
ui.delayInput.value = String(state.settings.delayMs);
|
|
|
|
|
|
|
|
|
|
|
|
ui.maxPagesInput.addEventListener("change", handleSettingsChange);
|
2026-04-10 11:28:42 +08:00
|
|
|
|
if (ui.timeRangeInput) { ui.timeRangeInput.addEventListener("change", handleSettingsChange); }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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() {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
|
|
|
|
void sender;
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
if (!message) {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
return false;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === "YFB_TOGGLE_PANEL") {
|
|
|
|
|
|
state.panelHidden = !state.panelHidden;
|
|
|
|
|
|
refreshView();
|
|
|
|
|
|
void persistState();
|
2026-04-10 11:28:42 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (message.type === "YFB_DETAIL_WORKER_PING") {
|
|
|
|
|
|
sendResponse({ ok: true, isDetailPage: detectDetailPage() });
|
|
|
|
|
|
return false;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
}
|
2026-04-10 11:28:42 +08:00
|
|
|
|
|
|
|
|
|
|
if (message.type === "YFB_RUN_DETAIL_EXTRACTION") {
|
|
|
|
|
|
void runDetailWorkerExtraction(message.payload)
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
sendResponse({ ok: true, data });
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch((error) => {
|
|
|
|
|
|
sendResponse({
|
|
|
|
|
|
ok: false,
|
|
|
|
|
|
error: error instanceof Error ? error.message : "详情提取失败"
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
async function runDetailWorkerExtraction(payload) {
|
|
|
|
|
|
const rowMeta = payload?.rowMeta || {};
|
|
|
|
|
|
const detailUrl = normalizeUrl(payload?.detailUrl || location.href);
|
|
|
|
|
|
|
|
|
|
|
|
await waitForDetailPage();
|
|
|
|
|
|
await waitForUiSettled(false);
|
|
|
|
|
|
dismissKnownDialogs();
|
|
|
|
|
|
await sleep(120);
|
|
|
|
|
|
|
|
|
|
|
|
const detailRecord = extractDetailRecord({
|
|
|
|
|
|
...rowMeta,
|
|
|
|
|
|
detailUrl
|
|
|
|
|
|
});
|
|
|
|
|
|
detailRecord.detailUrl = detailUrl || detailRecord.detailUrl;
|
|
|
|
|
|
|
|
|
|
|
|
const decision = await analyzeRecord(detailRecord);
|
|
|
|
|
|
return {
|
|
|
|
|
|
detailRecord,
|
|
|
|
|
|
decision
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
2026-04-10 11:28:42 +08:00
|
|
|
|
await clearOldHistory();
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
2026-04-10 11:28:42 +08:00
|
|
|
|
await clearOldHistory();
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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");
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
let outOfRangeCount = 0;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
const rMeta = rows[rowIndex];
|
|
|
|
|
|
if (state.settings.timeRange && state.settings.timeRange !== 'all') {
|
|
|
|
|
|
const ts = parsePublishTimeToTs(rMeta.publishTime);
|
|
|
|
|
|
if (ts > 0 && !isTimeInRange(ts, state.settings.timeRange)) {
|
|
|
|
|
|
outOfRangeCount++;
|
|
|
|
|
|
if (outOfRangeCount >= 3) {
|
|
|
|
|
|
log("发现连续超出时间范围的记录,停止任务。", "warning");
|
|
|
|
|
|
state.stopRequested = true;
|
|
|
|
|
|
updateRowStatus(rMeta.id, "skip", "超时跳过");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outOfRangeCount = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const hist = await getHistoryRecord(rowMeta.url || rowMeta.id);
|
|
|
|
|
|
if (hist) {
|
|
|
|
|
|
state.stats.scanned += 1;
|
|
|
|
|
|
updateRowStatus(rowMeta.id, "skip", "已在历史记录");
|
|
|
|
|
|
log(`历史记录跳过:${rowMeta.title}`, "info");
|
|
|
|
|
|
state.stats.hits = state.results.length;
|
|
|
|
|
|
refreshView();
|
|
|
|
|
|
await persistState();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) { console.warn(e); }
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
2026-04-10 11:28:42 +08:00
|
|
|
|
try { await saveHistoryRecord(detailRecord); } catch(e) { console.warn(e); }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-04-10 11:28:42 +08:00
|
|
|
|
url: deriveDetailUrlFromRow(rowEl, titleEl),
|
2026-04-08 13:07:01 +08:00
|
|
|
|
previewText,
|
|
|
|
|
|
previewKeywordHints: collectKeywordHints([title, type, region, publishTime, previewText].join("\n"))
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
function deriveDetailUrlFromRow(rowEl, titleEl) {
|
|
|
|
|
|
const candidates = [
|
|
|
|
|
|
titleEl,
|
|
|
|
|
|
titleEl?.closest?.("a[href]"),
|
|
|
|
|
|
rowEl?.querySelector?.("a[href]"),
|
|
|
|
|
|
rowEl?.querySelector?.("[href]")
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
|
const href = candidate?.href || candidate?.getAttribute?.("href") || "";
|
|
|
|
|
|
const normalized = normalizeUrl(href);
|
|
|
|
|
|
if (normalized) {
|
|
|
|
|
|
return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeUrl(url) {
|
|
|
|
|
|
const value = String(url || "").trim();
|
|
|
|
|
|
if (!value) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return new URL(value, location.href).href;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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 = `
|
|
|
|
|
|
<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
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
const workbook = window.XLSX.utils.book_new();
|
2026-04-10 11:28:42 +08:00
|
|
|
|
const worksheet = window.XLSX.utils.json_to_sheet(rows);
|
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>
|
2026-04-10 11:28:42 +08:00
|
|
|
|
<div class="yfb-field">
|
|
|
|
|
|
<label for="yfb-time-range">抓取范围</label>
|
|
|
|
|
|
<select id="yfb-time-range">
|
|
|
|
|
|
<option value="today">仅看今天</option>
|
|
|
|
|
|
<option value="24h">近 24 小时</option>
|
|
|
|
|
|
<option value="3d">近 3 天</option>
|
|
|
|
|
|
<option value="1w">近 1 周</option>
|
|
|
|
|
|
<option value="all" selected>全部(依赖页数)</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
2026-04-08 13:07:01 +08:00
|
|
|
|
<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");
|
2026-04-10 11:28:42 +08:00
|
|
|
|
ui.timeRangeInput = panel.querySelector("#yfb-time-range");
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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);
|
2026-04-10 11:28:42 +08:00
|
|
|
|
if(ui.timeRangeInput && state.settings.timeRange) { ui.timeRangeInput.value = state.settings.timeRange; }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
ui.delayInput.value = String(state.settings.delayMs);
|
|
|
|
|
|
|
|
|
|
|
|
ui.maxPagesInput.addEventListener("change", handleSettingsChange);
|
2026-04-10 11:28:42 +08:00
|
|
|
|
if (ui.timeRangeInput) { ui.timeRangeInput.addEventListener("change", handleSettingsChange); }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
2026-04-10 11:28:42 +08:00
|
|
|
|
await clearOldHistory();
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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");
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
let outOfRangeCount = 0;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
const rMeta = rows[rowIndex];
|
|
|
|
|
|
if (state.settings.timeRange && state.settings.timeRange !== 'all') {
|
|
|
|
|
|
const ts = parsePublishTimeToTs(rMeta.publishTime);
|
|
|
|
|
|
if (ts > 0 && !isTimeInRange(ts, state.settings.timeRange)) {
|
|
|
|
|
|
outOfRangeCount++;
|
|
|
|
|
|
if (outOfRangeCount >= 3) {
|
|
|
|
|
|
log("发现连续超出时间范围的记录,停止任务。", "warning");
|
|
|
|
|
|
state.stopRequested = true;
|
|
|
|
|
|
updateRowStatus(rMeta.id, "skip", "超时跳过");
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
outOfRangeCount = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
const detailUrl = await ensureDetailUrlForRow(rowMeta);
|
|
|
|
|
|
if (!detailUrl) {
|
|
|
|
|
|
state.stats.scanned += 1;
|
|
|
|
|
|
updateRowStatus(rowMeta.id, "error", "缺少详情地址");
|
|
|
|
|
|
log(`处理失败:${rowMeta.title},未找到详情页地址`, "error");
|
|
|
|
|
|
state.stats.hits = state.results.length;
|
|
|
|
|
|
refreshView();
|
|
|
|
|
|
await persistState();
|
|
|
|
|
|
restoreListHighlights();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-04-08 13:07:01 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-10 11:28:42 +08:00
|
|
|
|
const hist = await getHistoryRecord(detailUrl || rowMeta.id);
|
|
|
|
|
|
if (hist) {
|
|
|
|
|
|
state.stats.scanned += 1;
|
|
|
|
|
|
updateRowStatus(rowMeta.id, "skip", "已在历史记录");
|
|
|
|
|
|
log(`历史记录跳过:${rowMeta.title}`, "info");
|
|
|
|
|
|
state.stats.hits = state.results.length;
|
|
|
|
|
|
refreshView();
|
|
|
|
|
|
await persistState();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch(e) { console.warn(e); }
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await runtimeSendMessage({
|
|
|
|
|
|
type: "YFB_PROCESS_DETAIL_IN_HIDDEN_TAB",
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
detailUrl,
|
|
|
|
|
|
rowMeta: {
|
|
|
|
|
|
id: rowMeta.id,
|
|
|
|
|
|
title: rowMeta.title,
|
|
|
|
|
|
type: rowMeta.type,
|
|
|
|
|
|
region: rowMeta.region,
|
|
|
|
|
|
publishTime: rowMeta.publishTime,
|
|
|
|
|
|
url: detailUrl
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!response?.ok || !response.data?.detailRecord || !response.data?.decision) {
|
|
|
|
|
|
throw new Error(response?.error || "记录处理失败");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const detailRecord = response.data.detailRecord;
|
|
|
|
|
|
const decision = response.data.decision;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
state.stats.scanned += 1;
|
2026-04-10 11:28:42 +08:00
|
|
|
|
try { await saveHistoryRecord(detailRecord); } catch(e) { console.warn(e); }
|
2026-04-08 13:07:01 +08:00
|
|
|
|
|
|
|
|
|
|
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 : "记录处理失败";
|
2026-04-10 11:28:42 +08:00
|
|
|
|
state.stats.scanned += 1;
|
2026-04-08 13:07:01 +08:00
|
|
|
|
updateRowStatus(rowMeta.id, "error", "异常");
|
|
|
|
|
|
log(`处理失败:${rowMeta.title},${message}`, "error");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.stats.hits = state.results.length;
|
|
|
|
|
|
refreshView();
|
|
|
|
|
|
await persistState();
|
|
|
|
|
|
restoreListHighlights();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-10 11:28:42 +08:00
|
|
|
|
async function ensureDetailUrlForRow(rowMeta) {
|
|
|
|
|
|
const directUrl = normalizeUrl(rowMeta?.url || "");
|
|
|
|
|
|
if (directUrl) {
|
|
|
|
|
|
return directUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await captureDetailUrlViaNavigationFallback(rowMeta);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function captureDetailUrlViaNavigationFallback(rowMeta) {
|
|
|
|
|
|
if (!rowMeta?.titleEl) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const listUrl = location.href;
|
|
|
|
|
|
await clickElement(rowMeta.titleEl);
|
|
|
|
|
|
await sleep(state.settings.delayMs);
|
|
|
|
|
|
await waitForDetailPage();
|
|
|
|
|
|
const detailUrl = normalizeUrl(location.href);
|
|
|
|
|
|
await navigateBackToList();
|
|
|
|
|
|
await sleep(state.settings.delayMs);
|
|
|
|
|
|
|
|
|
|
|
|
const restoredRow = findRowById(rowMeta.id);
|
|
|
|
|
|
if (restoredRow) {
|
|
|
|
|
|
restoredRow.url = detailUrl;
|
|
|
|
|
|
rowMeta.url = detailUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!detailUrl || detailUrl === normalizeUrl(listUrl)) {
|
|
|
|
|
|
return "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return detailUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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-10 11:28:42 +08:00
|
|
|
|
const worksheet = window.XLSX.utils.json_to_sheet(rows, { header: headers });
|
2026-04-08 13:07:01 +08:00
|
|
|
|
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) => `<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,
|
|
|
|
|
|
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("'", "'");
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|