Files
2026-08-27 16:12:37 +08:00

312 lines
13 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// popup.js — 控制面板逻辑
const btnStart = document.getElementById('btnStart');
const btnPause = document.getElementById('btnPause');
const btnExport = document.getElementById('btnExport');
const btnClear = document.getElementById('btnClear');
const statusText = document.getElementById('statusText');
const progressFill = document.getElementById('progressFill');
const logBox = document.getElementById('logBox');
const modeSelect = document.getElementById('modeSelect');
const optDedup = document.getElementById('optDedup');
const optChat = document.getElementById('optChat');
const limitSelect = document.getElementById('limitSelect');
const chatTimeRangeSelect = document.getElementById('chatTimeRange');
const chatCustomDates = document.getElementById('chatCustomDates');
const chatSinceDate = document.getElementById('chatSinceDate');
const chatUntilDate = document.getElementById('chatUntilDate');
const chatLimitHint = document.getElementById('chatLimitHint');
function addLog(msg, type = '') {
const p = document.createElement('p');
p.className = type;
p.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
logBox.appendChild(p);
logBox.scrollTop = logBox.scrollHeight;
}
function setProgress(current, total) {
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
progressFill.style.width = pct + '%';
statusText.textContent = `${current} / ${total} (${pct}%)`;
}
function updateButtons(phase) {
const running = phase === 'list' || phase === 'detail';
const paused = phase === 'paused';
btnStart.disabled = running || paused;
btnPause.disabled = !running && !paused;
btnPause.textContent = paused ? '恢复' : '暂停';
btnExport.disabled = running || paused;
}
// 带重试的 send,content script 注入需要时间
function send(action, data = {}, retries = 3) {
return new Promise(resolve => {
function attempt(remaining) {
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
if (!tabs[0]) { addLog('未找到活动标签页', 'error'); resolve(); return; }
// 检查是否在正确的域名
if (!tabs[0].url || !tabs[0].url.includes('message.alibaba.com')) {
addLog('请在 message.alibaba.com 页面操作', 'error');
resolve();
return;
}
chrome.tabs.sendMessage(tabs[0].id, { action, ...data }, resp => {
if (chrome.runtime.lastError) {
// content script 未注入(扩展重载后旧页面失效/新标签注入慢)→ 程序化注入兜底。
// content.js 自带 __alibabaScraperInit 防重复注入,重复执行安全。
chrome.scripting.executeScript({ target: { tabId: tabs[0].id }, files: ['content.js'] }, () => {
if (chrome.runtime.lastError) {
addLog('自动注入失败: ' + chrome.runtime.lastError.message, 'error');
resolve();
return;
}
if (remaining > 0) {
setTimeout(() => attempt(remaining - 1), 800); // 注入完成后再试
} else {
addLog('连接失败,请刷新页面后重试', 'error');
resolve();
}
});
} else {
resolve(resp);
}
});
});
}
attempt(retries);
});
}
// 检查当前状态
send('getState').then(resp => {
if (resp) {
if (resp.running) {
addLog(`有进行中的任务 (列表${resp.listCount}条, 详情${resp.detailCount}条, 队列${resp.queueIndex}/${resp.queueTotal})`, 'info');
statusText.textContent = `运行中 (${resp.queueIndex}/${resp.queueTotal})`;
updateButtons(resp.phase);
} else if (resp.phase === 'paused') {
addLog('有暂停的任务,点击"恢复"继续', 'info');
statusText.textContent = '已暂停';
updateButtons('paused');
}
}
});
btnStart.addEventListener('click', async () => {
const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value);
if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; }
// 与 range 同步快照:导航等待 5s 内用户仍可改下拉,不能让部分参数冻结、部分活读(参数漂移)
const chatLimitSnap = chatTimeRangeSelect.value === 'all' ? (parseInt(document.getElementById('chatLimitInput').value, 10) || 0) : 0;
updateButtons('list');
hideRiskBanner(); // 开始新任务时隐藏风控红条
addLog('开始抓取...', 'info');
statusText.textContent = '启动中...';
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
const tab = tabs && tabs[0];
// 不在消息页 → 自动跳转,等 content.js 注入后再发 start
if (tab && !(tab.url && tab.url.includes('message.alibaba.com'))) {
addLog('当前不在消息页,自动跳转...', 'info');
statusText.textContent = '跳转消息页...';
await chrome.tabs.update(tab.id, { url: 'https://message.alibaba.com/message/default.htm#feedback/all' });
await new Promise(r => setTimeout(r, 5000)); // 等 document_idle + content 注入
addLog('消息页就绪,开始抓取', 'info');
}
await send('start', {
mode: modeSelect.value,
dedup: optDedup.checked,
chat: optChat.checked,
limit: parseInt(limitSelect.value, 10),
chatLimit: chatLimitSnap,
chatSince: range.since,
chatUntil: range.until,
});
});
});
btnPause.addEventListener('click', async () => {
const phase = btnPause.textContent === '恢复' ? 'resume' : 'pause';
await send(phase);
if (phase === 'pause') {
updateButtons('paused');
statusText.textContent = '已暂停';
btnPause.textContent = '恢复';
addLog('已暂停(支持跨页面恢复)', 'info');
} else {
updateButtons('list');
hideRiskBanner(); // 恢复时隐藏风控红条(用户已处理验证码)
statusText.textContent = '恢复中...';
btnPause.textContent = '暂停';
addLog('恢复抓取...', 'info');
}
});
btnExport.addEventListener('click', async () => {
addLog('生成 CSV...', 'info');
const resp = await send('export');
if (resp && resp.success) {
addLog(`导出 ${resp.count} 条数据`, 'success');
const blob = new Blob(['\uFEFF' + resp.csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `alibaba_inquiry_${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
}
});
btnClear.addEventListener('click', async () => {
await send('clear');
hideRiskBanner(); // 清空时隐藏风控红条
logBox.innerHTML = '';
statusText.textContent = '就绪';
progressFill.style.width = '0%';
btnPause.textContent = '暂停';
updateButtons('idle');
addLog('数据已清空', 'info');
});
// 监听 content script 推送
chrome.runtime.onMessage.addListener(msg => {
if (msg.type === 'log') addLog(msg.text, msg.level || '');
if (msg.type === 'progress') setProgress(msg.current, msg.total);
if (msg.type === 'done') { updateButtons('done'); btnExport.disabled = false; statusText.textContent = '完成'; }
if (msg.type === 'error') { updateButtons('idle'); statusText.textContent = '出错'; }
// 安全层 #6:风控报警 —— 顶部红条 + 日志,按钮切到 paused 态
if (msg.type === 'risk') {
const banner = document.getElementById('riskBanner');
if (banner) banner.style.display = 'block';
addLog('⚠️ 疑似触发风控: ' + (msg.msg || ''), 'error');
updateButtons('paused');
statusText.textContent = '风控暂停';
}
});
// 隐藏风控红条(开始新任务/恢复时调用)
function hideRiskBanner() {
const banner = document.getElementById('riskBanner');
if (banner) banner.style.display = 'none';
}
// ==== 聊天时间范围(spec 2026-08-25 §4.1)====
// minusMonths/computeChatRange 与 scripts/verify-chat-range.mjs 基准实现逐字同步
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
function minusMonths(base, n) {
const d = new Date(base.getFullYear(), base.getMonth(), 1,
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
d.setMonth(d.getMonth() - n);
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(base.getDate(), lastDay));
return d;
}
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
function computeChatRange(mode, sinceStr, untilStr) {
if (mode === 'all') return { since: null, until: null };
if (mode === '1m' || mode === '2m' || mode === '3m') {
// 整天边界:since=起始日 00:00:00(完整自然月天数),until=今日 23:59:59.999("至今"取今天整天,与 custom 止=今天一致)
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
const dayEnd = new Date(); dayEnd.setHours(23, 59, 59, 999);
return { since: minusMonths(dayStart, Number(mode[0])).toISOString(), until: dayEnd.toISOString() };
}
if (mode === 'custom') {
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
const u = new Date(untilStr + 'T23:59:59.999');
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
const uDay = new Date(untilStr + 'T00:00:00.000');
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
return { since: s.toISOString(), until: u.toISOString() };
}
return { error: '未知时间范围模式' };
}
function syncChatModeUI() {
const mode = chatTimeRangeSelect.value;
const custom = mode === 'custom';
chatCustomDates.style.display = custom ? 'block' : 'none';
if (custom && !chatUntilDate.value) {
const t = new Date();
chatUntilDate.value = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
}
const timeMode = mode !== 'all';
document.getElementById('chatLimitInput').disabled = timeMode; // D1: 时间替代条数
chatLimitHint.textContent = timeMode ? '(时间模式下忽略)' : '';
}
chatTimeRangeSelect.addEventListener('change', syncChatModeUI);
syncChatModeUI();
// === 后端推送配置 ===
const cfgServerUrl = document.getElementById('cfgServerUrl');
const cfgToken = document.getElementById('cfgToken');
const cfgEnabled = document.getElementById('cfgEnabled');
// 加载现有配置(background 返回 { ok, config })
chrome.runtime.sendMessage({ type: 'getPushConfig' }, resp => {
if (chrome.runtime.lastError) { addLog('读取推送配置失败: ' + chrome.runtime.lastError.message, 'error'); return; }
const cfg = resp && resp.ok ? resp.config : null;
cfgServerUrl.value = (cfg && cfg.serverUrl) || 'http://219.157.255.213:23010';
cfgToken.value = (cfg && cfg.token) || '688cbb4b6e880b340b2360559c4ad29d01a39ccbb3c31c4d';
cfgEnabled.checked = cfg ? !!cfg.enabled : true; // 默认勾「启用推送」(首次未配置时)
});
// 授权域名(chrome.permissions.request 必须在用户手势内)
document.getElementById('btnAuthorize').addEventListener('click', () => {
const url = cfgServerUrl.value.trim();
if (!url) { addLog('请先填写服务器 URL', 'error'); return; }
let origin;
try { const u = new URL(url); origin = `${u.origin}/*`; }
catch { addLog('URL 格式错误', 'error'); return; }
chrome.permissions.request({ origins: [origin] }, granted => {
if (chrome.runtime.lastError) { addLog('授权请求失败: ' + chrome.runtime.lastError.message, 'error'); return; }
if (granted) addLog('已授权 ' + origin, 'success');
else addLog('授权被拒绝', 'error');
});
});
// 保存设置
document.getElementById('btnSaveConfig').addEventListener('click', () => {
const config = {
serverUrl: cfgServerUrl.value.trim().replace(/\/+$/, ''), // 去尾斜杠
token: cfgToken.value.trim(),
enabled: cfgEnabled.checked,
};
chrome.runtime.sendMessage({ type: 'setPushConfig', config }, resp => {
if (chrome.runtime.lastError) { addLog('保存失败: ' + chrome.runtime.lastError.message, 'error'); return; }
if (resp && resp.ok) addLog('推送设置已保存' + (config.enabled ? '(已启用)' : ''), 'success');
else addLog('保存失败', 'error');
});
});
// === 推送状态显示 ===
const pushStatus = document.getElementById('pushStatus');
function refreshPushStatus() {
chrome.runtime.sendMessage({ type: 'getPushStats' }, resp => {
if (chrome.runtime.lastError || !resp || !resp.ok || !resp.stats) {
pushStatus.style.color = '#999';
pushStatus.textContent = '推送:未启用';
return;
}
const stats = resp.stats;
const pending = stats.pending || 0;
const failed = stats.failed || 0;
const sent = stats.sent || 0;
pushStatus.style.color = failed > 0 ? '#f5222d' : '#666';
pushStatus.textContent = `推送:已推 ${sent} / 待推 ${pending} / 失败 ${failed}`;
});
}
refreshPushStatus();
const statusTimer = setInterval(refreshPushStatus, 2000);
window.addEventListener('unload', () => clearInterval(statusTimer));
// 失败时点击查看提示
pushStatus.addEventListener('click', () => {
if (pushStatus.textContent.includes('失败 0')) return;
alert('有推送失败项。可在扩展后台日志查看错误,或检查后端是否在线。');
});