312 lines
13 KiB
JavaScript
312 lines
13 KiB
JavaScript
// 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('有推送失败项。可在扩展后台日志查看错误,或检查后端是否在线。');
|
||
});
|