插件
This commit is contained in:
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user