1302 lines
62 KiB
JavaScript
1302 lines
62 KiB
JavaScript
|
|
// 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(/&/g, '&');
|
|||
|
|
else {
|
|||
|
|
const img = avatarEl.tagName === 'IMG' ? avatarEl : avatarEl.querySelector('img');
|
|||
|
|
if (img) avatarUrl = (img.getAttribute('src') || '').replace(/&/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 元素,只解码字符引用(实体)不解析标签,能正确处理 á ñ ' 等所有命名/数字实体
|
|||
|
|
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)等待用户操作
|
|||
|
|
|
|||
|
|
})();
|