This commit is contained in:
浅念
2026-08-27 16:12:37 +08:00
commit 7f4f3be8d8
35 changed files with 8388 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
// 推送配置读写:存 chrome.storage.local。background 与 worker 共用。
//
// ★ worker 必须**每次自己调 loadConfig()**(无状态),不能把结果缓存到模块级变量 ——
// MV3 Service Worker 休眠重启会清空模块级状态,缓存会导致 worker 永不推送(B 的根因)。
const CONFIG_KEY = 'push_config';
export async function loadConfig() {
const data = await chrome.storage.local.get(CONFIG_KEY);
return data[CONFIG_KEY] || { enabled: false, serverUrl: '', token: '' };
}
export async function saveConfig(config) {
await chrome.storage.local.set({ [CONFIG_KEY]: config || {} });
}
+109
View File
@@ -0,0 +1,109 @@
// Adapter: maps the page-side "record + messages" shape scraped from
// Alibaba's CRM pages to the backend `/api/ingest` zod PayloadSchema.
// Trim undefined/empty/null to null for optional string fields.
const s = v => (v == null || v === '') ? null : v;
// Coerce numeric strings (e.g. "39") to number; otherwise null.
const n = v => {
if (v == null || v === '') return null;
const x = Number(v);
return Number.isFinite(x) ? x : null;
};
// 只接受标准 ISO(YYYY-MM-DDTHH:...);非 ISO(列表页 "11:58"/"2026-7-8")跳过 → 兜底当前时间
// 注:取消聊天时无 gmtCreate,列表时间为非 ISO,这里保证 createTime/updateTime 始终合法 ISO。
// 准确时间待抓聊天时由 gmtCreate 通过 upsert 覆盖。
const isIso = v => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(v);
// 解析 alibaba 列表的非 ISO 时间:
// "2026-7-1"(YYYY-M-D,创建时间)→ 当天 00:00(本地)
// "13:58"(HH:mm,今天的更新时间)→ 今天该时分(本地)
const parseListTime = v => {
if (v == null || v === '') return null;
const s = String(v).trim();
const d = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (d) return new Date(Number(d[1]), Number(d[2]) - 1, Number(d[3])).toISOString();
const hm = s.match(/^(\d{1,2}):(\d{2})$/);
if (hm) {
const n = new Date();
return new Date(n.getFullYear(), n.getMonth(), n.getDate(), Number(hm[1]), Number(hm[2])).toISOString();
}
return null;
};
const isoFallback = (...vals) => {
for (const v of vals) {
if (isIso(v)) return v;
const p = parseListTime(v);
if (p) return p;
}
return new Date(Date.now()).toISOString();
};
export function buildIngestPayload(record, messages) {
// 阶段1:允许 messages 为空(先走通列表+客户+行为,聊天后续补充)
const msgs = messages || [];
const iq = record || {};
const inquiry = {
inquiryId: String(iq.inquiryId),
manager: s(iq.manager) || undefined,
createTime: isoFallback(iq.createTimeFromApi, iq.createTime),
updateTime: isoFallback(
iq.updateTime,
iq.updateTimeFromApi,
iq.createTimeFromApi,
),
status: s(iq.status) || undefined,
msgPreview: s(iq.msgPreview) || undefined,
isFlagged: !!iq.isFlagged,
};
const customer = iq.customerId
? {
customerId: String(iq.customerId),
name: s(iq.detailBuyerName) || s(iq.buyerName),
country: s(iq.detailCountry) || s(iq.countryCode),
email: s(iq.email),
phone: s(iq.phone),
landline: s(iq.landline),
businessType: s(iq.businessType),
salesPlatform: s(iq.salesPlatform),
website: s(iq.website),
company: s(iq.company),
registerTime: s(iq.registerTime),
avatarUrl: s(iq.detailAvatarUrl) || s(iq.avatarUrl) || undefined,
buyerTags: s(iq.buyerTags) || undefined,
buyerFeatures: s(iq.buyerFeatures) || undefined,
recentInquiryProducts: Array.isArray(iq.recentInquiryProducts) ? iq.recentInquiryProducts : undefined,
storeInquiryProduct: s(iq.storeInquiryProduct) || undefined,
storeInquiryQty: s(iq.storeInquiryQty) || undefined,
storeVisitProducts: Array.isArray(iq.storeVisitProducts) ? iq.storeVisitProducts : undefined,
behavior: iq.behavior
? {
productViews: n(iq.behavior.productViews),
validInquiries: n(iq.behavior.validInquiries),
validRfq: n(iq.behavior.validRFQ), // detail 用 validRFQ(大写)
loginDays: n(iq.behavior.loginDays),
spamInquiries: n(iq.behavior.spamInquiries),
blacklistCount: n(iq.behavior.blacklistCount),
topIndustry: s(iq.behavior.topIndustry),
}
: undefined,
}
: null;
const out = {
inquiry,
customer,
messages: msgs.map(m => ({
messageId: String(m.messageId),
sendTime: m.sendTime,
sender: s(m.sender) || undefined,
direction: m.direction,
content: m.original ?? m.content ?? '',
translation: s(m.translation) ?? '',
})),
};
return out;
}
+106
View File
@@ -0,0 +1,106 @@
// IndexedDB-backed push queue for inquiry ingest payloads.
// Stores pending/sent/failed items, supports a "nextRetry" backoff gate so
// the worker only picks up items whose retry window has elapsed.
const DB_NAME = 'inquiry_push';
const STORE = 'queue';
const VERSION = 1;
function openDb() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function store(db, mode) {
return db.transaction(STORE, mode).objectStore(STORE);
}
function p(r) {
return new Promise((res, rej) => {
r.onsuccess = () => res(r.result);
r.onerror = () => rej(r.error);
});
}
function uuid() {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
return String(Date.now()) + '-' + Math.random().toString(36).slice(2);
}
export async function addItem(item) {
const db = await openDb();
try {
const id = uuid();
const rec = {
id,
payload: item.payload,
inquiryId: item.inquiryId,
status: 'pending',
retries: 0,
nextRetry: Date.now(),
createdAt: Date.now(),
};
await p(store(db, 'readwrite').add(rec));
return id;
} finally {
db.close();
}
}
export async function getPending(limit = 5) {
const db = await openDb();
try {
const all = await p(store(db, 'readonly').getAll());
const now = Date.now();
return all
.filter(r => r.status === 'pending' && r.nextRetry <= now)
.sort((a, b) => a.nextRetry - b.nextRetry)
.slice(0, limit);
} finally {
db.close();
}
}
export async function markStatus(id, status, opts = {}) {
const db = await openDb();
try {
const st = store(db, 'readwrite');
const r = await p(st.get(id));
if (!r) return;
Object.assign(r, { status, ...opts });
await p(st.put(r));
} finally {
db.close();
}
}
export async function remove(id) {
const db = await openDb();
try {
await p(store(db, 'readwrite').delete(id));
} finally {
db.close();
}
}
export async function getStats() {
const db = await openDb();
try {
const all = await p(store(db, 'readonly').getAll());
return all.reduce((acc, r) => {
acc[r.status] = (acc[r.status] || 0) + 1;
return acc;
}, {});
} finally {
db.close();
}
}
+58
View File
@@ -0,0 +1,58 @@
// Push queue worker: drains pending items, POSTs to the ingest endpoint,
// removes on success, backs off exponentially on failure, marks failed
// after MAX_RETRIES attempts. Driven by alarms in background.js.
//
// ★ 无状态:每次 processQueue 自己 loadConfig() 从 chrome.storage 读配置,
// 不缓存到模块级变量 —— MV3 SW 休眠重启会清空模块状态,缓存会导致 worker 永不推送(B)。
import { getPending, markStatus } from './queue.js';
import { buildIngestPayload } from './payload.js';
import { loadConfig } from './config.js';
const MAX_RETRIES = 8;
export async function processQueue() {
const cfg = await loadConfig();
if (!cfg || !cfg.enabled || !cfg.serverUrl || !cfg.token) {
return { processed: 0 };
}
const items = await getPending(3);
let processed = 0;
for (const it of items) {
try {
const payload = buildIngestPayload(it.payload.record, it.payload.messages);
const res = await fetch(`${cfg.serverUrl}/api/ingest`, {
method: 'POST',
headers: {
Authorization: `Bearer ${cfg.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
if (res.ok) {
// 标 sent 保留记录供 getStats 统计;旧实现 remove 会让 popup"已推"永远 0(显示 bug)
await markStatus(it.id, 'sent');
processed++;
} else {
await bumpRetry(it, `HTTP ${res.status}`);
}
} catch (e) {
await bumpRetry(it, String((e && e.message) || e));
}
}
return { processed };
}
async function bumpRetry(it, err) {
const retries = (it.retries || 0) + 1;
if (retries >= MAX_RETRIES) {
await markStatus(it.id, 'failed', { retries, lastError: err });
} else {
const delay = Math.min(Math.pow(2, retries) * 1000, 3600 * 1000);
await markStatus(it.id, 'pending', {
retries,
nextRetry: Date.now() + delay,
lastError: err,
});
}
}