Files
alibaba/popup.js
T

312 lines
13 KiB
JavaScript
Raw Normal View History

2026-08-27 16:12:37 +08:00
// 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('有推送失败项。可在扩展后台日志查看错误,或检查后端是否在线。');
});