// 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 (/^,白名单 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(//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)等待用户操作 })();