Files
alibaba/content.js
T
2026-08-27 16:12:37 +08:00

1302 lines
62 KiB
JavaScript
Raw 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.
// content.js — 核心抓取逻辑(支持跨页面导航状态持久化)
(async function () {
// 防止重复注入
if (window.__alibabaScraperInit) return;
window.__alibabaScraperInit = true;
// ============ 常量 ============
const LIST_URL = 'https://message.alibaba.com/message/default.htm#feedback/all';
const STORAGE_KEY = 'alibaba_scraper_state';
// ============ 状态(从 storage 恢复) ============
let state = {
phase: 'idle', // idle | list | detail | paused | done
mode: 'detail',
dedup: true,
chat: true,
listData: [],
detailData: [],
customerIdMap: {},
seenChatCustomer: {},
detailQueue: [],
queueIndex: 0,
currentPage: 0,
totalPages: 0,
totalInquiries: 0,
startTime: '',
};
// 从 storage 恢复状态
async function loadState() {
try {
const stored = await chrome.storage.local.get(STORAGE_KEY);
if (stored[STORAGE_KEY]) {
state = { ...state, ...stored[STORAGE_KEY] };
log('从缓存恢复状态: ' + state.phase + ', 队列 ' + state.queueIndex + '/' + state.detailQueue.length);
}
} catch (e) { /* 首次安装无缓存 */ }
}
async function saveState() {
try {
await chrome.storage.local.set({ [STORAGE_KEY]: state });
} catch (e) { /* 忽略 */ }
}
// ============ 通信 ============
function sendToPopup(type, data = {}) {
try { chrome.runtime.sendMessage({ type, ...data }); } catch (e) {}
}
function log(text, level = '') {
console.log('[阿里抓取]', text);
sendToPopup('log', { text, level });
}
function progress(current, total) {
sendToPopup('progress', { current, total });
}
// ============ 工具 ============
const sleep = ms => new Promise(r => setTimeout(r, ms));
const randSleep = (min, max) => sleep(min + Math.random() * (max - min)); // 随机间隔,降低风控指纹
function isDetailPage() {
return window.location.href.includes('maDetail.htm');
}
// 提取 customerId(三层兜底:真customerId link href → inquiryId → secTradeId)。
// 独立出来:fetchChatByApi 调用时 scrapeDetailData 还没跑,需提前用它判断"同客户是否已抓聊天"。
function extractCustomerId() {
const link = document.querySelector('a.customer-detail-link');
let cid = '';
if (link) {
const m = (link.getAttribute('href') || '').match(/customerId=([^&]+)/);
cid = m ? m[1] : '';
}
if (!cid) {
const iq = location.search.match(/imInquiryId=(\d+)/) || location.hash.match(/detail\/all\/(\d+)\//);
cid = iq ? 'iq_' + iq[1] : '';
}
if (!cid) {
const sec = location.hash.match(/MC1IDX1[\w-]+/);
cid = sec ? 'sec_' + sec[0] : '';
}
return cid;
}
// ==== 聊天时间范围工具(与 scripts/verify-chat-range.mjs 基准逐字同步;改任一侧必须同步另一侧)====
// 范围过滤(null sendTime 防御性保留 —— 宁多勿漏)
function inChatRange(sendTime, sinceMs, untilMs) {
if (sendTime == null) return true;
if (sinceMs != null && sendTime < sinceMs) return false;
if (untilMs != null && sendTime > untilMs) return false;
return true;
}
// 水位读取兼容:旧=纯 string(sendTime 未知) / 新={id,sendTime}
function parseWatermark(v) {
if (v == null || v === '') return null;
try {
const o = JSON.parse(v);
if (o && typeof o === 'object' && o.id != null) return { id: String(o.id), sendTime: Number(o.sendTime) || null };
} catch (e) { /* 旧格式继续 */ }
return { id: String(v), sendTime: null };
}
// 水位只前进写入判定
function shouldWriteWatermark(oldWm, candId, candSendTime) {
if (candId == null || candSendTime == null) return false;
if (oldWm == null) return true;
if (oldWm.sendTime == null) return true; // legacy 升级,允许覆盖一次
return candSendTime > oldWm.sendTime;
}
// probe 轮断言分类:'fail'(非对象/code≠200/data=null) | 'empty'(空list) | null(正常)。
// non-JSON/HTTP/关键词由 postForm 抛 RISK,不经此函数(真风控照抛,不降级)
function probeFailKind(json) {
if (!json || typeof json !== 'object') return 'fail';
if (json.code !== undefined && Number(json.code) !== 200) return 'fail';
if (json.data == null) return 'fail';
const list = json.data && json.data.list;
if (!Array.isArray(list) || list.length === 0) return 'empty';
return null;
}
// ISO → 毫秒,parse 容错(spec §5:非法/空 → null = 降级不限)
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; }
// ============ 安全层:风控关键词 ============
// getOpMessages 响应里命中任一关键词 → throw 'RISK:关键词',由主流程触发暂停报警
const RISK_KEYWORDS = ['验证码', 'captcha', '滑动验证', '安全验证', 'blocked', '访问频繁', '限制访问', '风控'];
// 对方账号不可用(自行注销 / 违反平台规则被管控)的提示文案。命中 → 本询盘无法抓取,
// 直接跳过到下一条(不算风控、不暂停、不发聊天 API,彻底绕开风控检测)。
const SKIP_KEYWORDS = ['对方账号已自行注销', '违反平台规则被管控'];
const hitsSkipKeyword = s => {
const lower = String(s == null ? '' : s).toLowerCase();
return SKIP_KEYWORDS.find(kw => lower.includes(String(kw).toLowerCase()));
};
// ============ 安全层:增量水位(新格式 {id,sendTime};只前进不倒退,spec §4.5) ============
async function getWatermark(inquiryId) {
if (!inquiryId) return null;
try {
const r = await chrome.storage.local.get(['lastTopMsg_' + inquiryId]);
return parseWatermark(r && r['lastTopMsg_' + inquiryId]);
} catch (e) { return null; }
}
async function trySetWatermark(inquiryId, candId, candSendTime) {
if (!inquiryId) return;
const oldWm = await getWatermark(inquiryId);
if (!shouldWriteWatermark(oldWm, candId, candSendTime)) return;
try {
await chrome.storage.local.set({ ['lastTopMsg_' + inquiryId]: JSON.stringify({ id: candId, sendTime: candSendTime }) });
} catch (e) { /* 持久化失败不致命 */ }
}
// ============ 安全层:DOM 增量抓取 lastDomTime 持久化(按 inquiryId) ============
// 用于 loadAllChatHistory 的 DOM 增量分支:记上次最新 sendTime(ISO 字符串),
// 下次只抓 sendTime > lastDomTime 的新增消息,不往旧滚。ISO 字符串字典序即时间序,可直接比较。
async function getLastDomTime(inquiryId) {
if (!inquiryId) return null;
try {
const key = 'lastDomTime_' + inquiryId;
const r = await chrome.storage.local.get([key]);
return r && r[key] ? String(r[key]) : null;
} catch (e) { return null; }
}
async function setLastDomTime(inquiryId, ts) {
if (!inquiryId || !ts) return;
try {
const key = 'lastDomTime_' + inquiryId;
await chrome.storage.local.set({ [key]: String(ts) });
} catch (e) { /* 持久化失败不致命,下一轮可能多抓一些 */ }
}
// ============ 安全层:作息时段(8-12 / 14-18,边界 ±30 分钟会话级随机) ============
// “最像人”:每个工作时段在本会话内有一次随机的开始/结束偏移,避免每天卡整点。
const WORK_RANGES = [{ start: 8, end: 12 }, { start: 14, end: 18 }];
const sessionWorkOffsets = WORK_RANGES.map(() => ({
// ±30 分钟(单位:分钟)
startOffset: Math.round((Math.random() - 0.5) * 2 * 30),
endOffset: Math.round((Math.random() - 0.5) * 2 * 30),
}));
// 作息时段开关:true=按 8-12/14-18(生产,降低非工作时段高速请求的风控风险);
// false=总是允许(仅测试用,避免非工作时段卡住等待)
const WORK_TIME_ENABLED = false; // 临时本地测试态(18:47 非工作时段会卡);本地验证完恢复 true
function isWorkTime() {
if (!WORK_TIME_ENABLED) return true;
const now = new Date();
const mod = now.getHours() * 60 + now.getMinutes(); // 当日分钟数 0-1439
for (let i = 0; i < WORK_RANGES.length; i++) {
const r = WORK_RANGES[i];
const startMin = r.start * 60 + sessionWorkOffsets[i].startOffset;
const endMin = r.end * 60 + sessionWorkOffsets[i].endOffset;
if (mod >= startMin && mod < endMin) return true;
}
return false;
}
// 暂停轮询直到进入作息时段;用户手动暂停(state.phase==='paused')或完成则提前返回 false
async function waitUntilWorkTime(reason) {
if (isWorkTime()) return true;
log(`非作息时段,暂停等待 (${reason})`, 'info');
while (!isWorkTime()) {
if (state.phase === 'paused') return false;
await randSleep(30000, 60000);
}
log(`进入作息时段,继续 (${reason})`, 'info');
return true;
}
// ============ 监听 popup 指令 ============
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
switch (msg.action) {
case 'start':
state = {
phase: 'list', mode: msg.mode || 'detail', dedup: msg.dedup !== false,
chat: msg.chat !== false, limit: msg.limit || 0, chatLimit: msg.chatLimit ?? 0,
chatSince: msg.chatSince ?? null, chatUntil: msg.chatUntil ?? null, // 时间范围(spec §4.2);显式列入重建清单
listData: [], detailData: [],
customerIdMap: {}, seenChatCustomer: {}, detailQueue: [], queueIndex: 0,
currentPage: 0, totalPages: 0, totalInquiries: 0,
startTime: new Date().toISOString(),
};
saveState();
if (!isDetailPage()) {
runListPhase();
} else {
window.location.href = LIST_URL;
}
sendResponse({ ok: true });
break;
case 'pause':
state.phase = 'paused';
saveState();
log('已暂停', 'info');
sendToPopup('log', { text: '已暂停(刷新页面后状态保留)', level: 'info' });
sendResponse({ ok: true });
break;
case 'resume':
state.phase = state.detailQueue.length > state.queueIndex ? 'detail' : 'list';
saveState();
if (!isDetailPage()) {
if (state.detailQueue.length > state.queueIndex) {
runDetailPhase();
} else {
runListPhase();
}
} else {
// 当前在详情页:跳转到 queueIndex 对应的详情页再抓,避免用上一条 DOM 造成数据错位
if (state.detailQueue.length > state.queueIndex) {
const it = state.detailQueue[state.queueIndex];
const u = it.dataHref.startsWith('http') ? it.dataHref : 'https://message.alibaba.com' + it.dataHref;
window.location.href = u;
} else {
finishAll();
}
}
sendResponse({ ok: true });
break;
case 'export': {
// 回调式获取 background 数据(避免在非 async 监听器里用 await 导致整文件加载失败)
chrome.runtime.sendMessage({ type: 'getBgData' }, bgResult => {
let exportData = state.detailData;
// 列表模式没有详情数据时,回退到 listData(否则列表模式导出空文件)
if (exportData.length === 0 && state.listData.length > 0) {
exportData = state.listData;
}
if (bgResult && bgResult.records && bgResult.records.length > exportData.length) {
exportData = bgResult.records;
}
const csv = buildCSV(exportData, state.mode);
const count = exportData.length;
sendResponse({ success: true, csv, count });
});
break;
}
case 'clear':
state = {
phase: 'idle', mode: 'detail', dedup: true, chat: true,
listData: [], detailData: [], customerIdMap: {}, seenChatCustomer: {},
detailQueue: [], queueIndex: 0, currentPage: 0, totalPages: 0,
totalInquiries: 0, startTime: '',
};
chrome.storage.local.remove(STORAGE_KEY);
// 清增量标记(lastTopMsgId/lastDomTime):旧版 sendTime null 400 可能留下 lastTopMsgId
// 但后端没存,导致新版增量命中后只抓 0 条新增、永远补不回。clear 时一并清,让下次全量重抓。
chrome.storage.local.get(null, all => {
const keys = Object.keys(all || {}).filter(k => k.startsWith('lastTopMsg_') || k.startsWith('lastDomTime_'));
if (keys.length) chrome.storage.local.remove(keys);
});
chrome.runtime.sendMessage({ type: 'clearBgData' });
sendResponse({ ok: true });
break;
case 'getState':
sendResponse({
running: ['list', 'detail'].includes(state.phase),
phase: state.phase,
listCount: state.listData.length,
detailCount: state.detailData.length,
queueIndex: state.queueIndex,
queueTotal: state.detailQueue.length,
});
break;
}
return true;
});
// ============ 列表页抓取 ============
function scrapeListPage() {
const items = document.querySelectorAll('a.inquiry-item');
const list = [];
for (const item of items) {
const idEl = item.querySelector('.spec-inquiry-id');
const inquiryId = idEl ? (idEl.innerText.match(/\d+/) || [''])[0] : '';
if (!inquiryId) continue;
const timeEl = idEl ? idEl.nextElementSibling : null;
let createTime = '', updateTime = '';
if (timeEl) {
const ct = timeEl.innerText.match(/创建时间[::]?\s*(\S+)/);
const ut = timeEl.innerText.match(/更新时间[::]?\s*(\S+)/);
if (ct) createTime = ct[1];
if (ut) updateTime = ut[1];
}
const nameEl = item.querySelector('.aui2-grid-name');
const flagEl = item.querySelector('[class*="ui2-flag"]');
const cc = (flagEl ? flagEl.className : '').match(/ui2-flag-(\w+)/);
const msgEl = item.querySelector('.buyer-chat-msg');
const ownerEl = item.querySelector('.aui-grid-owner-name');
const statusEl = item.querySelector('.aui2-grid-quo-status-col');
const avatarEl = item.querySelector('.sender-avatar');
let avatarUrl = '';
if (avatarEl) {
// .sender-avatar 实测无 style 属性,需尝试 background-image / 子 img / 自身 src
const bg = (avatarEl.getAttribute('style') || '').match(/url\(["']?([^"')]+)/);
if (bg) avatarUrl = bg[1].replace(/&amp;/g, '&');
else {
const img = avatarEl.tagName === 'IMG' ? avatarEl : avatarEl.querySelector('img');
if (img) avatarUrl = (img.getAttribute('src') || '').replace(/&amp;/g, '&');
}
}
list.push({
inquiryId, createTime, updateTime,
buyerName: nameEl ? nameEl.innerText.trim() : '',
countryCode: cc ? cc[1] : '',
msgPreview: msgEl ? msgEl.innerText.trim().substring(0, 300) : '',
manager: ownerEl ? ownerEl.innerText.trim() : '',
status: statusEl ? statusEl.innerText.trim().replace('查看详情', '').trim() : '',
hasBuyerLevel: !!item.querySelector('[class*="buyer-level"]'),
avatarUrl,
isFlagged: !!item.querySelector('[class*="flag-filled"]'),
dataHref: item.getAttribute('data-href') || '',
});
}
return list;
}
function getTotalPages() {
// 页码在 .ui2-pagination-pages 容器内,每页条数在 .ui2-pagination-show
const pagContainer = document.querySelector('.ui2-pagination-pages');
if (pagContainer) {
// 找所有 data-page 属性的最大值
const links = pagContainer.querySelectorAll('a[data-page]');
let maxPage = 1;
for (const link of links) {
const n = parseInt(link.getAttribute('data-page'), 10);
if (!isNaN(n) && n > maxPage) maxPage = n;
}
return maxPage;
}
// 备用
const el = document.querySelector('.ui2-pagination');
if (!el) return 1;
const pages = el.innerText.match(/(\d+)\s*$/);
return pages ? parseInt(pages[1], 10) : 1;
}
function setPageSize(n) {
const btn = document.querySelector(`a[data-page-size="${n}"]`);
if (btn) btn.click();
}
// 等待列表数据加载完成
function waitForListLoaded() {
return new Promise(resolve => {
let tries = 0;
const t = setInterval(() => {
tries++;
const items = document.querySelectorAll('a.inquiry-item');
if (items.length > 0 || tries > 20) {
clearInterval(t);
resolve();
}
}, 500);
});
}
function clickNextPage() {
const btn = document.querySelector('[mc-qa="list-page-next"]') ||
document.querySelector('[mc-role="btn-next-page"]');
if (btn) btn.click();
}
// ============ 列表阶段 ============
async function runListPhase() {
state.phase = 'list';
saveState();
log(`模式: ${state.mode === 'detail' ? '列表+详情' : '仅列表'} — 开始抓取列表`, 'info');
try {
setPageSize(100);
// 等待列表刷新
await waitForListLoaded();
await randSleep(1200, 3000);
state.totalPages = getTotalPages();
state.totalInquiries = state.totalPages * 100;
log(`共 ${state.totalPages} 页,约 ${state.totalInquiries} 条`, 'info');
saveState();
for (let page = 1; page <= state.totalPages; page++) {
if (state.phase === 'paused') return;
if (page > 1) {
clickNextPage();
await waitForListLoaded();
await randSleep(1200, 3000);
}
state.currentPage = page;
const items = scrapeListPage();
log(`第 ${page}/${state.totalPages} 页: ${items.length} 条`, '');
// limit:只取需要的数量
const remaining = state.limit > 0 ? state.limit - state.listData.length : items.length;
const trimmed = remaining > 0 ? items.slice(0, remaining) : [];
state.listData.push(...trimmed);
if (state.mode === 'detail') {
// 去重改由后端按 customerId 合并(同客户详情/聊天一致),扩展全推不丢数据。
state.detailQueue.push(...trimmed);
}
// limit 检查:达到上限就停止
if (state.limit > 0 && state.listData.length >= state.limit) {
log(`已达到限制 ${state.limit} 条,停止列表抓取`, 'info');
break;
}
progress(state.listData.length, state.totalInquiries);
saveState();
await randSleep(800, 1800);
}
log(`列表抓取完成,共 ${state.listData.length} 条`, 'success');
if (state.mode === 'list' || state.phase === 'paused') {
state.phase = 'done';
saveState();
sendToPopup('done', {});
return;
}
// 进入详情阶段
runDetailPhase();
} catch (e) {
log('列表抓取出错: ' + e.message, 'error');
state.phase = 'idle';
saveState();
sendToPopup('error', { text: e.message });
}
}
// ============ 详情阶段(导航到详情页前触发) ============
async function runDetailPhase() {
if (state.queueIndex >= state.detailQueue.length) {
finishAll();
return;
}
// 安全层 #4:进入下一个详情前做作息时段检测;非作息时段挂起等待,用户暂停则提前退出
if (!await waitUntilWorkTime('详情')) return;
if (state.phase === 'paused') return;
state.phase = 'detail';
saveState();
log(`开始抓取详情页 (${state.queueIndex + 1}/${state.detailQueue.length})`, 'info');
const item = state.detailQueue[state.queueIndex];
const fullUrl = item.dataHref.startsWith('http') ? item.dataHref :
'https://message.alibaba.com' + item.dataHref;
// 导航到详情页(content script 会被销毁)
window.location.href = fullUrl;
}
// ============ 在详情页加载后被调用 ============
async function processCurrentDetailPage() {
if (state.phase !== 'detail') return;
try {
// 等待页面加载
await waitForLoad();
// 对方账号注销/被管控:页面提示"对方账号已自行注销.../违反平台规则被管控",本询盘不可抓。
// 命中即 throw SKIP → catch 跳过下一条(在调聊天 API 之前,彻底绕开风控检测、不暂停)
if (hitsSkipKeyword(document.body.innerText || '')) throw new Error('SKIP:账号不可用');
// 优先用 API 直拉聊天(快且完整),失败回退 wheel DOM 滚动
let apiMsgs = null;
let apiMeta = null;
if (state.chat) {
// 同客户去重:同买家多询盘是同一聊天会话(后端 ingest 已按 customerId 归并到代表 inquiry 一份),
// 扩展侧每个询盘都翻页抓一遍是纯浪费(多余 API 请求 + 风控风险)。故同 customer 本次只抓首个遇到的询盘,
// 后续跳过(API/DOM 都不调),该询盘聊天留空 → 后端自动归代表(代表已有完整聊天)。
const custId = extractCustomerId();
if (custId && state.seenChatCustomer[custId]) {
log(`同客户 ${custId} 聊天已抓,跳过本询盘聊天抓取(后端已归代表)`, 'info');
state.chatSkipped = true; // 标记 scrapeDetailData 不再 DOM fallback,避免抓出重复的 dom- 消息
} else {
const r = await fetchChatByApi();
if (r) { apiMsgs = r.messages; apiMeta = r.meta; if (custId) state.seenChatCustomer[custId] = true; }
else await loadAllChatHistory();
}
}
// 等行为卡(含异步加载的「被加为黑名单数」)渲染完再抓,否则黑名单字段会漏抓
await waitForText('被加为黑名单数', 5000);
// 注:客户资料折叠面板(手机/座机/商业类型/销售平台/公司官网)无法用 JS 展开——
// alibaba 检查 event.isTrusted,只接受真实鼠标点击;扩展(content script)的
// .click()/dispatchEvent 都 isTrusted=false 被忽略。这些折叠字段抓不到,保留其他。
// 等客户信息卡(.contact-base-info + 买家名 .name-text)渲染完再抓,
// 否则买家名/国家/公司/邮箱/电话/注册时间等会因 DOM 没加载而漏抓(空)。
for (let i = 0; i < 30; i++) {
if (document.querySelector('.contact-base-info') && document.querySelector('.name-text')) break;
await randSleep(150, 400);
}
// 抓取数据(行为数据异步加载,可能漏;抓后验证 productViews 有值,否则重等重抓,最多 3 次)
// DOM handoff 在重试循环外取一次传入:若在 scrapeDetailData 内消费,首次调用即清空,
// 重试调用会落穿到未过滤全量 DOM 提取 → 时间模式下范围外消息泄漏(seam 修复)
const domHandoff = state._incDomMessages || null;
const domFiltered = !!state._incDomFiltered;
state._incDomMessages = null; state._incDomFiltered = false;
let detail = scrapeDetailData(apiMsgs, domHandoff, domFiltered);
for (let t = 0; t < 3 && !detail.behavior?.productViews; t++) {
await randSleep(800, 1500);
detail = scrapeDetailData(apiMsgs, domHandoff, domFiltered);
}
// 输出抓取结果日志,便于在 popup 确认是否抓全(客户ID/手机/座机/商业类型/行为等)
const it0 = state.detailQueue[state.queueIndex];
log(`抓取[${state.queueIndex + 1}/${state.detailQueue.length}] ${it0?.inquiryId || '?'} → 客户ID:${detail.customerId ? '✓' : '空'} 买家:${detail.detailBuyerName || '空'} 手机:${detail.phone || '空'} 座机:${detail.landline || '空'} 商业类型:${detail.businessType || '空'} 官网:${detail.website || '空'} 行为浏览:${detail.behavior?.productViews || '空'} 店内产品:${detail.storeInquiryProduct ? '✓' : '无'}`, detail.customerId && detail.behavior?.productViews ? 'success' : 'error');
// 把 API 直拉的会话级时间挂到 detail,供 payload.js 作为优先 createTime/updateTime
if (apiMeta) {
detail.createTimeFromApi = apiMeta.gmtCreate;
detail.updateTimeFromApi = apiMeta.gmtModified;
}
const item = state.detailQueue[state.queueIndex];
// 去重:通过 background.js 存储(同步返回,不依赖异步 storage)
const merged = { ...item, ...detail };
try {
const stored = await new Promise(resolve => {
chrome.runtime.sendMessage({
type: 'storeDetail',
record: merged,
customerId: detail.customerId || '',
createTime: item.createTime || '',
dedup: state.dedup,
}, resp => resolve(resp));
});
} catch (e) {
// background 不可用时回退到本地存储
if (state.dedup && detail.customerId) {
const existing = state.customerIdMap[detail.customerId];
if (!existing || item.createTime >= (existing.createTime || '')) {
state.customerIdMap[detail.customerId] = merged;
}
} else {
state.detailData.push(merged);
}
}
// 推送到后端(Plan 2):发 record + messages 给 SW 入队。fire-and-forget,
// 失败不影响本地抓取流程;SW 会根据 push_config.enabled 决定是否真正入队。
try {
chrome.runtime.sendMessage(
{ type: 'enqueue', record: merged, messages: detail.chatMessages || [] },
() => { /* 最后错误仅诊断用 */ void chrome.runtime.lastError; }
);
} catch (e) { /* SW 不可用时静默 */ }
state.queueIndex++;
progress(state.queueIndex, state.detailQueue.length);
await saveState();
if ((state.queueIndex) % 10 === 0) {
log(`详情进度: ${state.queueIndex}/${state.detailQueue.length}`, '');
}
if (state.phase === 'paused') return;
// 继续下一条
if (state.queueIndex < state.detailQueue.length) {
// 先回列表页再进详情页(更稳定)
// 直接跳转下一条详情页
const nextItem = state.detailQueue[state.queueIndex];
const nextUrl = nextItem.dataHref.startsWith('http') ? nextItem.dataHref :
'https://message.alibaba.com' + nextItem.dataHref;
// 额外延迟确保 storage 写入完成
await randSleep(800, 2000);
window.location.href = nextUrl;
} else {
finishAll();
}
} catch (e) {
// 安全层 #6:RISK: 前缀错误 → 触发风控报警并暂停(不跳过,等用户处理后恢复)
if (e && typeof e.message === 'string' && e.message.startsWith('RISK:')) {
state.phase = 'paused';
await saveState();
log('⚠️ 疑似触发风控,已暂停: ' + e.message, 'error');
sendToPopup('risk', { msg: e.message });
return;
}
// SKIP: 对方账号注销/管控 → 友好跳过下一条,不算异常、不暂停
if (e && typeof e.message === 'string' && e.message.startsWith('SKIP:')) {
const it = state.detailQueue[state.queueIndex];
log(`对方账号不可用(${e.message.slice(5)}),跳过本询盘 ${it?.inquiryId || ''}`, 'info');
state.queueIndex++;
await saveState();
await randSleep(800, 2000);
runDetailPhase();
return;
}
log('详情抓取异常: ' + e.message, 'error');
// 跳过这条继续
state.queueIndex++;
await saveState();
await randSleep(800, 2000);
runDetailPhase();
}
}
// ============ API 直拉聊天(替代 wheel:秒级拉全量,能取到 wheel 取不到的早期历史)============
// 取 cookie 值,两层:① 顶层 cookie(标准,值在 ^ 或 ; 边界之后,遇 ; 截断);
// ② 兜底——有的 token 不是顶层 cookie,而是嵌在别的 cookie 的 query-string 值里
// (ctoken 就藏在 xman_us_t 的 "l_source=..&ctoken=xxx&.." 值中),故再按 name= 全局取、遇 & 截断。
function getCookie(name) {
const dc = document.cookie;
let m = dc.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]+)'));
if (m) return m[1];
m = dc.match(new RegExp(name + '=([^;&]+)'));
return m ? m[1] : '';
}
function decodeHtml(s) {
// textarea 是 RCDATA 元素,只解码字符引用(实体)不解析标签,能正确处理 &aacute; &ntilde; &#39; 等所有命名/数字实体
const t = document.createElement('textarea');
t.innerHTML = String(s == null ? '' : s);
return t.value || '';
}
// 卡片消息(询盘/目录,type=6/2008)的 API content 是 workspace.alibaba.com/card URL。
// 阿里前端会调内部 API 把它渲染成卡片正文(询盘标题/采购数量/详细需求 等)放进 .session-rich-content。
// API 拿不到正文,所以详情页 DOM 渲染后,按 data-original(URL)对应回 wrapper,抓 .session-rich-content 正文。
function enrichCardFromDom(m) {
const c = m.content || '';
if (!/workspace\.alibaba\.com\/card\?type=/.test(c)) return;
const ext = (c.match(/[?&]extensionType=([^&]+)/) || [])[1] || '';
// 图片卡片(type=13)不走 DOM enrich:实测卡片 URL 的 secOperateAliId 也能构造可加载的 clouddisk 图片直链,
// 由 apiMsgToChat 直接构造,不依赖 DOM 渲染(避免历史图片懒加载 → [图片])
if (/^(jpg|jpeg|png|gif|webp|bmp)$/i.test(ext)) return;
if (/type=14\b/.test(c)) return; // 视频卡片:apiMsgToChat 从卡片 URL 构造 clouddisk,不依赖 DOM 懒加载
try {
for (const w of document.querySelectorAll('.message-item-wrapper')) {
if ((w.getAttribute('data-original') || '') === c) {
// 询盘/目录卡片:.session-rich-content 正文(标题/采购/需求)
const card = w.querySelector('.session-rich-content');
const text = card ? card.innerText.trim() : '';
if (text) m.content = text;
return;
}
}
} catch (e) { /* DOM 不可用则保留 URL,apiMsgToChat 兜底标记 */ }
}
function apiMsgToChat(m) {
const direction = m.messageType === 'send' ? 'outgoing' : (m.messageType === 'rec' ? 'incoming' : 'system');
let content = decodeHtml(m.content || '');
if (/^<img/i.test(content)) {
// 图片/表情:提取 src 存为 img:url(前端渲染 <img>,白名单 alibaba 域防 XSS)
const src = (content.match(/src=["']([^"']+)["']/i) || [])[1] || '';
content = src ? ('img:' + src) : '[图片]';
}
else if (/extensionType=/.test(content) && /workspace\.alibaba\.com\/card\b/.test(content)) {
const ext = (content.match(/[?&]extensionType=([^&]+)/) || [])[1] || '';
if (/^(jpg|jpeg|png|gif|webp|bmp)$/i.test(ext)) {
// 图片卡片(type=13):从卡片 URL 构造 clouddisk 图片直链(实测卡片 secOperateAliId 也能加载,
// 不依赖 DOM 懒加载,避免历史图片显示 [图片])
const fid = (content.match(/[?&]id=([^&]+)/) || [])[1] || '';
const fparent = (content.match(/[?&]parentId=([^&]+)/) || [])[1] || '';
const fsec = (content.match(/secOperateAliId=([^&]+)/) || [])[1] || '';
content = (fid && fparent && fsec)
? ('img:https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=OneChat&id=' + fid + '&parentId=' + fparent + '&secOperateAliId=' + fsec + '&fileAction=imagePreview')
: '[图片]';
} else if (/type=14\b/.test(content)) {
// 视频卡片(type=14):从卡片 URL 构造 clouddisk 视频直链(同图片,卡片 secOperateAliId 有效)
const fid = (content.match(/[?&]id=([^&]+)/) || [])[1] || '';
const fparent = (content.match(/[?&]parentId=([^&]+)/) || [])[1] || '';
const fsec = (content.match(/secOperateAliId=([^&]+)/) || [])[1] || '';
const fact = (content.match(/[?&]fileAction=([^&]+)/) || [])[1] || 'download';
content = (fid && fparent && fsec)
? ('video:https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=OneChat&id=' + fid + '&parentId=' + fparent + '&secOperateAliId=' + fsec + '&fileAction=' + fact)
: '[视频]';
} else {
// 文件卡片(PDF/Word/Excel):提取 clouddisk 直链(预览/下载用),用卡片 URL 打不开(需 alibaba 登录态)
const name = (content.match(/[?&]name=([^&]+)/) || [])[1] || '';
let fileName = name ? decodeURIComponent(name).replace(/\+/g, ' ') : '';
if (!fileName) fileName = ext ? ext.toUpperCase() + ' 文件' : '文件';
// 真实文件 URL 在 url= 参数里(clouddisk redirectFileUrl,含 secOperateAliId + fileAction)
const clouddisk = (content.match(/url=(https:\/\/clouddisk\.alibaba\.com\/file\/redirectFileUrl\.htm\?[^]*?fileAction=\w+)/) || [])[1] || '';
content = 'file:' + fileName + '|' + (clouddisk || content);
}
}
else if (/workspace\.alibaba\.com\/card\?type=/.test(content)) {
// 询盘/目录等卡片(API content 是 URL)。优先由 enrichCardFromDom 用 DOM 正文替换;
// 若 DOM 没抓到(回退场景),按 type 标记,避免把整段 URL 当文本显示。
const type = (content.match(/type=(\d+)/) || [])[1] || '';
content = type === '6' ? '[询盘卡片]' : (type === '2008' ? '[目录卡片]' : '[卡片消息]');
}
else if (/<[^>]+>/.test(content)) {
const t = content.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').trim();
content = t || '[卡片消息]';
}
// sendTime:API 返回毫秒数 → ISO 字符串(payload.js 直接消费)
const sendTime = m.sendTime ? new Date(m.sendTime).toISOString() : null;
// translation:从 m.extInfo.icbuData.extend(JSON 字符串)双重 parse 取
// extend.extParams.inputTranslateInfo.translate;任何步骤失败留空,不抛
let translation = '';
try {
const ext = m.extInfo && m.extInfo.icbuData && m.extInfo.icbuData.extend;
if (ext) {
const e1 = JSON.parse(ext);
const info = e1 && e1.extParams && e1.extParams.inputTranslateInfo;
if (info) {
const e2 = JSON.parse(info);
translation = (e2 && e2.translate) || '';
}
}
} catch (e) { /* translation 缺失不致命 */ }
// owner 固定是卖家(己方)、contact 固定是买家(对方),方向由 messageType 决定
const sender = direction === 'outgoing'
? ((m.owner && m.owner.name) || '卖家')
: ((m.contact && m.contact.name) || '买家');
return {
messageId: String(m.messageId),
sendTime,
sender,
original: content,
translation,
direction,
};
}
async function fetchChatByApi() {
// 注意:本函数内的 RISK: 前缀错误必须向上抛出(不能被这里的 catch 吞掉回退 DOM),
// 由 processCurrentDetailPage 的 catch 捕获并触发风控暂停报警。
// 其它普通异常(网络/解析/结构不完整)仍回退到 DOM 滚动。
try {
// URL hash 末尾的 secTradeId token(列表 dataHref 自带)
const hashMatch = location.hash.match(/(MC1IDX1[\w-]+)\/?\s*$/);
if (!hashMatch) { log('聊天 API 跳过: URL hash 无 secTradeId token,回退 DOM 滚动', 'info'); return null; }
const hashSecTradeId = hashMatch[1];
const ctoken = getCookie('ctoken'), tbToken = getCookie('_tb_token_'), xsrf = getCookie('XSRF-TOKEN');
if (!ctoken || !tbToken || !xsrf) { log('聊天 API 跳过: 缺 cookie(ctoken/_tb_token_/XSRF-TOKEN),回退 DOM', 'info'); return null; }
// 安全层 #5:postForm 加风控检测 —— HTTP 非 200 / 关键词 / 结构异常 → throw 'RISK:*'
// ★ 绝不扫描 data 内的消息正文:getOpMessages 的 data.list[].content 是真实聊天,
// 客户消息含 "captcha/blocked/验证码/风控" 是正常外贸对话。旧实现对整段 text 扫关键词,
// 会把正常聊天误判为风控 → 整任务误暂停(命中任一历史消息即停)。
// 现分两种响应形态分别检测:
// ① 非 JSON(HTML 验证页/滑块/登录页):整段 text 扫关键词(这才是风控页特征)
// ② JSON:只浅扫顶层 msg/message/errMsg/statue 等风控字段(data 是对象会被跳过,不进 list)
const RISK_TOP_FIELDS = new Set(['msg', 'message', 'errmsg', 'statue', 'errormessage', 'err']);
const hitsRiskKeyword = s => {
const lower = String(s == null ? '' : s).toLowerCase();
return RISK_KEYWORDS.find(kw => lower.includes(String(kw).toLowerCase()));
};
const postForm = async (url, params) => {
const resp = await fetch(url, {
method: 'POST', body: new URLSearchParams(params), credentials: 'include',
headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8' }
});
if (resp.status !== 200) throw new Error('RISK:HTTP(' + resp.status + ')');
const text = await resp.text();
let j;
try { j = JSON.parse(text); }
catch (e) {
// 非 JSON(HTML 风控/登录页)才扫整段 text
const kw = hitsRiskKeyword(text);
if (kw) throw new Error('RISK:关键词(' + kw + ')');
throw new Error('RISK:结构异常(non-JSON)');
}
// JSON:只浅扫顶层字符串类型的风控字段,不递进 data
if (j && typeof j === 'object') {
for (const f of Object.keys(j)) {
if (RISK_TOP_FIELDS.has(f.toLowerCase()) && typeof j[f] === 'string') {
const kw = hitsRiskKeyword(j[f]);
if (kw) throw new Error('RISK:关键词(' + kw + '@' + f + ')');
}
}
}
return j;
};
// 1) querySummary 拿加密会话参数(数据不完整视为「API 不可用」,回退 DOM,不算风控)
const qs = await postForm(
`https://message.alibaba.com/message/ajax/feedback/querySummary.htm?ctoken=${encodeURIComponent(ctoken)}&_tb_token_=${encodeURIComponent(tbToken)}`,
{ _csrf_token_: xsrf, params: JSON.stringify({ secTradeId: hashSecTradeId }) }
);
const d = qs && qs.data;
if (!d || !d.owner || !d.contact || !d.owner.accountIdEncrypt || !d.contact.accountIdEncrypt || !d.secTradeId) { log('聊天 API 跳过: querySummary 返回数据不完整,回退 DOM', 'info'); return null; }
// meta:从 querySummary 提取会话级时间(毫秒)→ ISO,挂到 detail 供 payload.js 用作
// createTime/updateTime 优先值(优于列表页的本地时分显示)
const meta = {
gmtCreate: d.gmtCreate ? new Date(d.gmtCreate).toISOString() : null,
gmtModified: d.gmtModified ? new Date(d.gmtModified).toISOString() : null,
};
const secOwnerAccountId = d.owner.accountIdEncrypt;
const secTargetAccountId = d.contact.accountIdEncrypt;
const secTradeId = d.secTradeId;
const targetAliId = d.cid ? String(d.cid).split('-')[0] : '';
// 安全层 #1:增量抓取。按 inquiryId 读上次最后一条(最新)messageId。
// 命中则提前停止翻页,只抓新增;首轮(无记录)→ 全量。
const itForId = state.detailQueue[state.queueIndex];
const inquiryId = itForId ? itForId.inquiryId : '';
const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil); // 时间范围毫秒(§4.2);null=不限
const lastTopWm = await getWatermark(inquiryId);
const lastTopId = lastTopWm ? lastTopWm.id : '';
if (lastTopId) log(`聊天 API 增量模式: 上次水位 ${lastTopId},遇此停止翻页`);
if (sinceMs != null || untilMs != null) {
log(`聊天范围: ${sinceMs != null ? new Date(sinceMs).toLocaleString() : '最早'} ~ ${untilMs != null ? new Date(untilMs).toLocaleString() : '至今'}`);
}
// 2) 循环 getOpMessages(spec 附录A:跳游标/probe/过滤/顺序/停止/水位)
const jump = untilMs != null && !state.cursorJumpUnsupported;
let cursor = jump ? untilMs + 1 : Date.now(); // +1ms:实测语义=严格小于,恰好覆盖 <= untilMs(附录B)
let probeRound = jump;
let probeDowngraded = false; // 局部瞬态:probe 降级过(仅本调用内有效 —— 不放 state,防止中途退出污染下个询盘)
let downgradeKind = null; // 降级原因:'fail'=结构异常 / 'empty'=空 list(多为真空,附录B组形态)
const raw = [];
const MAX_ROUNDS = state.chatLimit > 0 ? Math.ceil(state.chatLimit / 20) : 2000;
let candId = null, candSendTime = null; // 候选水位:逐轮取最大 sendTime(spec §4.5)
let stopReason = '轮数上限';
for (let i = 0; i < MAX_ROUNDS; i++) {
if (state.phase === 'paused') return null;
if (!await waitUntilWorkTime('聊天翻页')) return null;
if (state.phase === 'paused') return null;
const json = await postForm(
`https://onetalk.alibaba.com/message/getOpMessages.htm?ctoken=${encodeURIComponent(ctoken)}&_tb_token_=${encodeURIComponent(tbToken)}`,
{ params: JSON.stringify({ scene: '', timeSlide: { timeStamp: cursor, forward: false, pageSize: 20 }, secOwnerAccountId, secTradeId, secTargetAccountId, openRealTimeTranslation: true, targetAliId }), _csrf: xsrf }
);
if (probeRound) { // 首轮探测:仅降级集走 fallback(真风控由 postForm 抛出,spec §4.3)
probeRound = false;
const kind = probeFailKind(json);
if (kind) {
log(`until 跳游标首轮 ${kind === 'empty' ? '空 list' : '结构异常'},降级 cursor=now 重试`);
probeDowngraded = true;
downgradeKind = kind;
cursor = Date.now();
await randSleep(2000, 5000);
continue;
}
} else {
if (!json || typeof json !== 'object') throw new Error('RISK:结构异常(非对象)');
if (json.code !== undefined && Number(json.code) !== 200) throw new Error('RISK:结构异常(code=' + json.code + ')');
if (json.data == null) throw new Error('RISK:结构异常(data=null)');
}
const list = (json.data && json.data.list) || [];
if (!list.length) {
// 降级重试也空 = 真空,不设 unsupported(probeDowngraded 为局部变量,随调用结束自然消亡)
stopReason = '到顶(空列表)'; break;
}
// 仅 'fail'(结构异常)且降级重试首批非空才实锤"不支持";'empty' 多为会话在 until 前本无消息
// (真空,附录B组形态),据此设标志会把跳游标误禁一整个任务(final review Important#2 收紧)
if (probeDowngraded && downgradeKind === 'fail') {
probeDowngraded = false;
state.cursorJumpUnsupported = true; saveState();
}
for (const m of list) { // 候选水位逐轮 max(未过滤口径,spec §4.5)
if (m.sendTime != null && (candSendTime == null || m.sendTime > candSendTime)) {
candId = String(m.messageId); candSendTime = m.sendTime;
}
}
let hitLastTop = false;
for (const m of list) { // 顺序:水位命中查未过滤 list,再范围过滤(spec §4.3.2)
if (lastTopId && String(m.messageId) === lastTopId) { hitLastTop = true; break; }
if (inChatRange(m.sendTime, sinceMs, untilMs)) raw.push(m);
}
let minT = Infinity;
for (const m of list) if (m.sendTime != null && m.sendTime < minT) minT = m.sendTime; // null 跳过(防游标跳1970)
if (minT !== Infinity) cursor = minT;
if (raw.length % 400 < 20) log(`聊天 API 拉取中: ${raw.length} 条`);
if (hitLastTop) { stopReason = '增量命中'; break; }
if (sinceMs != null && cursor < sinceMs) { stopReason = '时间边界'; break; }
if (list.length < 20) { stopReason = '到顶'; break; }
await randSleep(2000, 5000);
}
log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`, 'success');
await trySetWatermark(inquiryId, candId, candSendTime); // 只前进写入(spec §4.5)
return { messages: raw.map(m => { enrichCardFromDom(m); return apiMsgToChat(m); }), meta };
} catch (e) {
// RISK: 前缀错误向上抛,不吞掉 —— 让 processCurrentDetailPage 触发风控暂停报警
if (e && typeof e.message === 'string' && e.message.startsWith('RISK:')) throw e;
log('API 拉取聊天失败,回退 DOM: ' + e.message, 'error');
return null;
}
}
// ============ DOM 聊天消息提取(可复用) ============
// 从 .message-item-wrapper 抓消息;sendTime 取法与原 scrapeDetailData 内联逻辑完全一致
// (item-base-info 下匹配 \d{4}-\d{2}-\d{2} 的 span),供 scrapeDetailData 和 loadAllChatHistory 增量分支共用。
function extractDomChatMessages() {
const wrappers = document.querySelectorAll('.message-item-wrapper');
const msgs = [];
for (const mw of wrappers) {
const isR = mw.className.includes('item-right');
const isL = mw.className.includes('item-left');
const ne = mw.querySelector('.name');
const sender = ne ? ne.innerText.trim() : (isR ? '卖家' : '');
const te = mw.querySelector('.item-base-info');
let time = '';
if (te) {
for (const sp of te.querySelectorAll('span')) {
if (sp.innerText.match(/\d{4}-\d{2}-\d{2}/)) { time = sp.innerText.trim(); break; }
}
}
// 原文:data-original 实际在 wrapper 自身属性上,其次子元素,最后 rich-content 兜底
let orig = mw.getAttribute('data-original') || '';
if (!orig) {
const oe = mw.querySelector('[data-original]');
orig = oe ? (oe.getAttribute('data-original') || '') : '';
}
if (!orig) {
const richEl = mw.querySelector('.session-rich-content');
orig = richEl ? richEl.innerText.trim() : '';
}
const trEl = mw.querySelector('.target-content');
const trans = trEl ? trEl.innerText.trim() : '';
if (orig || time) {
msgs.push({
messageId: mw.getAttribute('data-message-id') || `dom-${wrappers.length}-${msgs.length}`,
sendTime: time ? new Date(time.replace(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/, '$1T$2')).toISOString() : new Date().toISOString(),
// domTimed:是否有真实 DOM 时间戳(时间戳稀疏渲染,连续消息常无自己的时间,sendTime 为伪造 now)。
// 范围过滤对无真实时间的消息保留(宁多勿漏),否则 untilMs 模式会系统性丢弃它们
domTimed: !!time,
sender, original: orig, translation: trans, direction: isR ? 'outgoing' : (isL ? 'incoming' : 'system'),
});
}
}
return msgs;
}
// ============ 加载全部聊天历史(DOM wheel 回退方案,API 失败时使用)============
async function loadAllChatHistory() {
const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil); // 时间范围毫秒(§4.2);null=不限
// 增量分支:有 lastDomTime(非首次)→ 页面默认已显示最新 N 条(含新增),
// 只从 DOM 抓 sendTime > lastDomTime 的新增消息,推入 detail.chatMessages(经 state._incDomMessages
// 暂存,scrapeDetailData 消费),不往旧滚,直接结束。
const it = state.detailQueue[state.queueIndex];
const inquiryId = it ? it.inquiryId : '';
if (inquiryId) {
const lastDomTime = await getLastDomTime(inquiryId);
if (lastDomTime) {
const all = extractDomChatMessages();
const inc = all.filter(m => m.sendTime && m.sendTime > lastDomTime)
.filter(m => !m.domTimed || inChatRange(Date.parse(m.sendTime), sinceMs, untilMs)); // 无真实时间戳的保留(宁多勿漏)
log(`DOM 增量:抓到 ${inc.length} 条新增(sendTime > 上次)`);
if (inc.length) {
state._incDomMessages = inc;
// 更新 lastDomTime = 本批最新 sendTime(max)
let maxT = lastDomTime;
for (const m of inc) if (m.sendTime > maxT) maxT = m.sendTime;
await setLastDomTime(inquiryId, maxT);
} else if (sinceMs != null || untilMs != null) {
// 范围模式下 0 新增也是决定性结果(无新的范围内消息),防止消费点穿透到未过滤全量提取(同全量分支语义)
state._incDomMessages = [];
state._incDomFiltered = true;
}
return; // 不往旧滚,只增量
}
}
// === 全量分支(首次,无 lastDomTime):走原 wheel 滚动逻辑 ===
// 阿里消息历史通过“向上滚动”懒加载(wheel 事件触发),不是按钮。
// 注意:.ui-header-message-box-list-more(“查看更多>”)是跳转到旧版消息中心(atmgateway)的链接,
// 点击会打开新标签页,绝不能当作“加载历史”按钮。
const container = document.querySelector('.common-load-more');
if (!container) {
log('未找到聊天滚动容器 .common-load-more,跳过历史加载', 'info');
return;
}
const MAX_ROUNDS = state.chatLimit > 0 ? Math.ceil(state.chatLimit / 20) : 300; // chatLimit 来自 popup;0=全量(300 轮≈6000 条)
// 阿里历史加载存在“平台期”(连续多轮不变后又突增),STABLE_LIMIT 必须 ≥10 才不会误判截断。
// 实测 13776617920 加载到 2635+ 条仍未到顶,旧值 3 会严重截断。
// 安全层 #2:STABLE_LIMIT 12 → 18,容忍更长的平台期。
const STABLE_LIMIT = 18; // 连续 N 轮不变才认定到顶
let prevCount = document.querySelectorAll('.message-item-wrapper').length;
let stable = 0;
let lastLog = prevCount;
for (let i = 0; i < MAX_ROUNDS; i++) {
if (state.phase === 'paused') return; // 支持暂停中断
container.scrollTop = 0;
container.dispatchEvent(new WheelEvent('wheel', { deltaY: -500, bubbles: true, cancelable: true }));
// 安全层 #2:每次滚动 2-6 秒大范围随机,替代原 500-1200ms
await randSleep(2000, 6000);
// 安全层 #2:每 15 次滚动插一个超长停顿(30-90 秒,模拟走神)
if ((i + 1) % 15 === 0) {
log('聊天历史滚动: 超长停顿(模拟走神)');
await randSleep(30000, 90000);
}
// DOM 时间边界:顶部向下找第一个带时间 span 的 wrapper(时间戳稀疏渲染,非每条都有),早于 since → 停滚(spec §4.4)
if (sinceMs != null) {
let oldestT = null;
for (const mw of document.querySelectorAll('.message-item-wrapper')) {
const te = mw.querySelector('.item-base-info');
let hit = false;
if (te) for (const sp of te.querySelectorAll('span')) {
const m = sp.innerText.match(/(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})/);
if (m) { oldestT = new Date(m[1] + 'T' + m[2]).getTime(); hit = true; break; }
}
if (hit) break;
}
if (oldestT != null && oldestT < sinceMs) { log('DOM 时间边界,停止滚动'); break; }
}
const newCount = document.querySelectorAll('.message-item-wrapper').length;
if (newCount === prevCount) {
stable++;
if (stable >= STABLE_LIMIT) break;
} else {
stable = 0;
prevCount = newCount;
}
if (newCount - lastLog >= 100) {
log(`聊天历史加载中: ${newCount} 条`);
lastLog = newCount;
}
}
log(`聊天记录加载完成: ${prevCount} 条`);
// 全量抓完后:水位用未过滤集合的 max(水位=见到的最新);挂载给消费方的是过滤后的集合
if (inquiryId) {
const all = extractDomChatMessages();
let maxT = null;
for (const m of all) if (m.sendTime && (!maxT || m.sendTime > maxT)) maxT = m.sendTime;
if (maxT) await setLastDomTime(inquiryId, maxT);
state._incDomMessages = all.filter(m => !m.domTimed || inChatRange(Date.parse(m.sendTime), sinceMs, untilMs)); // 无真实时间戳的保留(宁多勿漏)
state._incDomFiltered = (sinceMs != null || untilMs != null); // 空数组也具决定性(防消费点穿透到未过滤全量)
}
}
// 等待 body 文本出现指定内容(用于等异步加载的行为卡字段),超时返回 false
function waitForText(text, timeout = 5000) {
const start = Date.now();
return new Promise(resolve => {
(function check() {
if (document.body.innerText.includes(text)) return resolve(true);
if (Date.now() - start > timeout) return resolve(false);
setTimeout(check, 200);
})();
});
}
// ============ 详情页数据提取 ============
function scrapeDetailData(apiMsgs, domHandoff = null, domFiltered = false) {
const data = {};
const bodyText = document.body.innerText;
// customerId 提取已抽到 extractCustomerId()(processCurrentDetailPage 也用它做同客户聊天去重)
data.customerId = extractCustomerId();
// 买家信息
const nt = document.querySelector('.name-text');
data.detailBuyerName = nt ? nt.innerText.trim() : '';
const cl = document.querySelector('.country-flag-label');
data.detailCountry = cl ? cl.innerText.trim() : '';
const av = document.querySelector('.alicrm-customer-detail-card-header-avatar');
data.detailAvatarUrl = av ? av.getAttribute('src') : '';
// 基本信息(需先在 processCurrentDetailPhase 展开折叠面板,才能看到手机/座机/商业类型/销售平台/公司官网)
const ci = document.querySelector('.contact-base-info');
if (ci) {
const t = ci.innerText || '';
const cleanV = v => { const x = (v || '').trim(); return (x === '' || x === '-') ? '' : x; };
data.company = cleanV((t.match(/公司名称\s*\n?\s*([^\n]+)/) || [])[1]);
data.email = cleanV((t.match(/邮箱\s*\n?\s*([^\n]+)/) || [])[1]);
data.registerTime = cleanV((t.match(/注册时间\s*\n?\s*([^\n]+)/) || [])[1]);
data.phone = cleanV((t.match(/手机\s*\n?\s*([+\d][\d\s\-()]{4,30})/) || [])[1]);
data.landline = cleanV((t.match(/座机\s*\n?\s*([+\d][\d\s\-()]{4,30})/) || [])[1]);
data.businessType = cleanV((t.match(/商业类型\s*\n?\s*([^\n]+)/) || [])[1]);
data.salesPlatform = cleanV((t.match(/销售平台\s*\n?\s*([^\n]+)/) || [])[1]);
data.website = cleanV((t.match(/公司官网\s*\n?\s*([^\n]+)/) || [])[1]);
}
// 标签
const tagList = ['流失预警', '高潜复购', '复购客户', 'TM沟通', '新客户', '大客户', '高价值客户', '待跟进'];
data.buyerTags = tagList.filter(t => bodyText.includes(t)).join(', ');
// 特征(按真实阿里国际站买家特征词表更新)
const featList = ['工厂偏好', '物流商偏好', '轻定制偏好', 'logo定制偏好', '其他沟通工具偏好', '货代服务偏好',
'付款方式偏好', '组货方式偏好', '高质量偏好', '新品偏好', '原装偏好', '商品价格敏感'];
data.buyerFeatures = featList.filter(f => bodyText.includes(f)).join(', ');
// 行为数据
const b = {};
// alibaba 行为卡 DOM 顺序是「数字 + 标签」(如 "318 产品浏览数"),
// 正则按「数字 + 标签」匹配,否则会错位抓到下一个指标的值。
b.productViews = (bodyText.match(/(\d+)\s*\n?\s*产品浏览数/) || [])[1] || '';
b.validInquiries = (bodyText.match(/(\d+)\s*\n?\s*有效询盘/) || [])[1] || '';
b.validRFQ = (bodyText.match(/(\d+)\s*\n?\s*有效RFQ数/) || [])[1] || '';
b.loginDays = (bodyText.match(/(\d+)\s*\n?\s*登录天数/) || [])[1] || '';
b.spamInquiries = (bodyText.match(/(\d+)\s*\n?\s*垃圾询盘数/) || [])[1] || '';
b.blacklistCount = (bodyText.match(/(\d+)\s*\n?\s*被加为黑名单数/) || [])[1] || '';
b.topIndustry = (bodyText.match(/最常采购行业\s*\n?\s*(.+?)(?:\n|$)/) || [])[1] || '';
data.behavior = b;
// 最近询盘产品(产品图片 URL 列表,alibaba「最近询盘产品」区是图片)
const recentImgs = document.querySelectorAll('.alicrm-customer-behavioral-card-recent-inquries img');
data.recentInquiryProducts = Array.from(recentImgs).map(i => i.src).filter(Boolean);
// 店内行为(店铺内:询盘产品名+数量 + 浏览产品图/链接)。部分询盘才有这个板块。
const minisite = document.querySelector('.alicrm-customer-behavioral-card-minisite-behavior');
if (minisite) {
const inq = minisite.querySelector('.alicrm-customer-behavioral-card-minisite-behavior-inquiry');
const inqText = inq ? inq.innerText : '';
const pm = inqText.match(/询盘产品\s*([\s\S]*?)\s*×\s*\d/);
data.storeInquiryProduct = pm ? pm[1].trim().replace(/\n+/g, ' ') : '';
const qm = inqText.match(/×\s*(\d+)/);
data.storeInquiryQty = qm ? qm[1] : '';
const vis = minisite.querySelector('.alicrm-customer-behavioral-card-minisite-behavior-visit');
data.storeVisitProducts = [];
if (vis) {
for (const vp of vis.querySelectorAll('.visit-product-container')) {
const img = vp.querySelector('img');
const a = vp.querySelector('a[href]');
let href = a ? (a.getAttribute('href') || '') : '';
if (href.startsWith('//')) href = 'https:' + href;
const pid = href.match(/_(\d+)\.html/);
data.storeVisitProducts.push({ img: img ? img.src : '', url: href, productId: pid ? pid[1] : '' });
}
}
}
// 聊天记录(优先 API 结果,回退 DOM 抓取)
if (state.chat) {
let msgs;
if (state.chatSkipped) {
// 同客户去重跳过:不抓 chat(后端已归代表),避免 DOM fallback 抓出重复的 dom- 消息
msgs = [];
state.chatSkipped = false;
} else {
// loadAllChatHistory 的 DOM handoff 由调用方(processCurrentDetailPage)在重试循环外
// 取出并传入(避免重试调用清空后落穿未过滤提取)。优先级:apiMsgs > 增量暂存 > 全量 DOM 抓取。
const incMsgs = domHandoff;
const incFiltered = domFiltered; // 空数组+过滤标记 → 空也有效(范围内0条,spec §4.4 降级)
// apiMsgs != null 表示 API 成功(含增量命中返回空数组 []=无新增);只有 API 失败(apiMsgs=null)才回退 DOM。
msgs = apiMsgs != null ? apiMsgs : null;
if (!msgs) msgs = (incMsgs && (incMsgs.length || incFiltered)) ? incMsgs : null;
if (!msgs) msgs = extractDomChatMessages();
}
data.chatCount = msgs.length;
data.chatMessages = msgs;
data.chatText = msgs.map(m => JSON.stringify(m)).join('|||');
}
return data;
}
// 等待详情页加载
function waitForLoad() {
return new Promise(resolve => {
let tries = 0;
const t = setInterval(() => {
tries++;
const txt = document.body.innerText || '';
if (document.querySelector('a.customer-detail-link') ||
document.querySelector('.contact-base-info') ||
document.querySelector('.message-item-wrapper') ||
hitsSkipKeyword(txt) || tries > 60) { // 30s,也认账号注销/管控提示 → 快速放行给 SKIP 检测
clearInterval(t);
resolve();
}
}, 500);
});
}
// ============ 完成 ============
async function finishAll() {
// 从 background.js 获取所有已存储的详情数据
try {
const bgResult = await new Promise(resolve => {
chrome.runtime.sendMessage({ type: 'getBgData' }, resp => resolve(resp));
});
if (bgResult && bgResult.records) {
state.detailData = bgResult.records;
} else {
// 回退:用本地数据
if (state.dedup) {
state.detailData = Object.values(state.customerIdMap);
}
}
} catch (e) {
if (state.dedup) {
state.detailData = Object.values(state.customerIdMap);
}
}
log(`最终 ${state.detailData.length} 条详情数据(原始 ${state.listData.length} 条)`, 'success');
state.phase = 'done';
await saveState();
await randSleep(800, 2000);
log('全部抓取完成!', 'success');
sendToPopup('done', {});
window.location.href = LIST_URL;
}
// ============ CSV 导出 ============
function buildCSV(data, mode) {
const rows = data || state.detailData;
if (!rows.length) return '';
const m = mode || state.mode;
// 仅 detail 模式且记录含详情字段时才输出详情列,避免列表模式多出 17 个空列
const hasDetail = m === 'detail' && rows.some(r => 'customerId' in r || 'detailBuyerName' in r);
const headers = [
'询价单号', '创建时间', '更新时间', '买家名称', '国家代码',
'消息预览', '负责人', '状态', '是否标记',
];
if (hasDetail) {
headers.push(
'customerId', '详情买家名', '详情国家', '头像URL',
'公司名称', '邮箱', '注册时间', '买家标签', '买家特征',
'产品浏览数', '有效询盘', '有效RFQ', '登录天数',
'垃圾询盘数', '最常采购行业', '聊天消息数', '聊天记录'
);
}
const esc = v => {
if (v == null) return '';
const s = String(v);
return (s.includes(',') || s.includes('"') || s.includes('\n'))
? '"' + s.replace(/"/g, '""') + '"'
: s;
};
let csv = headers.map(esc).join(',') + '\n';
for (const row of rows) {
const line = [
row.inquiryId, row.createTime, row.updateTime, row.buyerName,
row.countryCode, row.msgPreview, row.manager, row.status,
row.isFlagged ? '是' : '否',
];
if (hasDetail) {
const b = row.behavior || {};
line.push(
row.customerId || '', row.detailBuyerName || '', row.detailCountry || '',
row.detailAvatarUrl || '', row.company || '', row.email || '',
row.registerTime || '', row.buyerTags || '', row.buyerFeatures || '',
b.productViews || '', b.validInquiries || '', b.validRFQ || '',
b.loginDays || '', b.spamInquiries || '', b.topIndustry || '',
row.chatCount || '', row.chatText || ''
);
}
csv += line.map(esc).join(',') + '\n';
}
return csv;
}
// ============ 初始化:页面加载后判断当前阶段 ============
await loadState();
const pageType = isDetailPage() ? '详情页' : '列表页';
log(`插件已就绪 (${pageType}),当前阶段: ${state.phase}`, 'info');
if (state.phase === 'detail' && isDetailPage()) {
// 上次正在抓详情页,页面导航后恢复
await randSleep(1500, 3000); // 等页面完全加载
processCurrentDetailPage();
} else if (state.phase === 'detail' && !isDetailPage()) {
// 在详情阶段但当前在列表页,继续
await randSleep(800, 1800);
runDetailPhase();
} else if (state.phase === 'list') {
// 列表阶段(从详情页点 start 跳转过来后),自动开始,否则会卡死
await randSleep(1200, 3000);
runListPhase();
}
// 其他状态(idle/done/paused)等待用户操作
})();