插件
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
|
||||||
|
# temporary analysis artifacts
|
||||||
|
*_resp.json
|
||||||
|
*.txt
|
||||||
|
getOp_req_headers.txt
|
||||||
|
alibaba_login.png
|
||||||
|
.playwright-mcp/
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
// background.js — Service Worker:推送队列 worker + 本地数据汇聚(双写)
|
||||||
|
// 以 chrome.storage 为本地汇聚权威源(MV3 的 SW 会被休眠,内存变量不可靠);
|
||||||
|
// 同时通过 alarms + IndexedDB 队列把数据推送到后端 ingest 端点。
|
||||||
|
import { addItem, getStats } from './push/queue.js';
|
||||||
|
import { processQueue } from './push/worker.js';
|
||||||
|
import { loadConfig, saveConfig } from './push/config.js';
|
||||||
|
|
||||||
|
// ===== 本地数据汇聚(保留,供插件本地导出)=====
|
||||||
|
const BG_KEY = 'alibaba_bg_details';
|
||||||
|
|
||||||
|
async function loadMeta() {
|
||||||
|
const data = await chrome.storage.local.get(BG_KEY);
|
||||||
|
return data[BG_KEY] || { dedup: true, customerIdMap: {}, detailData: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMeta(meta) {
|
||||||
|
await chrome.storage.local.set({ [BG_KEY]: meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 推送配置 + alarms =====
|
||||||
|
const ALARM = 'push-tick';
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
if (cfg.enabled) {
|
||||||
|
await chrome.alarms.create(ALARM, { periodInMinutes: 1 });
|
||||||
|
processQueue();
|
||||||
|
} else {
|
||||||
|
await chrome.alarms.clear(ALARM);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 配置/alarms 失败不应阻断 SW 启动
|
||||||
|
console.error('[bg] refresh error:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(refresh);
|
||||||
|
chrome.runtime.onStartup.addListener(refresh);
|
||||||
|
chrome.alarms.onAlarm.addListener(a => {
|
||||||
|
if (a && a.name === ALARM) processQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 消息处理 =====
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
// --- 推送相关 ---
|
||||||
|
if (msg.type === 'enqueue') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
if (!cfg.enabled) { sendResponse({ ok: false, reason: 'disabled' }); return; }
|
||||||
|
const inquiryId = msg.record && msg.record.inquiryId;
|
||||||
|
const id = await addItem({
|
||||||
|
payload: { record: msg.record, messages: msg.messages || [] },
|
||||||
|
inquiryId,
|
||||||
|
});
|
||||||
|
sendResponse({ ok: true, id });
|
||||||
|
processQueue();
|
||||||
|
} catch (e) {
|
||||||
|
sendResponse({ ok: false, error: String((e && e.message) || e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'getPushStats') {
|
||||||
|
(async () => {
|
||||||
|
try { sendResponse({ ok: true, stats: await getStats() }); }
|
||||||
|
catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'getPushConfig') {
|
||||||
|
(async () => {
|
||||||
|
try { sendResponse({ ok: true, config: await loadConfig() }); }
|
||||||
|
catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'setPushConfig') {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await saveConfig(msg.config || {});
|
||||||
|
await refresh();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
sendResponse({ ok: false, error: String((e && e.message) || e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 本地数据汇聚(保留原逻辑,照搬) ---
|
||||||
|
if (msg.type === 'storeDetail') {
|
||||||
|
// 先读后写:SW 休眠唤醒后 DETAIL_STORE 会清空,必须从 storage 恢复再合并
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const meta = await loadMeta();
|
||||||
|
// 首条写入时锁定本次抓取的 dedup 策略
|
||||||
|
const isEmpty = Object.keys(meta.customerIdMap).length === 0 && meta.detailData.length === 0;
|
||||||
|
if (isEmpty) meta.dedup = !!msg.dedup;
|
||||||
|
|
||||||
|
if (meta.dedup && msg.customerId) {
|
||||||
|
// 按 customerId 去重,保留 createTime 更新的那条
|
||||||
|
const existing = meta.customerIdMap[msg.customerId];
|
||||||
|
if (!existing || (msg.createTime || '') >= (existing.createTime || '')) {
|
||||||
|
meta.customerIdMap[msg.customerId] = msg.record;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 无 customerId 或非去重模式:直接追加
|
||||||
|
meta.detailData.push(msg.record);
|
||||||
|
}
|
||||||
|
meta.lastUpdate = Date.now();
|
||||||
|
await saveMeta(meta);
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
} catch (e) {
|
||||||
|
sendResponse({ ok: false, error: String(e && e.message || e) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true; // 保持消息通道,等待异步 sendResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'clearBgData') {
|
||||||
|
(async () => {
|
||||||
|
await chrome.storage.local.remove(BG_KEY);
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'getBgData') {
|
||||||
|
(async () => {
|
||||||
|
const meta = await loadMeta();
|
||||||
|
// 合并两份数据源,避免去重模式下无 customerId 的记录被丢弃
|
||||||
|
const records = meta.dedup
|
||||||
|
? [...Object.values(meta.customerIdMap), ...meta.detailData]
|
||||||
|
: meta.detailData;
|
||||||
|
sendResponse({ records, dedup: meta.dedup });
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
+1301
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,260 @@
|
|||||||
|
# alibb 生产部署文档(1Panel + OpenResty + MySQL 8.4)
|
||||||
|
|
||||||
|
> 基于 2026-08-27 实际生产部署整理(服务器重装后完整恢复验证通过)。
|
||||||
|
> 与 2026-07-15 首次部署的差异见 §8。恢复/迁移专用 runbook 见
|
||||||
|
> [2026-08-25-server-restore-redeploy.md](superpowers/plans/2026-08-25-server-restore-redeploy.md)。
|
||||||
|
|
||||||
|
## 1. 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器扩展(抓 alibaba 询盘)
|
||||||
|
│ POST /api/ingest(Bearer INGEST_TOKEN)
|
||||||
|
▼
|
||||||
|
http://219.157.255.213:23010 ── OpenResty 站点「alibaba」(1Panel 面板管理,host 网络)
|
||||||
|
├─ / → 前端 dist 静态(/opt/1panel/www/sites/alibaba/index)
|
||||||
|
└─ /api/* → http://127.0.0.1:8090(反代,client_max_body_size 50m)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
alibb-backend 容器(Express + Prisma,127.0.0.1:8090→8080)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
1Panel MySQL 8.4(容器 1Panel-mysql-YL0F,库 alibaba)
|
||||||
|
```
|
||||||
|
|
||||||
|
管理后台:浏览器开 http://219.157.255.213:23010 → admin 登录(密码见服务器 `~/alibb-secrets.txt`)。
|
||||||
|
|
||||||
|
## 2. 环境与信息总表
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 服务器 | 阿里云 ECS `219.157.255.213`,Ubuntu 26.04 LTS |
|
||||||
|
| 登录用户 | `kt`(sudo 免密);SSH 密钥 `C:\Users\30997\.ssh\alibb_deploy` |
|
||||||
|
| SSH(本机) | `/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213`(Git Bash 自带 ssh 因私钥权限 644 不可用) |
|
||||||
|
| Docker 网络 | `1panel-network`(bridge,1Panel 安装时自动创建) |
|
||||||
|
| MySQL | 容器 `1Panel-mysql-YL0F`,镜像 mysql:8.4.11,`127.0.0.1:3306`,root 密码见容器 env `MYSQL_ROOT_PASSWORD` |
|
||||||
|
| 业务库/账号 | 库 `alibaba`(utf8mb4),用户 `alibaba@%`,密码 `wecbni5zRRnZrMMh`,授权 `ALL ON alibaba.*` |
|
||||||
|
| 后端 | 容器 `alibb-backend`,代码 `~/alibb-server/backend`,`127.0.0.1:8090→8080`,restart unless-stopped |
|
||||||
|
| OpenResty | 容器 `1Panel-openresty-Seym`,**host 网络模式**,管理 80/443/23010 |
|
||||||
|
| 站点 | 1Panel 面板管理,代号 **alibaba**;目录 `/opt/1panel/www/sites/alibaba/`(index/ 前端、log/ 日志) |
|
||||||
|
| 站点 conf | `/opt/1panel/www/conf.d/alibaba.conf`(面板生成的 server 块 + 手工并入的 SPA fallback 与 /api 反代) |
|
||||||
|
| 对外端口 | 23010(云安全组已放行);8090/3306 仅本机 |
|
||||||
|
| 生产密钥 | JWT_SECRET / INGEST_TOKEN / ADMIN_USERNAME / ADMIN_PASSWORD 内嵌于 `~/alibb-server/backend/docker-compose.yml`;明文副本 `~/alibb-secrets.txt`(600) |
|
||||||
|
| 本机代码 | 扩展 `d:\alibb`(master);后端 `d:\alibb-server\backend`(master,compose 为 **dev 版**);前端 `d:\alibb-server\frontend`(main) |
|
||||||
|
| 备份 | 本机 `D:\alibb-server\backups\`(权威);服务器副本 `~/alibb-backup-20260825/` |
|
||||||
|
|
||||||
|
## 3. 从零部署(完整步骤)
|
||||||
|
|
||||||
|
> 以下命令全部在 2026-08-27 实际验证。分两段:本机(Windows/Git Bash)与服务器(SSH)。
|
||||||
|
|
||||||
|
### 3.1 SSH 准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首次:云控制台 VNC 或 1Panel 面板「主机→终端」,root 执行(公钥替换成自己的)
|
||||||
|
useradd -m -s /bin/bash kt; echo "kt ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/kt; \
|
||||||
|
mkdir -p /home/kt/.ssh; echo "<本机公钥内容>" > /home/kt/.ssh/authorized_keys; \
|
||||||
|
chown -R kt:kt /home/kt/.ssh; chmod 700 /home/kt/.ssh; chmod 600 /home/kt/.ssh/authorized_keys
|
||||||
|
|
||||||
|
# 本机清除旧 host key(重装后必做,否则报 HOST IDENTIFICATION HAS CHANGED)
|
||||||
|
/c/Windows/System32/OpenSSH/ssh-keygen.exe -R 219.157.255.213
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 系统基础
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
sudo tee /etc/docker/daemon.json <<'EOF'
|
||||||
|
{ "registry-mirrors": ["https://docker.1panel.live"] }
|
||||||
|
EOF
|
||||||
|
sudo systemctl restart docker
|
||||||
|
# 1Panel(官方脚本,装时设面板密码)
|
||||||
|
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && sudo bash quick_start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 1Panel 应用
|
||||||
|
|
||||||
|
面板 → 应用商店安装:
|
||||||
|
|
||||||
|
- **OpenResty**(1.31+,host 网络模式)
|
||||||
|
- **MySQL 8.4**(装完记容器名,形如 `1Panel-mysql-XXXX`,下称 `<MYSQL容器名>`)
|
||||||
|
|
||||||
|
### 3.4 数据库
|
||||||
|
|
||||||
|
方式 A(面板):数据库 → 创建,库名 `alibaba`,用户 `alibaba`,权限 `alibaba.*`(密码设 `wecbni5zRRnZrMMh` 与 compose 一致,或建完后 ALTER)。
|
||||||
|
|
||||||
|
方式 B(命令,推荐):
|
||||||
|
```bash
|
||||||
|
M=<MYSQL容器名>
|
||||||
|
R=$(sudo docker inspect $M --format '{{range .Config.Env}}{{println .}}{{end}}' | grep MYSQL_ROOT_PASSWORD | cut -d= -f2)
|
||||||
|
sudo docker exec -i -e MYSQL_PWD=$R $M mysql -uroot <<'SQL'
|
||||||
|
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||||
|
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
# 验证
|
||||||
|
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh $M mysql -ualibaba -e "SELECT 1;"
|
||||||
|
```
|
||||||
|
|
||||||
|
导入存量数据(有备份时):
|
||||||
|
```bash
|
||||||
|
sudo docker exec -i -e MYSQL_PWD=$R $M mysql -uroot < alibaba-db.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本机推代码(排除 node_modules/dist/.git)
|
||||||
|
cd /d && tar czf /tmp/alibb-server.tgz --exclude='alibb-server/*/node_modules' --exclude='*/dist' --exclude='*/.git' alibb-server
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' /tmp/alibb-server.tgz kt@219.157.255.213:/tmp/
|
||||||
|
# 服务器解包
|
||||||
|
ssh ... 'tar xzf /tmp/alibb-server.tgz -C ~'
|
||||||
|
```
|
||||||
|
|
||||||
|
**compose 关键项**(服务器生产版,本地 dev compose 上线前必须对齐):
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
container_name: alibb-backend
|
||||||
|
build: .
|
||||||
|
ports: ["127.0.0.1:8090:8080"]
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: "mysql://alibaba:wecbni5zRRnZrMMh@<MYSQL容器名>:3306/alibaba?charset=utf8mb4&timezone=Z"
|
||||||
|
INGEST_TOKEN / JWT_SECRET / ADMIN_USERNAME / ADMIN_PASSWORD: 生产值(强随机,首次生成见下)
|
||||||
|
NODE_ENV: production
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
1panel-network:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
首次生成密钥(已有备份则沿用旧值,扩展/老 JWT 不失效):
|
||||||
|
```bash
|
||||||
|
openssl rand -hex 24 # INGEST_TOKEN
|
||||||
|
openssl rand -hex 32 # JWT_SECRET
|
||||||
|
openssl rand -hex 16 # ADMIN_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
构建启动(注意 MySQL 容器名替换):
|
||||||
|
```bash
|
||||||
|
cd ~/alibb-server/backend
|
||||||
|
sed -i "s/<旧MYSQL容器名>/<新MYSQL容器名>/g" docker-compose.yml
|
||||||
|
sudo docker compose build backend && sudo docker compose up -d
|
||||||
|
sudo docker compose logs backend | tail # 期望:No pending migrations + listening on 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 站点 + 前端
|
||||||
|
|
||||||
|
**先在 1Panel 面板建站**(网站 → 创建网站):类型**静态网站**,主域名 `219.157.255.213`,端口 `23010`,代号 `alibaba`。
|
||||||
|
> 注意:面板检查端口占用——若 conf.d 里有手工 conf 占着 23010 会报"端口已被占用",先移除再建。
|
||||||
|
|
||||||
|
面板会生成 `/opt/1panel/www/sites/alibaba/` 和 `/opt/1panel/www/conf.d/alibaba.conf`。然后合并前端与反代:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=/opt/1panel/www/sites/alibaba
|
||||||
|
# 前端 dist(备包直解最快;index 子目录在面板建站时已建)
|
||||||
|
sudo tar xzf alibb-frontend-dist.tgz -C $S/ && sudo chmod -R a+rX $S/index
|
||||||
|
# 或源码构建:
|
||||||
|
# cd ~/alibb-server/frontend && sudo docker run --rm -v "$PWD":/app -w /app node:22-alpine sh -c 'npm ci && npm run build'
|
||||||
|
# sudo rm -rf $S/index/* && sudo cp -r dist/. $S/index/ && sudo chmod -R a+rX $S/index
|
||||||
|
|
||||||
|
# 在 conf 的 root 行后并入 SPA fallback + /api 反代(host 网络 → 必须宿主端口,不能用容器名)
|
||||||
|
sudo sed -i "/root \/www\/sites\/alibaba\/index;/a\\
|
||||||
|
location / { try_files \$uri \$uri/ /index.html; }\\
|
||||||
|
location ^~ /api/ {\\
|
||||||
|
proxy_pass http://127.0.0.1:8090;\\
|
||||||
|
client_max_body_size 50m;\\
|
||||||
|
proxy_set_header Host \$host;\\
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;\\
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\\
|
||||||
|
}" /opt/1panel/www/conf.d/alibaba.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
重载 OpenResty:
|
||||||
|
```bash
|
||||||
|
sudo docker exec 1Panel-openresty-Seym openresty -t && sudo docker exec 1Panel-openresty-Seym openresty -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 安全组
|
||||||
|
|
||||||
|
云控制台放行 **TCP 23010** 入方向(源 0.0.0.0/0 或收窄)。8090/3306 不放行。
|
||||||
|
|
||||||
|
### 3.8 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://219.157.255.213:23010/ # 200 index.html
|
||||||
|
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||||
|
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||||
|
```
|
||||||
|
浏览器登录 admin / ADMIN_PASSWORD,核对询盘/客户/消息数据。
|
||||||
|
|
||||||
|
### 3.9 扩展配置
|
||||||
|
|
||||||
|
popup:serverUrl `http://219.157.255.213:23010` + INGEST_TOKEN(~/alibb-secrets.txt)→ 授权 → 启用推送 → 抓取。
|
||||||
|
|
||||||
|
## 4. 增量更新(改代码后)
|
||||||
|
|
||||||
|
| 改动 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| 后端 | 传变更文件 → `cd ~/alibb-server/backend && sudo docker compose build backend && sudo docker compose up -d`(migrate 自动跑) |
|
||||||
|
| 前端 | 传变更 → 服务器 build(§3.6 方式 B)→ 复制 dist → 无需 reload(静态文件即时生效) |
|
||||||
|
| 扩展 | 本机改 → `chrome://extensions` 刷新扩展(用户操作) |
|
||||||
|
|
||||||
|
## 5. 常用运维
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 后端日志 / 重启
|
||||||
|
sudo docker compose -f ~/alibb-server/backend/docker-compose.yml logs -f backend
|
||||||
|
sudo docker compose -f ~/alibb-server/backend/docker-compose.yml restart backend
|
||||||
|
|
||||||
|
# 查库
|
||||||
|
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-YL0F mysql -ualibaba alibaba
|
||||||
|
|
||||||
|
# 行数快照
|
||||||
|
sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-YL0F mysql -ualibaba alibaba -e \
|
||||||
|
"SELECT (SELECT COUNT(*) FROM inquiries) i,(SELECT COUNT(*) FROM customers) c,(SELECT COUNT(*) FROM chat_messages) m;"
|
||||||
|
|
||||||
|
# 清询盘重抓(customers 保留,重抓 upsert 更新)
|
||||||
|
... mysql ... -e "SET FOREIGN_KEY_CHECKS=0; TRUNCATE chat_messages; TRUNCATE inquiries; SET FOREIGN_KEY_CHECKS=1;"
|
||||||
|
|
||||||
|
# 重载 OpenResty(改 conf 后)
|
||||||
|
sudo docker exec 1Panel-openresty-Seym openresty -t && sudo docker exec 1Panel-openresty-Seym openresty -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 备份与恢复
|
||||||
|
|
||||||
|
- **备份**(本机执行,产物存 `D:\alibb-server\backups\`):
|
||||||
|
```bash
|
||||||
|
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 \
|
||||||
|
'sudo docker exec 1Panel-mysql-YL0F mysqldump -ualibaba -pwecbni5zRRnZrMMh --single-transaction --no-tablespaces --routines --triggers --databases alibaba' > D:\alibb-server\backups\alibaba-db-$(date +%Y%m%d).sql
|
||||||
|
```
|
||||||
|
- 完整备份方案:[2026-08-25-server-reinstall-backup.md](superpowers/plans/2026-08-25-server-reinstall-backup.md)
|
||||||
|
- 服务器重装恢复 runbook:[2026-08-25-server-restore-redeploy.md](superpowers/plans/2026-08-25-server-restore-redeploy.md)(含 2026-08-27 实操记录)
|
||||||
|
|
||||||
|
## 7. 故障排查
|
||||||
|
|
||||||
|
| 症状 | 原因 → 处理 |
|
||||||
|
|---|---|
|
||||||
|
| `/api` 502 | ① backend 挂了:`docker ps` / `logs backend`;② 反代写成了 `http://backend:8080`——host 网络模式解析不了容器名,必须 `http://127.0.0.1:8090` |
|
||||||
|
| openresty -t 报 host not found in upstream | 同上,proxy_pass 用容器名导致 |
|
||||||
|
| 后端起不来,连接拒绝 | DATABASE_URL 里 MySQL 容器名不对(重装/重建后容器名后缀会变) |
|
||||||
|
| 管理后台登录 401 | ADMIN_USERNAME/PASSWORD 与 compose 不符;扩展推送 401 → INGEST_TOKEN 不符 |
|
||||||
|
| migrate 报表不存在 | 库导错/导到别的库,重做 §3.4 |
|
||||||
|
| MySQL 8.x 用户连不上(老客户端) | `ALTER USER 'alibaba'@'%' IDENTIFIED WITH caching_sha2_password BY '...'`(Prisma 支持 caching_sha2,一般无需处理) |
|
||||||
|
| 换服务器/IP | 改 3 处:扩展 serverUrl、站点 conf `server_name`、云安全组 |
|
||||||
|
|
||||||
|
## 8. 与 2026-07-15 旧环境差异(重要)
|
||||||
|
|
||||||
|
| 项 | 旧(7 月) | 新(2026-08-27) |
|
||||||
|
|---|---|---|
|
||||||
|
| MySQL | 5.7.44(容器 1Panel-mysql-sLiG,0.0.0.0:3306) | 8.4.11(容器 1Panel-mysql-YL0F,**127.0.0.1:3306 已收紧**) |
|
||||||
|
| OpenResty | bridge 网络,反代可用容器名 `http://backend:8080` | **host 网络,反代必须 `http://127.0.0.1:8090`** |
|
||||||
|
| 1Panel 路径 | /opt/1panel/apps/openresty/openresty/www/sites/... | **/opt/1panel/www/sites/...**(容器内仍挂 /www,旧 conf 兼容) |
|
||||||
|
| 系统 | Ubuntu 24.04(kt-W580G20) | Ubuntu 26.04(xyh-W580G20) |
|
||||||
|
| 站点管理 | 1Panel 面板建站(站点 alibb) | 1Panel 面板建站(代号 **alibaba**,2026-08-27 起面板可见可管理) |
|
||||||
|
|
||||||
|
## 9. 已知限制(业务)
|
||||||
|
|
||||||
|
- **折叠面板字段抓不到**:客户资料折叠面板里的手机/座机/商业类型等,alibaba 校验 `event.isTrusted`(只认真实鼠标点击),扩展无法展开;这些字段多数买家本就是"-"。
|
||||||
|
- **风控**:抓取间隔已随机化(randSleep);大量抓取分批 + 中途暂停。
|
||||||
|
- 公网明文 HTTP(23010):内网/测试用途可接受;若需公网长期使用,建议套域名 + HTTPS(1Panel 可申请 Let's Encrypt)。
|
||||||
+175
@@ -0,0 +1,175 @@
|
|||||||
|
# 阿里国际站询盘数据采集与管理系统 · 项目介绍
|
||||||
|
|
||||||
|
> 基于 2026-08-27 代码现状整理(扩展 `d:\alibb` + 服务端 `d:\alibb-server`)。
|
||||||
|
> 部署细节见 [DEPLOY-ALIBB.md](DEPLOY-ALIBB.md),后端设计见 [superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md](superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md)。
|
||||||
|
|
||||||
|
## 1. 项目概述
|
||||||
|
|
||||||
|
本项目是一套**阿里国际站(alibaba.com)询盘数据的采集与管理系统**,由三个部分组成:
|
||||||
|
|
||||||
|
| 组成部分 | 位置 | 技术 | 角色 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **采集端** Chrome 扩展 | `d:\alibb`(本仓库) | Chrome MV3、原生 JS | 抓取询盘列表、客户资料、行为数据、聊天记录 |
|
||||||
|
| **服务端** 后端 API | `d:\alibb-server\backend` | Node.js + Express + TypeScript + Prisma + MySQL | 接收推送、幂等入库、提供查询/统计/导出 API |
|
||||||
|
| **服务端** 管理后台 | `d:\alibb-server\frontend` | Vue3 + Element Plus + Pinia + ECharts | 询盘列表/详情/聊天查看/统计报表 |
|
||||||
|
|
||||||
|
核心链路:
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器(登录阿里主账号)
|
||||||
|
Chrome 扩展 content.js
|
||||||
|
├─ 列表抓取(DOM 翻页)→ 详情抓取(DOM)→ 聊天抓取(阿里内部 API 直拉)
|
||||||
|
├─ 双写 A(本地):chrome.storage 汇聚 + customerId 去重 → CSV 导出
|
||||||
|
└─ 双写 B(推送):IndexedDB 队列 → alarm 每分钟消费 → POST /api/ingest(Bearer Token)
|
||||||
|
↓ 失败指数退避(≤8 次),成功出队
|
||||||
|
服务器(阿里云 ECS,1Panel + OpenResty + MySQL 8.4,:23010)
|
||||||
|
后端 zod 校验 → 幂等入库(同客户合并 / 消息级去重 / 早返回)
|
||||||
|
↓
|
||||||
|
管理员浏览器 → Vue3 后台(JWT 登录):列表筛选 / 聊天详情 / 统计 / CSV 导出
|
||||||
|
```
|
||||||
|
|
||||||
|
使用模式:主管理员账号一人操作扩展手动触发抓取(非实时同步),后台数据 = 最近一次抓取的快照;询盘记录的 `manager` 字段天然区分业务员,无需多人登录。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 采集端:Chrome MV3 扩展(本仓库)
|
||||||
|
|
||||||
|
### 2.1 文件结构
|
||||||
|
|
||||||
|
| 文件 | 行数/大小 | 职责 |
|
||||||
|
|---|---|---|
|
||||||
|
| [manifest.json](../manifest.json) | — | MV3 清单:权限 `storage/activeTab/alarms/unlimitedStorage/scripting`;host 覆盖 `message/alicrm/onetalk.alibaba.com`;`optional_host_permissions` 运行时授权任意后端地址 |
|
||||||
|
| [content.js](../content.js) | ~1300 行 | 核心:注入 `message.alibaba.com` 的抓取状态机 |
|
||||||
|
| [background.js](../background.js) | 145 行 | Service Worker:推送队列调度 + 本地数据汇聚(双写) |
|
||||||
|
| [push/queue.js](../push/queue.js) | — | IndexedDB 持久化队列(库 `inquiry_push`) |
|
||||||
|
| [push/worker.js](../push/worker.js) | — | 队列消费:POST `/api/ingest`,指数退避,8 次后标 failed |
|
||||||
|
| [push/payload.js](../push/payload.js) | — | 页面数据 → 后端 zod schema 的字段适配层 |
|
||||||
|
| [push/config.js](../push/config.js) | — | 推送配置(`chrome.storage.local`) |
|
||||||
|
| [popup.html](../popup.html) / [popup.js](../popup.js) | — | 操作界面:抓取控制 + 后端推送配置 |
|
||||||
|
| [tests/](../tests/) | 5 个文件 | vitest 单测(config/queue/payload/sync/worker,fake-indexeddb) |
|
||||||
|
| [scripts/](../scripts/) | — | `e2e-push.mjs`(推送端到端)、`verify-chat-range.mjs`、`verify-risk-detect.mjs` |
|
||||||
|
| [使用手册.html](../使用手册.html) | — | 面向操作者的图文五步指南 |
|
||||||
|
|
||||||
|
### 2.2 抓取状态机与断点续抓
|
||||||
|
|
||||||
|
[content.js](../content.js) 的核心是**跨页面持久化的状态机**:`state.phase ∈ idle | list | detail | paused | done`,存储于 `chrome.storage.local`(键 `alibaba_scraper_state`)。每跳转一个详情页 content script 就被销毁重建,靠恢复 `queueIndex` 继续队列 —— 整轮抓取可跨导航、可暂停恢复、可扩展重载续跑。
|
||||||
|
|
||||||
|
抓取编排由 `processCurrentDetailPage()` 完成:等待渲染 → 聊天抓取(API 优先)→ 详情提取 → **双写**(本地 `storeDetail` + 推送 `enqueue`)→ 跳下一条。
|
||||||
|
|
||||||
|
### 2.3 数据抓取能力
|
||||||
|
|
||||||
|
- **列表页**:DOM 解析 `a.inquiry-item`(询价单号/时间/买家/国家/消息预览/负责人/状态等 13 字段),100 条/页,自动翻页;
|
||||||
|
- **详情页**:客户资料(邮箱/电话/公司/注册时间等)、买家标签(8 词表)、买家特征(12 词表)、近 90 天行为数据(产品浏览/有效询盘/RFQ/登录天数/垃圾询盘/黑名单数)、店内行为与最近询盘产品;
|
||||||
|
- **聊天记录**(最复杂模块):
|
||||||
|
- **API 直拉主路径**:`querySummary.htm` 拿会话加密参数 → `getOpMessages.htm` 游标循环翻页(复用页面 cookie 凭据),单询盘实测 8000+ 条;实测全量 4576 询盘 + 68058 条消息零风控;
|
||||||
|
- **DOM 滚动回退**:API 失败时模拟 wheel 事件滚动 `.common-load-more` 懒加载;
|
||||||
|
- **同客户去重**:同买家多询盘共享同一会话,只抓首个(`seenChatCustomer`);
|
||||||
|
- **消息归一化**(`apiMsgToChat`):方向(incoming/outgoing/system)、富媒体(`img:`/`file:`/`video:` 前缀 + clouddisk 直链构造)、翻译(嵌套 JSON 双重 parse)。
|
||||||
|
|
||||||
|
### 2.4 反风控设计(贯穿全文件)
|
||||||
|
|
||||||
|
| 机制 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 随机时延 | 翻页 1.2–3s、聊天每轮 2–5s、每 15 轮插入 30–90s"模拟走神" |
|
||||||
|
| 作息时段 | 默认 8–12 / 14–18 点工作,会话级 ±30 分钟抖动,非工作时段自动挂起 |
|
||||||
|
| 风控检测 | `RISK_KEYWORDS`(验证码/captcha/blocked 等)命中 → 暂停 + popup 红色横幅报警;只浅扫 API 响应顶层字段,不扫消息正文防误判 |
|
||||||
|
| 跳过策略 | 注销/管控账号(`SKIP_KEYWORDS`)直接跳过不暂停 |
|
||||||
|
| 增量水位 | 按 inquiryId 记录已抓最新消息(`lastTopMsg_*`),重抓只拉增量,只前进不倒退 |
|
||||||
|
| 时间范围 | 近 1/2/3 月或自定义起止;`until` 跳游标直接从截止时间往回翻,大幅减少请求量 —— 同时是风控减压手段 |
|
||||||
|
|
||||||
|
### 2.5 推送链路(可靠性)
|
||||||
|
|
||||||
|
```
|
||||||
|
content.js 抓完一条 → sendMessage 'enqueue' → SW 写 IndexedDB(不丢)
|
||||||
|
→ 立即 + alarm 'push-tick'(每 1 分钟)触发 processQueue()
|
||||||
|
→ 每批 3 条,POST {serverUrl}/api/ingest(Bearer Token)
|
||||||
|
→ 成功标 sent;失败指数退避(2^n 秒,封顶 1h),8 次后标 failed(popup 红字提示)
|
||||||
|
```
|
||||||
|
|
||||||
|
关键设计:worker **每次自己 `loadConfig()`**(无状态),因为 MV3 SW 休眠重启会清空模块级变量 —— 缓存配置会导致永不推送。
|
||||||
|
|
||||||
|
### 2.6 popup 界面
|
||||||
|
|
||||||
|
抓取模式(仅列表/列表+详情)、条数(全部/前10/前50)、customerId 去重开关、聊天上限、**聊天时间范围**(全部/近1-3月/自定义日期)、后端推送设置(服务器 URL + Token + 授权域名 + 启用开关,默认指向生产 `http://219.157.255.213:23010`)、实时推送状态条(已推/待推/失败,2s 刷新)、开始/暂停/恢复/导出 CSV/清空数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 服务端后端(d:\alibb-server\backend)
|
||||||
|
|
||||||
|
### 3.1 技术栈与结构
|
||||||
|
|
||||||
|
Express 4 + TypeScript + Prisma 5 + MySQL 8,zod 校验,Docker 两阶段构建(node:20-slim),容器启动自动 `prisma migrate deploy`。目录:`src/{routes,services,middleware,utils,config,lib}` + `prisma/` + `tests/`(vitest + testcontainers 真库集成测试)。
|
||||||
|
|
||||||
|
### 3.2 API 清单
|
||||||
|
|
||||||
|
| 方法 + 路径 | 鉴权 | 功能 |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /api/health` | 无 | 探活(DB 连通性) |
|
||||||
|
| `POST /api/ingest/` | Ingest Token + 60次/min 限流 | 扩展推送入口,幂等入库 |
|
||||||
|
| `POST /api/auth/login` / `refresh` | 无 / JWT | 管理员登录(bcrypt)、续签 |
|
||||||
|
| `GET /api/inquiries` | JWT | 列表:分页 + 国家/业务员/状态/时间筛选 + 关键词搜索 + 排序 |
|
||||||
|
| `GET /api/inquiries/:id` | JWT | 详情 + 客户 + 行为 + 消息数 |
|
||||||
|
| `GET /api/inquiries/:id/messages` | JWT | 聊天游标分页(复合键 `(sendTime, messageId)`,微信式向上翻页) |
|
||||||
|
| `GET /api/inquiries/:id/messages/export` | JWT | 单询盘聊天 CSV 流式导出(预留) |
|
||||||
|
| `GET /api/stats/overview` | JWT | 总数 / 国家 Top10 / 业务员分布 / 近 12 月趋势 |
|
||||||
|
| `GET /api/export` | JWT | 询盘列表 CSV 导出(21 列中文表头,与列表筛选一致) |
|
||||||
|
|
||||||
|
### 3.3 ingest 幂等核心(亮点)
|
||||||
|
|
||||||
|
1. **同客户合并**:同一 customerId 只保留一条"代表询盘"(最早创建),后续询盘追加进 `allInquiries` JSON 数组,消息统一归入代表 —— 不产生冗余行;
|
||||||
|
2. **消息级幂等**:按 `message_id` 唯一约束 `createMany skipDuplicates`,500 条/批,单批失败只计数不回滚(`has_failed_batches` 标记,下次推送强制重插);
|
||||||
|
3. **早返回优化**:"消息数 + 最大 sendTime + 无失败批次" 三项指纹未变 → 跳过批量插入直接返回,避免重复重写几千条消息;
|
||||||
|
4. customer upsert 用 `skipNulls`,本次没抓到的字段不覆盖已落库值。
|
||||||
|
|
||||||
|
### 3.4 数据模型(4 张表)
|
||||||
|
|
||||||
|
- **customers**:`customer_id` 唯一;资料 + 行为七字段(可空,区分"无数据"与"为 0")+ 店内行为 JSON 列;
|
||||||
|
- **inquiries**:`inquiry_id` 唯一;`all_inquiries`(同客户合并)、`messages_count`/`last_send_time`(幂等指纹)、`has_failed_batches`;
|
||||||
|
- **chat_messages**:`message_id` 唯一(幂等键);`content MEDIUMTEXT`;复合索引 `(inquiry_id, send_time, message_id)` 恰好覆盖游标分页;
|
||||||
|
- **users**:管理员(bcrypt,空表时按环境变量播种)。
|
||||||
|
|
||||||
|
### 3.5 鉴权
|
||||||
|
|
||||||
|
两套并行:扩展 → 静态 `INGEST_TOKEN`(`timingSafeEqual` 常量时间比较防时序攻击);后台 → JWT(HS256,7 天,登录签发)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 管理后台前端(d:\alibb-server\frontend)
|
||||||
|
|
||||||
|
### 4.1 页面
|
||||||
|
|
||||||
|
| 路由 | 页面 | 功能要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/login` | 登录 | JWT + open-redirect 防护 |
|
||||||
|
| `/inquiries` | 询盘列表 | 筛选栏(国家/业务员/状态/时间范围/搜索/排序)、表格分页、**CSV 导出复用当前筛选**;列表状态存 sessionStorage,进详情返回后恢复 |
|
||||||
|
| `/inquiries/:id` | 询盘详情 | 客户信息卡 + 行为数据卡(复刻阿里详情页行为区)+ **同会话多询盘列表** + 完整聊天 |
|
||||||
|
| `/stats` | 统计 | 数字卡(总数/本月新增)+ ECharts:国家 Top10 环形图、业务员柱状图、12 月趋势折线 |
|
||||||
|
|
||||||
|
### 4.2 聊天组件(核心体验)
|
||||||
|
|
||||||
|
- **虚拟滚动**(vue-virtual-scroller):几千条消息流畅渲染,可变高度气泡自动测量;
|
||||||
|
- **游标分页**:首屏拉最新一页自动滚到底,顶部按钮向上加载历史,prepend 后保持视觉位置不跳动;
|
||||||
|
- **微信式气泡**:incoming 左 / outgoing 右 / system 居中;消息类型协议(`img:`/`file:`/`video:`/纯链接/纯文本);
|
||||||
|
- **XSS 防护**:文本一律插值不用 v-html;富媒体 URL 走 alibaba 域名白名单,纯 URL 只许 http/https。
|
||||||
|
|
||||||
|
### 4.3 工程细节
|
||||||
|
|
||||||
|
axios 封装(401 全局跳登录、错误脱敏不含 token)、Pinia auth store(JWT 存 localStorage,MVP 可接受)、路由守卫、全套 vitest 单测、双 lint(oxlint + eslint)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 部署与运维
|
||||||
|
|
||||||
|
生产环境(详见 [DEPLOY-ALIBB.md](DEPLOY-ALIBB.md)):
|
||||||
|
|
||||||
|
- 阿里云 ECS `219.157.255.213`(Ubuntu 26.04),对外端口 **23010**;
|
||||||
|
- 1Panel 面板管理:OpenResty(host 网络,站点 `alibaba`)→ SPA 静态 + `/api/*` 反代 → `alibb-backend` 容器(127.0.0.1:8090)→ 1Panel MySQL 8.4(库 `alibaba`,utf8mb4,仅本机可达);
|
||||||
|
- `client_max_body_size 50m` 配合大聊天 payload;备份:mysqldump → 本机 `D:\alibb-server\backups\`(权威)+ 服务器副本;
|
||||||
|
- 三份代码位置:扩展 `d:\alibb`(master)、后端 `d:\alibb-server\backend`(master)、前端 `d:\alibb-server\frontend`(main);服务器重装恢复 runbook 见 [superpowers/plans/](superpowers/plans/)。
|
||||||
|
|
||||||
|
## 6. 已知限制
|
||||||
|
|
||||||
|
- 客户资料**折叠面板字段抓不到**(阿里校验 `event.isTrusted` 只认真实鼠标点击),好在多数买家本就是 "-";
|
||||||
|
- 公网明文 HTTP(23010),内网/测试可接受,长期公网建议套域名 + HTTPS;
|
||||||
|
- 数据时效 = 上次手动抓取快照,需浏览器保持阿里页打开;
|
||||||
|
- 同 messageId 内容被上游修订时早返回会跳过更新(MVP 接受,重推主场景是新增消息)。
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
|||||||
|
# 管理后台前端 实施计划 (Plan 3)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development 或 superpowers:executing-plans。Steps 用 `- [ ]` 跟踪。
|
||||||
|
|
||||||
|
**Goal:** Vue3 + Element Plus 管理后台,对接 Plan 1 后端(`/api/*`),实现登录 + 询盘列表(筛选/搜索/分页)+ 详情(客户+行为+聊天虚拟滚动)+ 统计 + 导出。
|
||||||
|
|
||||||
|
**Architecture:** SPA。axios 调后端 REST API(JWT 鉴权)。Pinia 管 auth 状态。Vue Router(+ 守卫)。构建产物 dist 由 nginx 反代(/api→backend, /→dist)。
|
||||||
|
|
||||||
|
**Tech Stack:** Vue3 + TypeScript + Element Plus + Vite + Pinia + Vue Router + axios + ECharts + vue-virtual-scroller。
|
||||||
|
|
||||||
|
**关联:** spec §9;后端 Plan 1(`d:/alibb-server/backend`,compose 跑在 `localhost:8080`)。
|
||||||
|
|
||||||
|
**位置:** `d:/alibb-server/frontend/`(新建,git init)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
d:/alibb-server/frontend/
|
||||||
|
src/
|
||||||
|
api/{request.ts(client+JWT interceptor), inquiry.ts, stats.ts, auth.ts}
|
||||||
|
stores/auth.ts # token + 用户名(localStorage)
|
||||||
|
router/index.ts # 路由 + 守卫
|
||||||
|
layouts/MainLayout.vue # 侧边栏 + 头部
|
||||||
|
views/{Login.vue, InquiryList.vue, InquiryDetail.vue, Stats.vue}
|
||||||
|
components/{BehaviorCard.vue, ChatList.vue(虚拟滚动)}
|
||||||
|
App.vue / main.ts
|
||||||
|
vite.config.ts # 代理 /api → localhost:8080(dev)
|
||||||
|
Dockerfile / nginx.conf # 构建 + 反代
|
||||||
|
package.json / tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
1. **项目初始化**:`npm create vue@latest`(Vue3+TS+Router+Pinia),装 Element Plus / axios / ECharts / vue-virtual-scroller。vite dev 代理 `/api→:8080`。git init。
|
||||||
|
2. **API client + auth + 登录**:`api/request.ts`(axios,baseURL `/api`,请求拦截加 Bearer,401 跳登录);`stores/auth.ts`(token 存 localStorage);`views/Login.vue`(用户名+密码 → `/api/auth/login` → 存 token → 跳 `/inquiries`)。
|
||||||
|
3. **主布局 + 路由守卫**:`MainLayout.vue`(左侧菜单:询盘/统计;头部:用户名/登出);router 守卫(无 token → `/login`)。
|
||||||
|
4. **询盘列表** `InquiryList.vue`:Element Plus `el-table` + 顶部筛选(国家/业务员/状态/时间范围 el-date-picker)+ 搜索框 + el-pagination + 导出按钮(调 `/api/export` 下载 CSV)。列:询价单号/买家/国家/业务员/状态/创建时间/消息数。点行跳详情。
|
||||||
|
5. **询盘详情** `InquiryDetail.vue`:`el-tabs` 或左右分栏:客户信息卡 + 行为数据卡(产品浏览/有效询盘/RFQ/登录天数/垃圾询盘/最常行业)+ 聊天(`ChatList.vue` vue-virtual-scroller,游标分页加载 `/api/inquiries/:id/messages`,左右气泡按 direction)。
|
||||||
|
6. **统计** `Stats.vue`:数字卡片(总数/本月新增)+ ECharts(国家 Top10 饼图 / 业务员柱状图 / 月趋势折线)。调 `/api/stats/overview`。
|
||||||
|
7. **导出**:列表页「导出 CSV」按钮 → `window.open('/api/export?...当前筛选', headers JWT)` 或 axios blob 下载。
|
||||||
|
8. **构建 + nginx**:`npm run build` → dist;`nginx.conf`(/api → backend:8080,/ → dist);Dockerfile。可选:加到 backend 的 docker-compose(nginx 服务)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准(DoD)
|
||||||
|
|
||||||
|
- [ ] 登录页 → 输入 admin/admin-password-1234 → 进列表
|
||||||
|
- [ ] 列表:筛选(国家/业务员/状态/时间)+ 搜索 + 分页工作
|
||||||
|
- [ ] 详情:客户 + 行为 + 聊天(虚拟滚动,游标分页)显示
|
||||||
|
- [ ] 统计:卡片 + 3 图表
|
||||||
|
- [ ] 导出:按筛选下载 CSV
|
||||||
|
- [ ] `npm run build` 成功;nginx 反代后前后端通
|
||||||
|
|
||||||
|
## 后续
|
||||||
|
|
||||||
|
- 真实 HTTPS 域名(Plan 4)
|
||||||
|
- 聊天全文搜索、客户合并视图、跟进管理
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
# 插件推送改造 实施计划 (Plan 2)
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 在现有 `d:\alibb\` Chrome 插件(已实现抓取 + API 直拉聊天)上**新增推送模块**:抓完一个询盘自动入队,由 service worker 经 `chrome.alarms` 消费、POST 到 Plan 1 后端 `/api/ingest`。带 IndexedDB 队列(防丢)+ 指数退避重试 + 配置页 + 运行时域名授权。**保留**现有本地导出能力(双写)。
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
- `background.js`(SW)改 **ES module**(`manifest` `type:module`),`import` 推送逻辑 `push/*.js`
|
||||||
|
- 推送逻辑分三个 ES module(可被 node + vitest 测试):`push/queue.js`(IndexedDB 队列)、`push/payload.js`(组装后端 payload)、`push/worker.js`(消费 + POST + 重试)
|
||||||
|
- `content.js` 保持经典脚本,抓完一条 → `chrome.runtime.sendMessage({type:'enqueue', record, messages})` 发原始数据给 SW;SW 组装 payload + 入队
|
||||||
|
- IndexedDB 由 **SW 落盘**(content script 的 IDB 属页面源,v3 修订),`chrome.storage.local` 只存 `queue_index` + 元数据
|
||||||
|
- `popup` 加「后端设置」(URL/token/启用)+ 推送状态显示;`chrome.permissions.request` 运行时授权后端域名
|
||||||
|
|
||||||
|
**Tech Stack:** 现有 JS 插件 + vitest 2 + fake-indexeddb(node 测队列)+ Chrome MV3 API(alarms/indexedDB/permissions/storage)。
|
||||||
|
|
||||||
|
**关联:** spec `docs/superpowers/specs/2026-07-13-alibaba-inquiry-backend-design.md` §8;后端 Plan 1 已就绪(`d:\alibb-server\backend`,`/api/ingest`)。
|
||||||
|
|
||||||
|
**前提:** `d:\alibb` 当前**非 git 仓库**,Task 1 会 `git init`。Plan 1 后端能 `docker compose up` 跑起来供端到端验证。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
d:\alibb\ # 插件根(本 plan 改这里)
|
||||||
|
manifest.json # 改:type:module + permissions(alarms/unlimitedStorage) + optional_host_permissions
|
||||||
|
background.js # 改:ES module,import push/*,alarms worker,接收 enqueue;保留 storeDetail/getBgData(本地导出)
|
||||||
|
content.js # 改:apiMsgToChat 扩展(messageId/sendTime ISO/translation);fetchChatByApi 扩展(meta);processCurrentDetailPage 加 enqueue
|
||||||
|
popup.html / popup.js # 改:加后端设置区 + 推送状态显示
|
||||||
|
push\
|
||||||
|
queue.js # 新:IndexedDB 队列操作(addItem/getPending/markStatus/remove/getStats)
|
||||||
|
payload.js # 新:buildIngestPayload(record, messages) → 适配后端 zod
|
||||||
|
worker.js # 新:processQueue(消费 + POST + 指数退避)
|
||||||
|
tests\
|
||||||
|
queue.test.js # vitest + fake-indexeddb
|
||||||
|
payload.test.js # 纯函数测试
|
||||||
|
worker.test.js # mock fetch + fake-indexeddb
|
||||||
|
package.json # 新:vitest + fake-indexeddb
|
||||||
|
vitest.config.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: node 测试基础 + git init
|
||||||
|
|
||||||
|
**Files:** `package.json`, `vitest.config.ts`, `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `cd d:/alibb && git init`(若已是 git 仓库则跳过)
|
||||||
|
- [ ] **Step 2:** `package.json`:`{ "name":"alibaba-inquiry-plugin", "type":"module", "scripts":{"test":"vitest run"} }`
|
||||||
|
- [ ] **Step 3:** `npm install -D vitest fake-indexeddb`
|
||||||
|
- [ ] **Step 4:** `vitest.config.ts`:`{ test:{ globals:true, testTimeout:30000, poolOptions:{ forks:{ singleFork:true } }, setupFiles:['fake-indexeddb/auto'] } }`
|
||||||
|
- [ ] **Step 5:** `.gitignore`:`node_modules/ .env`(注意:**不忽略** push/*.js、content.js 等插件文件)
|
||||||
|
- [ ] **Step 6:** commit `chore: add node test harness (vitest + fake-indexeddb)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: manifest 改造
|
||||||
|
|
||||||
|
**Files:** `manifest.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** permissions 加 `alarms`、`unlimitedStorage` → `["storage","activeTab","alarms","unlimitedStorage"]`
|
||||||
|
- [ ] **Step 2:** 加 `optional_host_permissions: ["http://*/*","https://*/*"]`(后端域名运行时授权)
|
||||||
|
- [ ] **Step 3:** background 加 `"type":"module"` → `{ "service_worker":"background.js", "type":"module" }`
|
||||||
|
- [ ] **Step 4:** commit `feat(manifest): alarms/unlimitedStorage + optional_host_permissions + sw module`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: push/queue.js(IndexedDB 队列)
|
||||||
|
|
||||||
|
**Files:** `push/queue.js`, `tests/queue.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 写 `tests/queue.test.ts`(TDD):addItem → getPending 返回含它;markStatus('sent') → 不在 pending;getStats → {pending,failed,sent} 计数
|
||||||
|
- [ ] **Step 2:** 跑测试确认失败
|
||||||
|
- [ ] **Step 3:** 写 `push/queue.js`:
|
||||||
|
```js
|
||||||
|
const DB_NAME = 'inquiry_push', STORE = 'queue', VERSION = 1;
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME, VERSION);
|
||||||
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE, { keyPath: 'id' });
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function tx(db, mode) { return db.transaction(STORE, mode).objectStore(STORE); }
|
||||||
|
function req2promise(r) { return new Promise((res, rej) => { r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error); }); }
|
||||||
|
|
||||||
|
export async function addItem(item) {
|
||||||
|
const db = await openDb();
|
||||||
|
const rec = { id: crypto.randomUUID(), payload: item.payload, inquiryId: item.inquiryId,
|
||||||
|
status: 'pending', retries: 0, nextRetry: Date.now(), createdAt: Date.now() };
|
||||||
|
await req2promise(tx(db, 'readwrite').add(rec));
|
||||||
|
db.close();
|
||||||
|
return rec.id;
|
||||||
|
}
|
||||||
|
export async function getPending(limit = 5) {
|
||||||
|
const db = await openDb();
|
||||||
|
const all = await req2promise(tx(db, 'readonly').getAll());
|
||||||
|
db.close();
|
||||||
|
const now = Date.now();
|
||||||
|
return all.filter(r => r.status === 'pending' && r.nextRetry <= now)
|
||||||
|
.sort((a, b) => a.nextRetry - b.nextRetry)
|
||||||
|
.slice(0, limit);
|
||||||
|
}
|
||||||
|
export async function markStatus(id, status, opts = {}) {
|
||||||
|
const db = await openDb();
|
||||||
|
const store = tx(db, 'readwrite');
|
||||||
|
const r = await req2promise(store.get(id));
|
||||||
|
if (!r) { db.close(); return; }
|
||||||
|
Object.assign(r, { status, ...opts });
|
||||||
|
await req2promise(store.put(r));
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
export async function remove(id) {
|
||||||
|
const db = await openDb();
|
||||||
|
await req2promise(tx(db, 'readwrite').delete(id));
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
export async function getStats() {
|
||||||
|
const db = await openDb();
|
||||||
|
const all = await req2promise(tx(db, 'readonly').getAll());
|
||||||
|
db.close();
|
||||||
|
return all.reduce((a, r) => { a[r.status] = (a[r.status] || 0) + 1; return a; }, {});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> `crypto.randomUUID()` 在 SW 和 node 18+ 都可用。fake-indexeddb 提供 indexedDB 全局。
|
||||||
|
- [ ] **Step 4:** 测试通过 + commit `feat(push): IndexedDB queue (add/getPending/markStatus/remove/stats)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: push/payload.js(组装后端 payload)
|
||||||
|
|
||||||
|
**Files:** `push/payload.js`, `tests/payload.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 测试:`buildIngestPayload(record, messages)` 输出匹配后端 zod(inquiryId/createTime ISO/updateTime ISO/customer{customerId,country,...}/messages[{messageId,sendTime ISO,direction,content,...}]);空字符串 behavior 归 null;messages 为空时抛错(后端 zod min(1))
|
||||||
|
- [ ] **Step 2:** 写 `push/payload.js`:
|
||||||
|
```js
|
||||||
|
const s = v => (v == null || v === '') ? null : v;
|
||||||
|
const iso = ms => new Date(ms).toISOString();
|
||||||
|
|
||||||
|
export function buildIngestPayload(record, messages) {
|
||||||
|
if (!messages || messages.length === 0) throw new Error('messages required');
|
||||||
|
const iq = record || {};
|
||||||
|
return {
|
||||||
|
inquiry: {
|
||||||
|
inquiryId: String(iq.inquiryId),
|
||||||
|
manager: s(iq.manager) || undefined,
|
||||||
|
// 优先用 meta 的 gmtCreate/gmtModified(record.createTimeFromApi),回退列表 createTime
|
||||||
|
createTime: iq.createTimeFromApi || iq.createTime || iso(Date.now()),
|
||||||
|
updateTime: iq.updateTimeFromApi || iq.updateTime || iq.createTimeFromApi || iso(Date.now()),
|
||||||
|
status: s(iq.status) || undefined,
|
||||||
|
msgPreview: s(iq.msgPreview) || undefined,
|
||||||
|
isFlagged: !!iq.isFlagged,
|
||||||
|
},
|
||||||
|
customer: iq.customerId ? {
|
||||||
|
customerId: String(iq.customerId),
|
||||||
|
name: s(iq.detailBuyerName) || s(iq.buyerName),
|
||||||
|
country: s(iq.detailCountry) || s(iq.countryCode) ? (s(iq.detailCountry) || s(iq.countryCode)) : null,
|
||||||
|
email: s(iq.email), company: s(iq.company),
|
||||||
|
registerTime: s(iq.registerTime) || null,
|
||||||
|
avatarUrl: s(iq.detailAvatarUrl) || s(iq.avatarUrl) || undefined,
|
||||||
|
buyerTags: s(iq.buyerTags) || undefined, buyerFeatures: s(iq.buyerFeatures) || undefined,
|
||||||
|
behavior: iq.behavior ? {
|
||||||
|
productViews: n(iq.behavior.productViews), validInquiries: n(iq.behavior.validInquiries),
|
||||||
|
validRfq: n(iq.behavior.validRfq), loginDays: n(iq.behavior.loginDays),
|
||||||
|
spamInquiries: n(iq.behavior.spamInquiries), topIndustry: s(iq.behavior.topIndustry),
|
||||||
|
} : undefined,
|
||||||
|
} : null,
|
||||||
|
messages: messages.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) || '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function n(v) { const x = Number(v); return Number.isFinite(x) ? x : null; }
|
||||||
|
```
|
||||||
|
> 注:`messages` 已是 apiMsgToChat 转换后的对象(messageId/sendTime ISO/sender/original/direction/translation)。content 映射用 original(apiMsgToChat 产出 original)。
|
||||||
|
- [ ] **Step 3:** 测试通过(对照 Plan 1 后端 PayloadSchema 语义)+ commit `feat(push): buildIngestPayload adapter`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: push/worker.js(消费 + POST + 重试)
|
||||||
|
|
||||||
|
**Files:** `push/worker.js`, `tests/worker.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 测试:mock global.fetch(成功 → markStatus sent 风格的 remove);失败 → retries++ nextRetry 退避;超过 8 次 → markStatus failed。注入 `{addItem,getPending,markStatus,remove}` mock 或用真实 fake-indexeddb + fetch mock
|
||||||
|
- [ ] **Step 2:** 写 `push/worker.js`:
|
||||||
|
```js
|
||||||
|
import { getPending, markStatus, remove } from './queue.js';
|
||||||
|
import { buildIngestPayload } from './payload.js';
|
||||||
|
|
||||||
|
const MAX_RETRIES = 8;
|
||||||
|
let _config = null; // {serverUrl, token, enabled}
|
||||||
|
|
||||||
|
export function configure(cfg) { _config = cfg; }
|
||||||
|
|
||||||
|
export async function processQueue() {
|
||||||
|
if (!_config || !_config.enabled || !_config.serverUrl || !_config.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(`${_config.serverUrl}/api/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': `Bearer ${_config.token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (res.ok) { await remove(it.id); 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 + 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- [ ] **Step 3:** 测试通过 + commit `feat(push): worker (consume + POST + backoff)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: background.js(SW,alarms worker + 接收 enqueue)
|
||||||
|
|
||||||
|
**Files:** `background.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 顶部改 ES module import:
|
||||||
|
```js
|
||||||
|
import { addItem, getStats } from './push/queue.js';
|
||||||
|
import { processQueue, configure } from './push/worker.js';
|
||||||
|
```
|
||||||
|
- [ ] **Step 2:** 保留现有 storeDetail/getBgData/clearBgData(本地导出)**不动**
|
||||||
|
- [ ] **Step 3:** 加配置加载 + alarms 注册 + enqueue 处理:
|
||||||
|
```js
|
||||||
|
const CONFIG_KEY = 'push_config';
|
||||||
|
const ALARM = 'push-tick';
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const d = await chrome.storage.local.get(CONFIG_KEY);
|
||||||
|
return d[CONFIG_KEY] || { enabled: false, serverUrl: '', token: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 + 配置变更时刷新 worker 配置 + alarm
|
||||||
|
async function refresh() {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
configure(cfg);
|
||||||
|
if (cfg.enabled) {
|
||||||
|
await chrome.alarms.create(ALARM, { periodInMinutes: 1 });
|
||||||
|
processQueue(); // 立即跑一次
|
||||||
|
} else {
|
||||||
|
await chrome.alarms.clear(ALARM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener(refresh);
|
||||||
|
chrome.runtime.onStartup.addListener(refresh);
|
||||||
|
chrome.alarms.onAlarm.addListener(a => { if (a.name === ALARM) processQueue(); });
|
||||||
|
|
||||||
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
|
if (msg.type === 'enqueue') {
|
||||||
|
(async () => {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
if (!cfg.enabled) { sendResponse({ ok: false, reason: 'disabled' }); return; }
|
||||||
|
const id = await addItem({ payload: { record: msg.record, messages: msg.messages }, inquiryId: msg.record?.inquiryId });
|
||||||
|
sendResponse({ ok: true, id });
|
||||||
|
processQueue(); // 立即尝试
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (msg.type === 'getPushStats') {
|
||||||
|
(async () => sendResponse(await getStats()))();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (msg.type === 'setPushConfig') {
|
||||||
|
(async () => {
|
||||||
|
await chrome.storage.local.set({ [CONFIG_KEY]: msg.config });
|
||||||
|
await refresh();
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
})();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
- [ ] **Step 4:** commit `feat(bg): push worker + alarms + enqueue handler (keep local export)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: content.js — apiMsgToChat 扩展
|
||||||
|
|
||||||
|
**Files:** `content.js`(apiMsgToChat ~434)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** apiMsgToChat 改产出 `{ messageId, sendTime(ISO), sender, original, translation, direction }`:
|
||||||
|
- `messageId: String(m.messageId)`
|
||||||
|
- `sendTime: new Date(m.sendTime).toISOString()`(替代原来的本地时区 "YYYY-MM-DD HH:MM:SS")
|
||||||
|
- `translation`:从 `m.extInfo?.icbuData?.extend` 解析(extend 是 JSON 字符串,内含 extParams.inputTranslateInfo.translate);解析失败留 `''`
|
||||||
|
- sender/original/direction 保持
|
||||||
|
- [ ] **Step 2:** translation 解析加双重 JSON.parse 兜底(extend 可能是字符串里的字符串),失败不抛(留 '')
|
||||||
|
- [ ] **Step 3:** 手动/Playwright 验证(无 node 测试,因 content.js 非 module):用 Playwright 注入新版 apiMsgToChat + 真实 m 样本,断言 messageId/sendTime/translation 正确
|
||||||
|
- [ ] **Step 4:** commit `feat(content): apiMsgToChat emits messageId/sendTime(ISO)/translation`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: content.js — fetchChatByApi 扩展(提取 gmtCreate/country)
|
||||||
|
|
||||||
|
**Files:** `content.js`(fetchChatByApi ~451)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** fetchChatByApi 在调 querySummary 后,额外提取 `d.gmtCreate`/`d.gmtModified`(毫秒→ISO)存入返回 meta;`d.contact?.countryCode` 或 `d.cid` 派生也行(spec 用 contact.countryCode)。返回 `{ messages, meta: { gmtCreate, gmtModified } }`
|
||||||
|
- [ ] **Step 2:** 调用方(processCurrentDetailPage / scrapeDetailData)把 meta 挂到 detail:`detail.createTimeFromApi = meta.gmtCreate; detail.updateTimeFromApi = meta.gmtModified`
|
||||||
|
- [ ] **Step 3:** commit `feat(content): fetchChatByApi extracts gmtCreate/gmtModified for payload`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: content.js — processCurrentDetailPage 加 enqueue
|
||||||
|
|
||||||
|
**Files:** `content.js`(processCurrentDetailPage ~347,storeDetail 之后)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 在 storeDetail(本地导出)**之后**加 enqueue(推送):
|
||||||
|
```js
|
||||||
|
// 推送到后端(Plan 2):发 record + messages 给 SW 入队
|
||||||
|
try {
|
||||||
|
chrome.runtime.sendMessage({
|
||||||
|
type: 'enqueue',
|
||||||
|
record: merged, // {item, detail} 合并
|
||||||
|
messages: detail.chatMessages || [], // apiMsgToChat 产出数组
|
||||||
|
}, () => {}); // fire-and-forget,失败不影响本地导出
|
||||||
|
} catch (e) {}
|
||||||
|
```
|
||||||
|
> `detail.chatMessages` 需在 scrapeDetailData 产出(现有 chatText 是序列化串,加一个 `chatMessages` 数组字段)。确保 merged 含 `createTimeFromApi/updateTimeFromApi`(Task 8)。
|
||||||
|
- [ ] **Step 2:** commit `feat(content): enqueue record+messages to SW after detail scrape`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: popup 配置页 + permissions.request
|
||||||
|
|
||||||
|
**Files:** `popup.html`, `popup.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** popup.html 加「后端设置」区:服务器 URL 输入、Token 输入、启用推送 checkbox、「授权域名」按钮、「保存」按钮
|
||||||
|
- [ ] **Step 2:** popup.js:加载时 `chrome.runtime.sendMessage({type:'getPushConfig'})` 回填(或直接 `chrome.storage.local.get`);保存时 setPushConfig
|
||||||
|
- [ ] **Step 3:** 「授权域名」按钮 → `chrome.permissions.request({origins:[serverUrl + '/*']})`(用户手势触发);成功后提示
|
||||||
|
- [ ] **Step 4:** commit `feat(popup): backend config UI + runtime origin permission`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: popup 推送状态显示
|
||||||
|
|
||||||
|
**Files:** `popup.html`, `popup.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** popup 加状态条:「已推 N / 待推 M / 失败 K」
|
||||||
|
- [ ] **Step 2:** popup 打开时 + 每 2s `chrome.runtime.sendMessage({type:'getPushStats'})` 刷新
|
||||||
|
- [ ] **Step 3:** 失败 K > 0 时红字 + 可点击查看 lastError(简化:弹 alert 或展开列表)
|
||||||
|
- [ ] **Step 4:** commit `feat(popup): push stats display (pending/failed/sent)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: 端到端验证
|
||||||
|
|
||||||
|
**Files:** `scripts/e2e-plugin.md`(验证步骤文档)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 起 Plan 1 后端:`cd d:/alibb-server/backend && docker compose up -d --build`,等 health ok
|
||||||
|
- [ ] **Step 2:** Chrome `chrome://extensions` 开发者模式 → 加载 `d:\alibb`(本 plan 改后的插件)
|
||||||
|
- [ ] **Step 3:** 点插件图标 → 后端设置填 `http://localhost:8080` + token `dev-ingest-token-1234` + 启用 → 授权域名 → 保存
|
||||||
|
- [ ] **Step 4:** 打开 `message.alibaba.com` → 抓一个询盘(用插件现有抓取流程,或 Playwright 注入触发)
|
||||||
|
- [ ] **Step 5:** 验证:`curl -H "Authorization: Bearer <jwt>" http://localhost:8080/api/inquiries` 能看到刚抓的询盘;`docker compose exec mysql mysql -uroot -proot inquiry -e "SELECT inquiry_id, messages_count FROM inquiries"` 数据在
|
||||||
|
- [ ] **Step 6:** popup 状态条显示「已推 1」
|
||||||
|
- [ ] **Step 7:** 验证幂等:再抓同一询盘 → 后端 messages_count 不变(早返回)
|
||||||
|
- [ ] **Step 8:** commit `test: e2e plugin→backend validation doc`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完成标准(DoD)
|
||||||
|
|
||||||
|
- [ ] manifest:`type:module` + alarms/unlimitedStorage + optional_host_permissions
|
||||||
|
- [ ] `push/queue.js` / `payload.js` / `worker.js` 三个 module + vitest 测试全绿
|
||||||
|
- [ ] `background.js`:alarms(1 分钟)消费队列,POST `/api/ingest`,失败指数退避(≤8 次);保留本地 storeDetail/getBgData
|
||||||
|
- [ ] `content.js`:apiMsgToChat 产 messageId/sendTime(ISO)/translation;fetchChatByApi 提取 gmtCreate/gmtModified;processCurrentDetailPage enqueue
|
||||||
|
- [ ] popup:后端设置(URL/token/启用)+ permissions.request 授权 + 状态显示(已推/待推/失败)
|
||||||
|
- [ ] 端到端:抓一条询盘 → Plan 1 后端能看到数据;再抓同一条幂等(messages_count 不变)
|
||||||
|
- [ ] 断网/后端停 → 数据进队列不丢;后端恢复 → 自动推完
|
||||||
|
|
||||||
|
## 后续(本 plan 不含)
|
||||||
|
|
||||||
|
- Plan 3:管理后台前端(查看/筛选/导出/统计)
|
||||||
|
- Plan 4:正式部署(域名 + HTTPS + 备份定时 + 监控)
|
||||||
|
- 增量推送(插件记录 last sendTime,只推新增消息)
|
||||||
|
- 失败项重试 UI(popup 查看失败 + 手动重推)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# 线上测试环境部署(1Panel + alibaba 库 + OpenResty 站点)
|
||||||
|
|
||||||
|
> 实际部署方案(2026-07-17)。对应 spec §11.2「测试环境:公网 IP + 端口 + HTTP」。
|
||||||
|
|
||||||
|
## 目标服务器
|
||||||
|
|
||||||
|
- 公网 IP:`219.157.255.213`,Ubuntu 24.04,主机 `kt-W580G20`
|
||||||
|
- 登录用户:`kt`(sudo 免密,不在 docker 组但 `sudo docker` 可用)
|
||||||
|
- 已装:Docker 28.0.1 + compose v2.33.1、Node v20(前端 build 用 docker node 镜像)
|
||||||
|
- 镜像加速:`/etc/docker/daemon.json` → `https://docker.1panel.live`
|
||||||
|
- 已有 1Panel + OpenResty(管理所有站点,占 80/443)
|
||||||
|
|
||||||
|
## SSH 访问(本机 → 服务器)
|
||||||
|
|
||||||
|
- 本机密钥:`C:\Users\30997\.ssh\alibb_deploy`(ed25519,无 passphrase)
|
||||||
|
- 连接(用 Windows 自带 OpenSSH,Git Bash 的 ssh 因私钥权限 644 被拒):
|
||||||
|
```bash
|
||||||
|
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 '...'
|
||||||
|
```
|
||||||
|
- 或本机 `~/.ssh/config` 加别名(Windows 路径 IdentityFile):
|
||||||
|
```
|
||||||
|
Host alibb
|
||||||
|
HostName 219.157.255.213
|
||||||
|
User kt
|
||||||
|
IdentityFile C:\Users\30997\.ssh\alibb_deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 ──http:23010──► 1Panel OpenResty 站点(alibb)
|
||||||
|
├─ / → 站点 index(前端 dist 静态)
|
||||||
|
└─ /api/* → backend 容器(:8080)
|
||||||
|
后端容器(alibb-backend,Express)──► 1Panel MySQL(alibaba 库,:3306)
|
||||||
|
扩展(抓 alibaba)──POST──► http://219.157.255.213:23010/api/ingest
|
||||||
|
```
|
||||||
|
|
||||||
|
- **后端**:`~/alibb-server/backend`,docker compose(backend 服务,build 本地 Dockerfile,映射 127.0.0.1:8080)。连 **1Panel 已有的 MySQL**(容器 `1Panel-mysql-sLiG`,库 `alibaba`,密码 `wecbni5zRRnZrMMh`)。env:JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD 强随机(存服务器 `~/alibb-secrets.txt`)。
|
||||||
|
- **前端**:`~/alibb-server/frontend`,docker build 出 dist → 复制到 **1Panel 站点目录** `/opt/1panel/apps/openresty/openresty/www/sites/alibb/index`。用 1Panel 的 OpenResty serve(不再单独起 nginx 容器)。
|
||||||
|
- **站点**:1Panel 建的「alibb」站点,`listen 23010`,`server_name 219.157.255.213`,root = 站点 index,`/api` 反代到 `backend:8080`(容器名),`try_files` SPA fallback,`client_max_body_size 50m`。站点 conf 在 `/opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf`(1Panel 管理,**改后要在面板重载 OpenResty**,别手改被覆盖)。
|
||||||
|
- **访问**:http://219.157.255.213:23010(admin / ADMIN_PASSWORD)
|
||||||
|
|
||||||
|
## 部署步骤(从零)
|
||||||
|
|
||||||
|
### 1. SSH 密钥(本机生成 + 上传 kt)
|
||||||
|
```bash
|
||||||
|
# 本机 Git Bash 生成
|
||||||
|
ssh-keygen -t ed25519 -f ~/.ssh/alibb_deploy -N ""
|
||||||
|
# 上传到 kt(用密码)
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe -i ... ~/.ssh/alibb_deploy.pub kt@219.157.255.213:... # 或 ssh-copy-id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 传代码(本机 → 服务器 ~/alibb-server)
|
||||||
|
```bash
|
||||||
|
cd /d && tar -czf ~/alibb-server.tgz --exclude='alibb-server/*/node_modules' --exclude='*/dist' --exclude='*/.git' alibb-server
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe -i ... 'C:\Users\30997\alibb-server.tgz' kt@219.157.255.213:/tmp/
|
||||||
|
# 服务器解压
|
||||||
|
... 'tar xzf /tmp/alibb-server.tgz -C ~'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 后端 build + up(连 alibaba 库)
|
||||||
|
```bash
|
||||||
|
cd ~/alibb-server/backend
|
||||||
|
# 生成线上密钥(替换 compose dev 值)
|
||||||
|
JWT_SECRET=$(openssl rand -hex 32); INGEST_TOKEN=$(openssl rand -hex 24); ADMIN_PASSWORD=$(openssl rand -hex 16)
|
||||||
|
sed -i -e "s/dev-ingest-token-1234/$INGEST_TOKEN/" -e "s/dev-jwt-secret-1234/$JWT_SECRET/" -e "s/admin-password-1234/$ADMIN_PASSWORD/" docker-compose.yml
|
||||||
|
# compose 改成连 1Panel mysql(alibaba 库)+ backend 端口映射 127.0.0.1:8080
|
||||||
|
sudo docker compose build backend
|
||||||
|
sudo docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 前端 build → 复制站点
|
||||||
|
```bash
|
||||||
|
cd ~/alibb-server/frontend
|
||||||
|
sudo docker run --rm -v "$PWD":/app -w /app node:22-alpine sh -c 'npm ci && npm run build'
|
||||||
|
SITE=/opt/1panel/apps/openresty/openresty/www/sites/alibb/index
|
||||||
|
sudo rm -rf "$SITE"/* && sudo cp -r dist/. "$SITE"/ && sudo chmod -R a+rX "$SITE"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 站点 conf(1Panel 面板配或 alibb.conf)
|
||||||
|
1Panel 面板建站点「alibb」(域名/IP `219.157.255.213`,端口 23010,根目录 = 站点 index),再配:
|
||||||
|
- 伪静态:`try_files $uri $uri/ /index.html;`(SPA 路由刷新)
|
||||||
|
- 反向代理:`/api/` → `http://backend:8080`(容器名,同 docker 网络;或 `http://127.0.0.1:8080`)
|
||||||
|
- 或直接编辑 `alibb.conf`(root + `/api` proxy + try_files + client_max_body_size 50m),面板重载 OpenResty。
|
||||||
|
|
||||||
|
### 6. 安全组放 23010(云控制台)
|
||||||
|
TCP 23010 入站;服务器防火墙 `sudo ufw allow 23010/tcp`(若开)。
|
||||||
|
|
||||||
|
### 7. 验证
|
||||||
|
```bash
|
||||||
|
curl http://219.157.255.213:23010/ # 200(index.html)
|
||||||
|
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||||
|
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||||
|
```
|
||||||
|
浏览器开 http://219.157.255.213:23010 → admin / ADMIN_PASSWORD 登录。
|
||||||
|
|
||||||
|
### 8. 扩展配置
|
||||||
|
popup:serverUrl `http://219.157.255.213:23010` + token(~/alibb-secrets.txt 的 INGEST_TOKEN)→ 授权 → 启用推送 → 抓取。
|
||||||
|
|
||||||
|
## 增量更新(改代码后)
|
||||||
|
|
||||||
|
- **后端改**:传变更文件 → `cd ~/alibb-server/backend && sudo docker compose build backend && sudo docker compose up -d`(migrate 自动跑)。
|
||||||
|
- **前端改**:传变更 → 服务器 `npm run build` → 复制 dist 到站点(Step 4)。
|
||||||
|
- **扩展改**:本机改 → chrome://extensions 刷新扩展(用户操作)。
|
||||||
|
|
||||||
|
## 常用运维
|
||||||
|
|
||||||
|
| 操作 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| 后端日志 | `sudo docker compose logs -f backend` |
|
||||||
|
| 重启后端 | `sudo docker compose restart backend` |
|
||||||
|
| 查库 | `sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-sLiG mysql -ualibaba alibaba` |
|
||||||
|
| 清询盘重抓 | `... mysql ... -e "SET FOREIGN_KEY_CHECKS=0; TRUNCATE chat_messages; TRUNCATE inquiries; SET FOREIGN_KEY_CHECKS=1;"`(customers 保留,重抓 upsert 更新) |
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- **折叠面板字段抓不到**:客户资料折叠面板里的 手机/座机/商业类型/销售平台/公司官网,alibaba 检查 `event.isTrusted`(只接受真实鼠标点击),扩展 JS 无法展开。这些字段大部分买家本就是"-"(alibaba 默认隐藏联系方式)。保留抓其他全部(基本信息 + 行为数据 + 店内行为 + 最近询盘产品图)。
|
||||||
|
- **风控**:抓取间隔已随机化(randSleep),降低被识别为爬虫的概率;大量抓取建议分批 + 中途暂停。
|
||||||
|
- **MySQL 公网暴露**:1Panel 的 mysql 映射 `0.0.0.0:3306`(1Panel 默认,非本次部署),建议后续在 1Panel 收紧为本地。
|
||||||
|
|
||||||
|
## 仓库
|
||||||
|
|
||||||
|
- 扩展:`d:/alibb`(master)
|
||||||
|
- 后端:`d:/alibb-server/backend`(master)
|
||||||
|
- 前端:`d:/alibb-server/frontend`(main)
|
||||||
|
- 服务器代码:`~/alibb-server/{backend,frontend}`
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
# 聊天时间范围抓取 Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 聊天抓取支持时间范围(全部/近1/2/3月/自定义起止),到范围起点即停翻页 —— 时间范围同时是风控减压手段。
|
||||||
|
|
||||||
|
**Architecture:** spec `docs/superpowers/specs/2026-08-25-chat-time-range-design.md`(7轮审查+真机实测,含附录A实现基准伪代码、附录B实测记录)。改动全部在扩展侧(popup + content.js),后端/推送零改动。判定逻辑以 `scripts/verify-chat-range.mjs` 纯函数为基准实现,content.js/popup.js 照搬(IIFE 架构无法被 vitest import,沿用 verify-risk-detect.mjs 复刻模式)。
|
||||||
|
|
||||||
|
**Tech Stack:** Chrome MV3 扩展(原生 JS)、vitest(fake-indexeddb)、node 复刻验证脚本。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⛔ 风控红线(全程有效,用户明确要求)
|
||||||
|
|
||||||
|
1. **不得改动任何现有安全层参数**:`randSleep(2000,5000)` 翻页间隔、`waitUntilWorkTime`、`postForm` 的 RISK 检测、`RISK_KEYWORDS`/`SKIP_KEYWORDS`。
|
||||||
|
2. **真机验证只用小规模**:手动验证(Task 8)一律 `limit=10`,单次验证 ≤ 10 个询盘,两次验证之间间隔 ≥ 2 分钟。
|
||||||
|
3. **不新增任何 API 端点调用**:只改现有 `querySummary`/`getOpMessages` 的参数(timeStamp)与本地逻辑。
|
||||||
|
4. Task 1-7 全部是本地代码/纯逻辑验证,**零真实请求**。
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| 文件 | 职责 |
|
||||||
|
|---|---|
|
||||||
|
| `scripts/verify-chat-range.mjs`(新) | 判定逻辑基准实现 + 用例锁定(纯函数,零请求) |
|
||||||
|
| `popup.html` | 时间范围下拉/日期框/置灰 UI |
|
||||||
|
| `popup.js` | computeChatRange 计算、校验、start 传参 |
|
||||||
|
| `content.js` | state 字段、fetchChatByApi 主路径、水位结构、DOM 回退 |
|
||||||
|
| `docs/.../spec`(已完成) | 依据,不再改 |
|
||||||
|
|
||||||
|
**同步义务**:verify 脚本中的纯函数(`minusMonths/computeChatRange/inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind/toMs`)与 popup.js/content.js 内联版本必须逐字一致 —— 改任一侧必须同步另一侧(文件头注明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: verify-chat-range.mjs — 判定逻辑基准(TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/verify-chat-range.mjs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 写用例 + 桩函数(预期红)**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
|
||||||
|
// content.js/popup.js 为 IIFE/非 module,无法被 vitest import —— 本文件是判定逻辑的
|
||||||
|
// 基准实现+用例锁定;实现侧照搬函数体。⚠️ 改任一侧必须同步另一侧。
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (name, cond) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); cond ? pass++ : fail++; };
|
||||||
|
|
||||||
|
// ---- 基准实现(实现照搬区)----
|
||||||
|
function minusMonths(base, n) { throw new Error('TODO'); }
|
||||||
|
function computeChatRange(mode, sinceStr, untilStr) { throw new Error('TODO'); }
|
||||||
|
function inChatRange(sendTime, sinceMs, untilMs) { throw new Error('TODO'); }
|
||||||
|
function parseWatermark(v) { throw new Error('TODO'); }
|
||||||
|
function shouldWriteWatermark(oldWm, candId, candSendTime) { throw new Error('TODO'); }
|
||||||
|
function probeFailKind(json) { throw new Error('TODO'); }
|
||||||
|
|
||||||
|
// ---- 用例 ----
|
||||||
|
ok('3-31 − 1月 = 2-28(月末钳制,setMonth 溢出防护)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 2, 31, 15, 30), 1);
|
||||||
|
return r.getMonth() === 1 && r.getDate() === 28 && r.getHours() === 15;
|
||||||
|
})());
|
||||||
|
ok('8-25 − 1月 = 7-25(普通日期)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 7, 25), 1);
|
||||||
|
return r.getMonth() === 6 && r.getDate() === 25;
|
||||||
|
})());
|
||||||
|
ok('12-31 − 2月 = 10-31(同年跨月)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 11, 31), 2);
|
||||||
|
return r.getMonth() === 9 && r.getDate() === 31;
|
||||||
|
})());
|
||||||
|
ok('1-31 − 2月 = 上年 11-30(跨年+钳制)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 0, 31), 2);
|
||||||
|
return r.getFullYear() === 2025 && r.getMonth() === 10 && r.getDate() === 30;
|
||||||
|
})());
|
||||||
|
ok('闰年:2028-03-31 − 1月 = 2028-02-29', (() => {
|
||||||
|
const r = minusMonths(new Date(2028, 2, 31), 1);
|
||||||
|
return r.getMonth() === 1 && r.getDate() === 29;
|
||||||
|
})());
|
||||||
|
|
||||||
|
ok('all → 双 null(完全同现状)', computeChatRange('all').since === null && computeChatRange('all').until === null);
|
||||||
|
ok('1m → since 在 25~32 天前,until 不限', (() => {
|
||||||
|
const r = computeChatRange('1m');
|
||||||
|
const age = Date.now() - Date.parse(r.since);
|
||||||
|
return r.until === null && age > 25 * 86400e3 && age < 32 * 86400e3;
|
||||||
|
})());
|
||||||
|
ok('custom 本地时区(since=本地0点,非 UTC)', (() => {
|
||||||
|
const r = computeChatRange('custom', '2026-08-25', '2026-08-25');
|
||||||
|
const s = new Date(r.since);
|
||||||
|
return s.getMonth() === 7 && s.getDate() === 25 && s.getHours() === 0 && s.getMinutes() === 0;
|
||||||
|
})());
|
||||||
|
ok('custom until=止日 23:59:59.999 本地(含全天)', (() => {
|
||||||
|
const r = computeChatRange('custom', '2026-08-01', '2026-08-25');
|
||||||
|
const u = new Date(r.until);
|
||||||
|
return u.getDate() === 25 && u.getHours() === 23 && u.getMinutes() === 59 && u.getMilliseconds() === 999;
|
||||||
|
})());
|
||||||
|
ok('custom 起>止 → error', !!computeChatRange('custom', '2026-08-10', '2026-08-01').error);
|
||||||
|
ok('custom 起为空 → error', !!computeChatRange('custom', '', '2026-08-01').error);
|
||||||
|
ok('custom 止>今天 → error', !!computeChatRange('custom', '2026-01-01', '2099-01-01').error);
|
||||||
|
|
||||||
|
const SINCE = Date.parse('2026-07-25T00:00:00'), UNTIL = Date.parse('2026-08-25T23:59:59');
|
||||||
|
ok('since 当天消息收(含边界)', inChatRange(SINCE, SINCE, null));
|
||||||
|
ok('since 前一天不收', !inChatRange(SINCE - 86400e3, SINCE, null));
|
||||||
|
ok('until 末毫秒收(含边界)', inChatRange(UNTIL, null, UNTIL));
|
||||||
|
ok('until 次日不收', !inChatRange(UNTIL + 1, null, UNTIL));
|
||||||
|
ok('sendTime=null 防御性保留', inChatRange(null, SINCE, UNTIL));
|
||||||
|
ok('范围不限(null)全收', inChatRange(1, null, null));
|
||||||
|
|
||||||
|
ok('新格式水位解析', (() => { const w = parseWatermark('{"id":"a1","sendTime":1787626659384}'); return w.id === 'a1' && w.sendTime === 1787626659384; })());
|
||||||
|
ok('旧格式(纯string)→ legacy,sendTime 未知', (() => { const w = parseWatermark('abc123'); return w.id === 'abc123' && w.sendTime === null; })());
|
||||||
|
ok('只前进:候选6-30 不覆盖 7-28', !shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-06-30')));
|
||||||
|
ok('只前进:候选8-25 覆盖 7-28', shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-08-25')));
|
||||||
|
ok('legacy 允许覆盖一次(升级)', shouldWriteWatermark({ id: 'o', sendTime: null }, 'n', Date.parse('2026-06-30')));
|
||||||
|
ok('无旧水位 → 写入', shouldWriteWatermark(null, 'n', 1));
|
||||||
|
ok('候选缺 sendTime → 不写', !shouldWriteWatermark({ id: 'o', sendTime: 5 }, 'n', null));
|
||||||
|
|
||||||
|
ok('probe 正常(200+非空) → null', probeFailKind({ code: '200', data: { list: [{}] } }) === null);
|
||||||
|
ok('probe code≠200 → fail 降级', probeFailKind({ code: '500' }) === 'fail');
|
||||||
|
ok('probe data=null → fail 降级', probeFailKind({ code: '200', data: null }) === 'fail');
|
||||||
|
ok('probe 非对象 → fail 降级', probeFailKind('ok') === 'fail');
|
||||||
|
ok('probe 空 list → empty 降级(实测形态)', probeFailKind({ code: '200', data: { list: [] } }) === 'empty');
|
||||||
|
ok('probe list 缺失 → empty 降级', probeFailKind({ code: '200', data: {} }) === 'empty');
|
||||||
|
|
||||||
|
// toMs:parse 容错(spec §5:非法 → null 降级不限)
|
||||||
|
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; }
|
||||||
|
ok('toMs 非法/空 → null,合法 → 毫秒', toMs('garbage') === null && toMs(null) === null && toMs('2026-08-25T00:00:00Z') === Date.parse('2026-08-25T00:00:00Z'));
|
||||||
|
// until 游标 = untilMs+1:附录B实测语义=返回 sendTime < cursor,故恰好覆盖 <= untilMs 全部、不漏不重
|
||||||
|
ok('until 游标 +1ms 恰好覆盖含止日末毫秒', (() => {
|
||||||
|
const untilMs = Date.parse('2026-06-30T23:59:59.999');
|
||||||
|
const cursor = untilMs + 1;
|
||||||
|
return (untilMs < cursor) && !(untilMs + 1 < cursor);
|
||||||
|
})());
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 跑,确认红**
|
||||||
|
|
||||||
|
Run: `node scripts/verify-chat-range.mjs`
|
||||||
|
Expected: 脚本在**第一个用例即崩溃**(stack trace: `Error: TODO`)—— 桩函数的 throw 发生在用例 IIFE 内,无法逐行 FAIL;崩溃 + exit 1 即为有效红灯
|
||||||
|
|
||||||
|
- [ ] **Step 3: 填基准实现(替换 6 个桩函数)**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
|
||||||
|
function minusMonths(base, n) {
|
||||||
|
const d = new Date(base.getFullYear(), base.getMonth(), 1,
|
||||||
|
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
|
||||||
|
d.setMonth(d.getMonth() - n);
|
||||||
|
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||||
|
d.setDate(Math.min(base.getDate(), lastDay));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
|
||||||
|
function computeChatRange(mode, sinceStr, untilStr) {
|
||||||
|
if (mode === 'all') return { since: null, until: null };
|
||||||
|
if (mode === '1m' || mode === '2m' || mode === '3m') {
|
||||||
|
return { since: minusMonths(new Date(), Number(mode[0])).toISOString(), until: null };
|
||||||
|
}
|
||||||
|
if (mode === 'custom') {
|
||||||
|
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
|
||||||
|
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
|
||||||
|
const u = new Date(untilStr + 'T23:59:59.999');
|
||||||
|
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
|
||||||
|
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
|
||||||
|
const uDay = new Date(untilStr + 'T00:00:00.000');
|
||||||
|
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
|
||||||
|
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
|
||||||
|
return { since: s.toISOString(), until: u.toISOString() };
|
||||||
|
}
|
||||||
|
return { error: '未知时间范围模式' };
|
||||||
|
}
|
||||||
|
// 范围过滤(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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 跑,确认全绿(36 passed)**
|
||||||
|
|
||||||
|
Run: `node scripts/verify-chat-range.mjs`
|
||||||
|
Expected: `36 passed, 0 failed`,exit 0
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/verify-chat-range.mjs
|
||||||
|
git commit -m "feat(ext): chat time-range predicate baseline + verification cases"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: popup UI + 传参
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `popup.html`(options 区,chatLimitInput 行附近)
|
||||||
|
- Modify: `popup.js`(元素引用、syncChatModeUI、start 计算)
|
||||||
|
|
||||||
|
- [ ] **Step 1: popup.html 加控件**
|
||||||
|
|
||||||
|
在"每询盘聊天上限"label 行后追加(chatLimit label 内加 hint span):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<label>每询盘聊天上限(0=全量;测试可设小值加快): <input type="number" id="chatLimitInput" value="0" min="0" style="width:64px;"> 条 <span id="chatLimitHint" style="color:#999;"></span></label>
|
||||||
|
<label>聊天时间范围:
|
||||||
|
<select id="chatTimeRange" style="width:120px;">
|
||||||
|
<option value="all" selected>全部(不限)</option>
|
||||||
|
<option value="1m">近 1 个月</option>
|
||||||
|
<option value="2m">近 2 个月</option>
|
||||||
|
<option value="3m">近 3 个月</option>
|
||||||
|
<option value="custom">自定义日期…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div id="chatCustomDates" style="display:none;margin:4px 0;">
|
||||||
|
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: popup.js 加引用 + 模式切换 + 计算传参**
|
||||||
|
|
||||||
|
元素引用区(文件顶部 getElementById 区)追加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const chatTimeRangeSelect = document.getElementById('chatTimeRange');
|
||||||
|
const chatCustomDates = document.getElementById('chatCustomDates');
|
||||||
|
const chatSinceDate = document.getElementById('chatSinceDate');
|
||||||
|
const chatUntilDate = document.getElementById('chatUntilDate');
|
||||||
|
const chatLimitHint = document.getElementById('chatLimitHint');
|
||||||
|
```
|
||||||
|
|
||||||
|
(在 push-config 区之前)追加模式切换 + 基准函数照搬(注明同步义务,函数体与 verify-chat-range.mjs **逐字一致**):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ==== 聊天时间范围(spec 2026-08-25 §4.1)====
|
||||||
|
// minusMonths/computeChatRange 与 scripts/verify-chat-range.mjs 基准实现逐字同步
|
||||||
|
function minusMonths(base, n) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
function computeChatRange(mode, sinceStr, untilStr) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
|
||||||
|
function syncChatModeUI() {
|
||||||
|
const mode = chatTimeRangeSelect.value;
|
||||||
|
const custom = mode === 'custom';
|
||||||
|
chatCustomDates.style.display = custom ? 'block' : 'none';
|
||||||
|
if (custom && !chatUntilDate.value) {
|
||||||
|
const t = new Date();
|
||||||
|
chatUntilDate.value = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
const timeMode = mode !== 'all';
|
||||||
|
document.getElementById('chatLimitInput').disabled = timeMode; // D1: 时间替代条数
|
||||||
|
chatLimitHint.textContent = timeMode ? '(时间模式下忽略)' : '';
|
||||||
|
}
|
||||||
|
chatTimeRangeSelect.addEventListener('change', syncChatModeUI);
|
||||||
|
syncChatModeUI();
|
||||||
|
```
|
||||||
|
|
||||||
|
btnStart click 里,`send('start', {...})` **之前**插入校验,并改传参:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value);
|
||||||
|
if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; }
|
||||||
|
```
|
||||||
|
|
||||||
|
`send('start', {...})` 的参数改为:
|
||||||
|
|
||||||
|
```js
|
||||||
|
await send('start', {
|
||||||
|
mode: modeSelect.value,
|
||||||
|
dedup: optDedup.checked,
|
||||||
|
chat: optChat.checked,
|
||||||
|
limit: parseInt(limitSelect.value, 10),
|
||||||
|
chatLimit: chatTimeRangeSelect.value === 'all' ? (parseInt(document.getElementById('chatLimitInput').value, 10) || 0) : 0,
|
||||||
|
chatSince: range.since,
|
||||||
|
chatUntil: range.until,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 语法自检**
|
||||||
|
|
||||||
|
Run: `node --check popup.js 2>&1 || echo "popup.js 非 module,--check 对 ESM 误报可忽略;改用浏览器加载验证"`
|
||||||
|
手动:重载扩展 → 打开 popup → 切下拉:custom 显示日期框且止=今天;时间模式下条数框置灰;切回 all 恢复。(零请求)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add popup.html popup.js
|
||||||
|
git commit -m "feat(ext): chat time-range picker in popup (all/1m/2m/3m/custom)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: content.js — state 字段 + 派生
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `content.js`(start 分支 state 重建对象;fetchChatByApi/loadAllChatHistory 入口)
|
||||||
|
|
||||||
|
- [ ] **Step 1: start 分支重建对象加两字段**
|
||||||
|
|
||||||
|
`case 'start':` 的 `state = {...}` 对象中,`chatLimit: msg.chatLimit ?? 0,` 行后加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
chatSince: msg.chatSince ?? null, chatUntil: msg.chatUntil ?? null, // 时间范围(spec §4.2);显式列入重建清单
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 文件顶部(IIFE 内、RISK_KEYWORDS 之前)加范围工具(照搬 verify + 派生)**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// ==== 聊天时间范围工具(与 scripts/verify-chat-range.mjs 基准逐字同步)====
|
||||||
|
function inChatRange(sendTime, sinceMs, untilMs) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
function parseWatermark(v) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
function shouldWriteWatermark(oldWm, candId, candSendTime) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
function probeFailKind(json) { /* 同 verify-chat-range.mjs */ }
|
||||||
|
function toMs(v) { const n = v ? Date.parse(v) : NaN; return Number.isFinite(n) ? n : null; } // 同 verify-chat-range.mjs 照搬区(parse 失败→null 降级不限,spec §5)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: fetchChatByApi 与 loadAllChatHistory 入口各派生一次**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const sinceMs = toMs(state.chatSince), untilMs = toMs(state.chatUntil);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 手动自检(零请求)**
|
||||||
|
|
||||||
|
重载扩展 → popup"开始抓取"选"全部" → 行为与现状一致(state 字段为 null 短路所有新逻辑);SW console 无报错。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add content.js
|
||||||
|
git commit -m "feat(ext): chatSince/chatUntil state plumbed through start"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: content.js — 水位存储升级(只前进)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `content.js`(`getLastTopMsgId`/`setLastTopMsgId` 改造为 `getWatermark`/`trySetWatermark`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 替换两个函数**
|
||||||
|
|
||||||
|
删除 `getLastTopMsgId`/`setLastTopMsgId`,替换为(键名 `lastTopMsg_` 不变,clear 分支无需改):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 增量水位(新格式 {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) { /* 持久化失败不致命 */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注:调用点改造在 Task 5(fetchChatByApi 内),本 Task 仅换函数定义会暂留旧调用 → **Task 4 与 Task 5 合并提交**(见 Task 5 Step 5)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: content.js — fetchChatByApi 主路径(spec 附录 A 落地)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `content.js`(`// 2) 循环 getOpMessages` 起 至 `return { messages: ... }` 止的整段)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 替换翻页整段(对照附录 A 伪代码逐行落地)**
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 增量水位(新格式;旧 string 兼容读取)
|
||||||
|
const lastTopWm = await getWatermark(inquiryId);
|
||||||
|
const lastTopId = lastTopWm ? lastTopWm.id : '';
|
||||||
|
if (lastTopId) log(`聊天 API 增量模式: 上次水位 ${lastTopId}(sendTime=${lastTopWm && lastTopWm.sendTime}),遇此停止翻页`);
|
||||||
|
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;
|
||||||
|
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(真风控照抛,spec §4.3)
|
||||||
|
probeRound = false;
|
||||||
|
const kind = probeFailKind(json);
|
||||||
|
if (kind) {
|
||||||
|
state.cursorJumpUnsupported = false; // 先不设标志,由降级重试结果决定(实测精化)
|
||||||
|
log(`until 跳游标首轮 ${kind === 'empty' ? '空 list' : '结构异常'},降级 cursor=now 重试`);
|
||||||
|
cursor = Date.now();
|
||||||
|
await randSleep(2000, 5000);
|
||||||
|
// 重试首轮非空才实锤"不支持"(真空时重试也空,不设标志,下个询盘继续尝试)
|
||||||
|
// —— 实现方式:本标志在下一轮循环体内设置(见下)
|
||||||
|
state._probeDowngraded = true;
|
||||||
|
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) {
|
||||||
|
// 降级重试后的空 list = 真空(正常到顶);且若 _probeDowngraded 未实锤则清试探态
|
||||||
|
if (state._probeDowngraded) { state._probeDowngraded = false; /* 重试也空 → 真空,不设 unsupported */ }
|
||||||
|
stopReason = '到顶(空列表)'; break;
|
||||||
|
}
|
||||||
|
if (state._probeDowngraded) { // 降级重试首批非空 → 跳游标确实漏数据,实锤不支持
|
||||||
|
state._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)
|
||||||
|
```
|
||||||
|
|
||||||
|
同时删除原 `newestMsgIdThisRound` 相关整段与旧 `setLastTopMsgId` 调用;`lastTopMsgId` 读取段(原 `const lastTopMsgId = await getLastTopMsgId(...)`)由开头 `getWatermark` 段替代。**其后的 `return { messages: raw.map(...), meta };` 原样保留,不在替换范围内。**
|
||||||
|
|
||||||
|
- [ ] **Step 2: 同步义务检查**
|
||||||
|
|
||||||
|
对照 `scripts/verify-chat-range.mjs`:`inChatRange/parseWatermark/shouldWriteWatermark/probeFailKind` 函数体逐字一致(Task 3 已内联)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 逻辑回归(零请求)**
|
||||||
|
|
||||||
|
Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run`
|
||||||
|
Expected: 36 passed / 10 passed / 16 passed,全部 exit 0
|
||||||
|
|
||||||
|
- [ ] **Step 4: 语法自检(浏览器加载)**
|
||||||
|
|
||||||
|
重载扩展,SW console 无报错;popup"开始抓取(全部, limit=10)"跑一轮 —— 行为与现状一致(all → sinceMs/untilMs null,新分支全短路)。
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit(Task 4+5 合并)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add content.js
|
||||||
|
git commit -m "feat(ext): time-bounded chat paging - cursor jump + probe fallback + forward-only watermark"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: content.js — DOM 回退路径
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `content.js`(`loadAllChatHistory`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 全量分支加时间探测停滚**
|
||||||
|
|
||||||
|
`loadAllChatHistory` 全量分支(无 lastDomTime 时)滚动循环内,`await randSleep(2000, 6000)` 之后、计数逻辑之前加:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 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; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 提取后过滤(全量/增量两分支)**
|
||||||
|
|
||||||
|
全量分支尾部(水位 maxT 计算保持用**未过滤** `all`):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const all = extractDomChatMessages();
|
||||||
|
// ... maxT 用 all 计算并存 lastDomTime(不变,水位=见到的最新)...
|
||||||
|
const filtered = all.filter(m => inChatRange(m.sendTime ? Date.parse(m.sendTime) : null, sinceMs, untilMs));
|
||||||
|
state._incDomMessages = filtered;
|
||||||
|
state._incDomFiltered = (sinceMs != null || untilMs != null); // 空数组也具决定性(见下)
|
||||||
|
```
|
||||||
|
|
||||||
|
**配套改消费点**(scrapeDetailData 内,原 `incMsgs && incMsgs.length` 处)—— 否则空 `filtered` 会穿透 `incMsgs.length` 保护、回退到**未过滤**的 `extractDomChatMessages()`,把范围外可见消息泄漏出去(违背 §4.4"0 条降级"):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const incMsgs = state._incDomMessages || null;
|
||||||
|
const incFiltered = !!state._incDomFiltered; // 空数组 + 过滤标记 → 空也有效(0条降级)
|
||||||
|
state._incDomMessages = null; state._incDomFiltered = false;
|
||||||
|
// ...(apiMsgs 判定不变)...
|
||||||
|
if (!msgs) msgs = (incMsgs && (incMsgs.length || incFiltered)) ? incMsgs : null;
|
||||||
|
```
|
||||||
|
|
||||||
|
增量分支:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const inc = all.filter(m => m.sendTime && m.sendTime > lastDomTime)
|
||||||
|
.filter(m => inChatRange(Date.parse(m.sendTime), sinceMs, untilMs));
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 回归 + Commit**
|
||||||
|
|
||||||
|
Run: `node scripts/verify-chat-range.mjs && npx vitest run`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add content.js
|
||||||
|
git commit -m "feat(ext): DOM fallback respects chat time range (probe-stop + filter)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 全量回归(零请求)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 三套验证全绿**
|
||||||
|
|
||||||
|
Run: `node scripts/verify-chat-range.mjs && node scripts/verify-risk-detect.mjs && npx vitest run`
|
||||||
|
Expected: 36 / 10 / 16 全 passed,exit 0
|
||||||
|
|
||||||
|
- [ ] **Step 2: 对照 spec 附录 A 逐行走查一遍 fetchChatByApi 实际代码(人工,15 分钟)**
|
||||||
|
|
||||||
|
锚点清单:初始游标+1 / probe 只首轮 / PROBE 降级集不含 non-JSON / 水位命中未过滤 / 过滤 null guard / minT 跳 null / 停止优先级(增量>时间>到顶)/ stopReason 日志 / trySetWatermark 只前进 / randSleep 与 waitUntilWorkTime 原样。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: 手动验证(⚠️ 风控纪律:limit=10、间隔≥2分钟、不批量)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 回归基线** — 选"全部"+limit=10,跑一轮,行为与旧版一致(注:all 模式**无**"聊天范围"日志,仅范围模式输出)。
|
||||||
|
- [ ] **Step 2: 近1月** — 选"近 1 个月"+limit=10(挑含老客户的窗口),日志出现 `停:时间边界`,单询盘条数明显少于全量。两次运行间隔 ≥2 分钟。
|
||||||
|
- [ ] **Step 3: 校验** — 选 custom,起>止 → popup 报错不发;止选明天 → 报错。(零请求)
|
||||||
|
- [ ] **Step 4: until 跳游标复测(附录B复测)** — custom 起=2个月前、止=1个月前 + limit=10:每个询盘**首轮** SW Network 里 `getOpMessages` 请求体 timeStamp ≈ 止日+1ms(注:无成功标志,后续询盘首轮**仍会**以止日起跳 —— 这是设计行为,不是重复试探 bug);首轮返回正常 → 本询盘后续轮次从 minT 正常翻页、无"降级 cursor=now"日志;若出现降级且降级重试首批非空 → 后续询盘直接 now 起跳(标志生效)。
|
||||||
|
- [ ] **Step 5: 增量** — 同询盘重复 Step 2,第二次 `停:增量命中` 且推送无重复(后端 count 不变)。
|
||||||
|
- [ ] **Step 6: 验证结果记录回 spec 附录 B 复测栏(如行为有出入,STOP 并回到 systematic-debugging)**
|
||||||
|
- [ ] **Step 7: 范围存活验证** — 范围模式任务中途暂停→恢复、扩展重载后:抓取沿用同一范围(state 持久化链路),日志仍显示同一"聊天范围"。
|
||||||
|
- [ ] **Step 8: 水位升级确认** — 首轮范围抓取后旧水位(string)升级为 JSON,下轮增量正常命中(停:增量命中)。
|
||||||
|
- [ ] **Step 9: 上线前开关** — 全部验证完成后把 `WORK_TIME_ENABLED` 改回 `true`(生产作息限制;当前为用户测试态 false)。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 服务器重装备份方案(alibb 项目)
|
||||||
|
|
||||||
|
> 2026-08-25。服务器 `219.157.255.213`(kt-W580G20)需要重装系统。
|
||||||
|
> **范围:只备 alibb**(后端 + 前端 + alibaba 库 + 站点配置);服务器上其他业务(1Panel 全家桶、邮件、小红书等)与本备份无关。
|
||||||
|
> 备份落盘:本机 `D:\alibb-server\backups\alibb-backup-20260825\`(D 盘重装不受影响)。
|
||||||
|
|
||||||
|
## 一、备份内容(已完成 ✅)
|
||||||
|
|
||||||
|
服务器端打包于 `~/alibb-backup-20260825`,已 scp 拉回本机 `D:\alibb-server\backups\`,SHA256 全部校验通过。
|
||||||
|
|
||||||
|
| 文件 | 大小 | 内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| `alibaba-db.sql` | 25M | alibaba 库全量 dump(`--single-transaction --no-tablespaces`):chat_messages 61872 行、customers 14794 行、inquiries 13570 行、users、_prisma_migrations |
|
||||||
|
| `alibb-server-code.tgz` | 998K | `~/alibb-server` 代码(backend+frontend),**docker-compose.yml 内含生产密钥**(JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD) |
|
||||||
|
| `alibb-secrets.txt` | 296B | 生产密钥明文 |
|
||||||
|
| `alibb-site.conf` | 750B | OpenResty 站点 conf:`listen 23010`、`/api` → `http://backend:8080`、SPA fallback、`client_max_body_size 50m` |
|
||||||
|
| `alibb-frontend-dist.tgz` | 811K | 1Panel 站点目录(前端 dist;可由代码重 build,备作保险) |
|
||||||
|
| `alibb-backend-inspect.json` | 11K | 容器完整配置快照(env/网络/挂载) |
|
||||||
|
| `RESTORE-NOTES.md` | — | 恢复要点:MySQL 授权重建、网络别名、密钥清单 |
|
||||||
|
|
||||||
|
dump 完整性:文件尾 `-- Dump completed on 2026-08-25 14:14:01`,5 张 CREATE TABLE 齐全。
|
||||||
|
|
||||||
|
## 二、关键事实(重装前记牢)
|
||||||
|
|
||||||
|
1. **密钥全部沿用备包里的值** → 重装后扩展的 serverUrl + INGEST_TOKEN 不用改,老数据 JWT 也不失效。
|
||||||
|
2. **mysqldump 不含用户授权** → 重装后必须手工执行(见 RESTORE-NOTES.md):
|
||||||
|
```sql
|
||||||
|
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||||
|
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
3. backend 加入 **external 网络 `1panel-network`**(1Panel 装好后自动存在),容器别名 `alibb-backend`/`backend`——OpenResty 反代按容器名 `backend` 寻址,别名丢了 /api 就 502。
|
||||||
|
4. 端口映射是 `127.0.0.1:8090:8080`(宿主 8090,比 7 月部署文档写的 8080 新,以备包为准)。
|
||||||
|
5. 1Panel 的 MySQL root 密码重装后会变(装 1Panel 时新设),与业务无关;业务只依赖上面的 `alibaba` 账号。
|
||||||
|
|
||||||
|
## 三、恢复步骤(新系统从零)
|
||||||
|
|
||||||
|
### 1. 基础环境
|
||||||
|
```bash
|
||||||
|
# Ubuntu 24.04 + Docker(官方脚本)+ 1Panel(官方安装脚本,装时设新的面板/MySQL root 密码)
|
||||||
|
# 1Panel 应用商店装:OpenResty、MySQL 8.x
|
||||||
|
# 云安全组放行:23010/tcp(以及 1Panel 面板端口)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 恢复代码
|
||||||
|
```bash
|
||||||
|
# 本机推备包上去(Windows OpenSSH)
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' -r 'D:\alibb-server\backups\alibb-backup-20260825' kt@219.157.255.213:~/
|
||||||
|
ssh 上去:
|
||||||
|
tar xzf ~/alibb-backup-20260825/alibb-server-code.tgz -C ~ # → ~/alibb-server,compose 已是生产值,勿再跑 sed
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 恢复数据库
|
||||||
|
```bash
|
||||||
|
# 建库 + 授权 + 导入(注意库名 alibaba 与用户名 alibaba 同名)
|
||||||
|
sudo docker exec -i 1Panel-mysql-<新后缀> mysql -uroot -p'<新root密码>' <<'SQL'
|
||||||
|
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||||
|
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
sudo docker exec -i 1Panel-mysql-<新后缀> mysql -uroot -p'<新root密码>' alibaba < ~/alibb-backup-20260825/alibaba-db.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 起后端
|
||||||
|
```bash
|
||||||
|
cd ~/alibb-server/backend
|
||||||
|
sudo docker compose build backend && sudo docker compose up -d # migrate 自动跑(应显示全部 already applied)
|
||||||
|
```
|
||||||
|
注意:compose 里 networks 声明 `1panel-network` external——1Panel 装好后已存在,无需改。
|
||||||
|
|
||||||
|
### 5. 站点 + 前端
|
||||||
|
- 1Panel 面板建网站「alibb」:域名/IP `219.157.255.213`,端口 `23010`,根目录默认站点 index
|
||||||
|
- 直接用备份 conf:把 `alibb-site.conf` 内容对到 `/opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf`(经面板配伪静态+反代等价),**面板里重载 OpenResty**
|
||||||
|
- 前端二选一:解包 `alibb-frontend-dist.tgz` 复制进站点目录;或 `~/alibb-server/frontend` 里 docker node build 后复制(同 7 月文档 Step 4)
|
||||||
|
|
||||||
|
### 6. 验证
|
||||||
|
```bash
|
||||||
|
curl http://219.157.255.213:23010/ # 200
|
||||||
|
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||||
|
```
|
||||||
|
浏览器登录 admin / ADMIN_PASSWORD(alibb-secrets.txt),确认询盘/客户/聊天记录数量与备份时一致(询盘 13570 / 客户 14794 / 消息 61872)。
|
||||||
|
|
||||||
|
### 7. 扩展侧
|
||||||
|
无需改配置(serverUrl `http://219.157.255.213:23010` + 原 token 均不变)。Chrome 里点一次「测试连接」确认,再恢复日常抓取。
|
||||||
|
|
||||||
|
## 四、重装前 Checklist
|
||||||
|
|
||||||
|
- [x] 服务器备包完成并拉回本机,SHA256 校验通过
|
||||||
|
- [x] MySQL 授权语句已记录(RESTORE-NOTES.md)
|
||||||
|
- [ ] **再拷一份 `D:\alibb-server\backups\` 到外接硬盘或网盘**(D 盘虽不动,双保险,防重装误格全盘)
|
||||||
|
- [ ] 重装窗口内**停止扩展抓取**(推送会失败;若误抓,数据在本地,恢复后可重推;被风控的会话除外)
|
||||||
|
- [ ] 本机 `d:\alibb-server\backend` 有未提交改动(schema.prisma / ingest.service.ts / 新 migration)——与本次备份无关,但建议提交或至少保留现状
|
||||||
|
- [x] 重装方式无要求:恢复完全依赖本机 D 盘备份,服务器**彻底重置也可接受**(其他业务数据是否保留与 alibb 无关)
|
||||||
|
|
||||||
|
## 五、风险与说明
|
||||||
|
|
||||||
|
- **备份截止 2026-08-25 14:14**。之后新增的询盘/抓取数据不在 dump 里;重装前若又抓了数据,可重新导一次(命令见下)。
|
||||||
|
- 服务器备包副本留在 `kt@219.157.255.213:~/alibb-backup-20260825/`,若重装保留 /home 可顺带幸存(不依赖)。
|
||||||
|
- 增量重导命令:
|
||||||
|
```bash
|
||||||
|
ssh … 'sudo docker exec -e MYSQL_PWD=wecbni5zRRnZrMMh 1Panel-mysql-sLiG mysqldump -ualibaba --single-transaction --no-tablespaces --routines --triggers --databases alibaba' > D:\alibb-server\backups\alibaba-db-final.sql
|
||||||
|
```
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# alibb 服务器重装后完整恢复部署方案(Runbook)
|
||||||
|
|
||||||
|
> 2026-08-25。配合 [2026-08-25-server-reinstall-backup.md](2026-08-25-server-reinstall-backup.md) 使用。
|
||||||
|
> 目标:新装系统后,把 alibb 恢复到与重装前**业务完全一致**(数据、密钥、访问方式、扩展零改动)。
|
||||||
|
> 备份位置:本机 `D:\alibb-server\backups\`(SHA256 已校验)。
|
||||||
|
|
||||||
|
## 0. 恢复后与重装前的差异(预期内,不影响业务)
|
||||||
|
|
||||||
|
| 项 | 重装前 | 重装后 | 影响 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MySQL 容器名 | `1Panel-mysql-sLiG` | `1Panel-mysql-<新随机后缀>` | compose 的 DATABASE_URL 要 sed 替换(见 §4.2) |
|
||||||
|
| 1Panel 面板/MySQL root 密码 | 旧值 | 装机新设 | 无(alibb 只用 `alibaba` 账号) |
|
||||||
|
| SSH host key / kt 用户 | 有 | 新建 | 本机要清 known_hosts + 重新传公钥(§1) |
|
||||||
|
| 公网 IP | 219.157.255.213 | 云盘重装一般保留 | **若 IP 变了**,要改 3 处(§8) |
|
||||||
|
| 数据 | — | 恢复到 2026-08-25 14:14 备份时点 | 之后的新数据需重装前重导(见备份方案 §五) |
|
||||||
|
|
||||||
|
## 1. 前置:本机能连上新服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) 清掉旧 host key(重装后会报 REMOTE HOST IDENTIFICATION HAS CHANGED)
|
||||||
|
/c/Windows/System32/OpenSSH/ssh.exe -i 'C:\Users\30997\.ssh\alibb_deploy' kt@219.157.255.213 'true' 2>&1 | head -3
|
||||||
|
# 报错则执行:
|
||||||
|
/c/Windows/System32/OpenSSH/ssh-keygen.exe -R 219.157.255.213
|
||||||
|
|
||||||
|
# 2) 新系统建 kt 用户(sudo 免密)+ 传公钥(用 root/云控制台密码登录一次)
|
||||||
|
ssh root@219.157.255.213 'useradd -m -s /bin/bash kt && echo "kt ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/kt && mkdir -p /home/kt/.ssh'
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe 'C:\Users\30997\.ssh\alibb_deploy.pub' root@219.157.255.213:/home/kt/.ssh/authorized_keys
|
||||||
|
ssh root@219.157.255.213 'chown -R kt:kt /home/kt/.ssh && chmod 700 /home/kt/.ssh && chmod 600 /home/kt/.ssh/authorized_keys'
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 系统基础(Ubuntu 24.04)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Docker(官方脚本)
|
||||||
|
curl -fsSL https://get.docker.com | sudo sh
|
||||||
|
# 镜像加速(照旧)
|
||||||
|
sudo tee /etc/docker/daemon.json <<'EOF'
|
||||||
|
{ "registry-mirrors": ["https://docker.1panel.live"] }
|
||||||
|
EOF
|
||||||
|
sudo systemctl restart docker
|
||||||
|
# 1Panel(官方脚本,装时设新面板密码)
|
||||||
|
curl -sSL https://resource.fit2cloud.com/1panel/package/quick_start.sh -o quick_start.sh && sudo bash quick_start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 1Panel 应用安装
|
||||||
|
|
||||||
|
面板 → 应用商店,安装:
|
||||||
|
|
||||||
|
- **OpenResty**(必须先于建站)
|
||||||
|
- **MySQL 5.7.44**(与重装前一致,优先选 5.7)
|
||||||
|
- ⚠️ 若商店已无 5.7 → 装 8.0 也可:dump 为 utf8mb4,兼容;仅 §4.1 建用户时多加一句 `IDENTIFIED WITH mysql_native_password BY '...'`(防老驱动连不上,Prisma/mysql2 两种都支持,加这句最稳)
|
||||||
|
|
||||||
|
装好后记下 **MySQL 容器实际名称**(下称 `<MYSQL容器名>`,形如 `1Panel-mysql-XXXX`)和 root 密码。
|
||||||
|
|
||||||
|
## 4. 恢复数据库
|
||||||
|
|
||||||
|
### 4.1 传备包 + 建库授权导入
|
||||||
|
```bash
|
||||||
|
# 本机推备包
|
||||||
|
/c/Windows/System32/OpenSSH/scp.exe -i 'C:\Users\30997\.ssh\alibb_deploy' -r 'D:\alibb-server\backups' kt@219.157.255.213:~/
|
||||||
|
```
|
||||||
|
```bash
|
||||||
|
# 服务器上(替换 <MYSQL容器名> 和 <root密码>)
|
||||||
|
M=<MYSQL容器名>; R=<root密码>
|
||||||
|
sudo docker exec -i $M mysql -uroot -p"$R" <<'SQL'
|
||||||
|
CREATE DATABASE alibaba CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||||
|
CREATE USER 'alibaba'@'%' IDENTIFIED BY 'wecbni5zRRnZrMMh';
|
||||||
|
GRANT ALL PRIVILEGES ON `alibaba`.* TO 'alibaba'@'%' WITH GRANT OPTION;
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
SQL
|
||||||
|
sudo docker exec -i $M mysql -uroot -p"$R" alibaba < ~/backups/alibaba-db.sql
|
||||||
|
```
|
||||||
|
> 注:mysqldump 不含用户授权,以上 CREATE USER/GRANT 来自重装前实机导出(RESTORE-NOTES.md)。
|
||||||
|
|
||||||
|
### 4.2 验证行数(必须与下表一致,否则停)
|
||||||
|
```bash
|
||||||
|
sudo docker exec -i $M mysql -ualibaba -pwecbni5zRRnZrMMh alibaba -e \
|
||||||
|
"SELECT (SELECT COUNT(*) FROM inquiries) inquiries,(SELECT COUNT(*) FROM customers) customers,(SELECT COUNT(*) FROM chat_messages) chat_messages;"
|
||||||
|
```
|
||||||
|
期望:**inquiries 13570 / customers 14794 / chat_messages 61872**(若重装前重导过,以最终 dump 为准)。
|
||||||
|
|
||||||
|
## 5. 恢复后端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 解包代码(~/alibb-server,compose 已是生产值,严禁再跑 sed 替换 dev 值)
|
||||||
|
tar xzf ~/backups/alibb-server-code.tgz -C ~
|
||||||
|
|
||||||
|
# ★ 关键一步:DATABASE_URL 里的旧容器名替换为新容器名
|
||||||
|
cd ~/alibb-server/backend
|
||||||
|
M=<MYSQL容器名>
|
||||||
|
sed -i "s/1Panel-mysql-sLiG/$M/g" docker-compose.yml
|
||||||
|
grep DATABASE_URL docker-compose.yml # 人工确认已指向新容器名
|
||||||
|
|
||||||
|
# build + 起(首次 migrate 会显示全部 already applied)
|
||||||
|
sudo docker compose build backend && sudo docker compose up -d
|
||||||
|
sudo docker compose logs backend | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
- compose 声明 `1panel-network` 为 external——1Panel 装好后已存在,无需改。
|
||||||
|
- 密钥(JWT_SECRET/INGEST_TOKEN/ADMIN_PASSWORD/ADMIN_USERNAME)已内嵌在 compose 里,保持不变 → **扩展 token、登录账号全部沿用**。
|
||||||
|
|
||||||
|
## 6. 站点 + 前端
|
||||||
|
|
||||||
|
1. 1Panel → 网站 → 创建网站:名称 `alibb`,域名 `219.157.255.213`,端口 `23010`,根目录用默认站点 index 路径。
|
||||||
|
2. 站点配置替换为备份内容(面板「配置文件」里改,或直接编辑 conf 后在面板重载 OpenResty):
|
||||||
|
```bash
|
||||||
|
sudo cp ~/backups/alibb-site.conf /opt/1panel/apps/openresty/openresty/conf/conf.d/alibb.conf
|
||||||
|
# 面板 → OpenResty → 重载
|
||||||
|
```
|
||||||
|
> conf 要点:`listen 23010`、root 指向站点 index、`/api` → `http://backend:8080`、`try_files … /index.html`、`client_max_body_size 50m`。若 1Panel 生成的路径/结构与 conf 里写的不一致,以面板生成的 server 块为骨架,把上面四项并进去。
|
||||||
|
3. 前端文件(二选一):
|
||||||
|
```bash
|
||||||
|
# 方案 A(快,直接用备包):解包即完整 dist
|
||||||
|
sudo tar xzf ~/backups/alibb-frontend-dist.tgz -C /opt/1panel/apps/openresty/openresty/www/sites/alibb/
|
||||||
|
sudo chmod -R a+rX /opt/1panel/apps/openresty/openresty/www/sites/alibb/index
|
||||||
|
# 方案 B(干净,代码重 build):同 7 月部署文档 Step 4(docker node:22-alpine npm ci && npm run build 后复制 dist)
|
||||||
|
```
|
||||||
|
4. 云安全组放行 **TCP 23010** 入方向。
|
||||||
|
|
||||||
|
## 7. 端到端验证(全部通过才算恢复完成)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://219.157.255.213:23010/ # 200,返回 index.html
|
||||||
|
curl http://219.157.255.213:23010/inquiries # 200(SPA fallback)
|
||||||
|
curl http://219.157.255.213:23010/api/health # {"ok":true}
|
||||||
|
```
|
||||||
|
- [ ] 浏览器开 http://219.157.255.213:23010 → admin / ADMIN_PASSWORD(compose 里的原值)登录成功
|
||||||
|
- [ ] 询盘/客户/聊天页数据齐全,条数与 §4.2 一致
|
||||||
|
- [ ] Chrome 扩展 popup:**配置不变**,点「测试连接」通过 → 恢复日常抓取
|
||||||
|
|
||||||
|
## 8. 若公网 IP 变了(默认不变,仅备用)
|
||||||
|
|
||||||
|
需要同步改 3 处:
|
||||||
|
1. 扩展 popup 的 serverUrl → `http://<新IP>:23010`
|
||||||
|
2. 站点 conf 的 `server_name` → 新 IP
|
||||||
|
3. 云安全组规则跟随新 IP/网卡
|
||||||
|
|
||||||
|
## 9. 收尾
|
||||||
|
|
||||||
|
- [ ] 把 `~/backups/alibb-secrets.txt` 复制回 `~/alibb-secrets.txt`(惯例位置),`chmod 600`
|
||||||
|
- [ ] 服务器上留一份 `~/alibb-backup-20260825/`(把 ~/backups 里文件拷过去),本机 `D:\alibb-server\backups\` 永久保留
|
||||||
|
- [ ] 更新 [2026-07-15-deploy-test-env.md](2026-07-15-deploy-test-env.md) 里的 MySQL 容器名/端口等(或注明以本 runbook 为准)
|
||||||
|
|
||||||
|
## 10. 故障速查
|
||||||
|
|
||||||
|
| 症状 | 原因 → 处理 |
|
||||||
|
|---|---|
|
||||||
|
| `/api` 502 | ① backend 没起来:`docker compose logs backend`;② OpenResty 与 backend 不在同网络/别名丢失:确认 compose networks 段 `1panel-network: external: true` 且容器别名含 `backend`;③ OpenResty 容器未加入 `1panel-network` |
|
||||||
|
| 后端起不来,连接拒绝 | DATABASE_URL 容器名没替换对(§5),`grep DATABASE_URL` 核对 |
|
||||||
|
| 登录 401 | ADMIN_USERNAME/ADMIN_PASSWORD 与旧值不一致——用备包 compose,别用本地 dev compose 覆盖 |
|
||||||
|
| 扩展测试连接失败 | INGEST_TOKEN 变了(同上)或 23010 未放行 |
|
||||||
|
| MySQL 8.0 下用户连不上 | `ALTER USER 'alibaba'@'%' IDENTIFIED WITH mysql_native_password BY 'wecbni5zRRnZrMMh';` |
|
||||||
|
| migrate 报表不存在 | dump 导入的库不对/没选 alibaba 库,重做 §4 |
|
||||||
|
|
||||||
|
## 11. 实际恢复记录(2026-08-27,已完成 ✅)
|
||||||
|
|
||||||
|
新环境与原方案的差异(下次重装直接按这个来):
|
||||||
|
|
||||||
|
1. **MySQL 8.4.11**(容器 `1Panel-mysql-YL0F`),非 5.7 → dump 直接导入零问题;`alibaba@%` 用户保持默认 caching_sha2_password,Prisma 连接正常,无需切 native 插件
|
||||||
|
2. **OpenResty 1.31 是 host 网络模式** → 容器名 DNS 不可用,`/api` 反代**必须** `proxy_pass http://127.0.0.1:8090`(backend 的宿主映射),不能用 `http://backend:8080`
|
||||||
|
3. **1Panel 新路径 `/opt/1panel/www`**(挂载到容器 `/www`,与旧 conf 完全兼容):站点在 `www/sites/alibb/`,conf 在 `www/conf.d/alibb.conf`
|
||||||
|
4. 站点目录//conf 为手工创建,**1Panel 面板里不显示该站点**(如需面板管理,后续在面板建同名站再合并 conf)
|
||||||
|
5. 精确行数(COUNT(*)):inquiries 14817 / customers 15402 / chat_messages 68268(高于备份时 information_schema 估算值,属正常)
|
||||||
|
6. 验证结果:外网 `http://219.157.255.213:23010` index 200 / SPA 200 / api/health `{"ok":true}`,migrate 无缺项,安全组 23010 已放行
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
# 阿里询盘管理后台 设计文档
|
||||||
|
|
||||||
|
- 日期: 2026-07-13(修订 v3:纳入二次评审发现)
|
||||||
|
- 状态: 修订完成,待用户审查
|
||||||
|
- 关联: 现有 Chrome 插件位于 `d:\alibb\`
|
||||||
|
- 修订说明: v2 修正了 spec review 发现的 4 个致命问题(host_permissions 运行时不可改 / messageId 丢失 / 队列存储布局 / 淘汰丢数据)与 8 个重要问题(询盘时间源、批量写入、alarms 周期、时区、索引、HTTP 推送、behavior 类型、外键可空)。
|
||||||
|
- v3 修正二次评审发现:① **早返回逻辑反转**(失败批次永不重试 → inquiries 表新增 `has_failed_batches` 列 + `messages_count` 改存实际插入数);② **IndexedDB 写入上下文**(content script 的 IDB 属页面源,须由 SW 落盘)+ 双存储一致性写入顺序;③ **MySQL 8 默认 max_allowed_packet 是 64MB 非 4MB**;④ 批量行数 1000/500 统一为 **500**;⑤ direction ENUM 补 `'system'`;⑥ §8.6 补 behavior null 强制转换 / country 来源替换 / translation 双重 JSON.parse。
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
把现有 Chrome 插件(抓取阿里国际站询盘/客户/聊天)的「本地 CSV 导出」升级为完整链路:
|
||||||
|
|
||||||
|
> 插件自动推送 → 自建后端 → MySQL → 管理后台(查看 / 筛选 / 导出 / 统计)
|
||||||
|
|
||||||
|
### 1.2 使用模式(已确认)
|
||||||
|
- **主账号统一抓取**:登录阿里主管理员账号(可看到所有业务员的询盘),**一个人操作插件**一次性抓取全部询盘。
|
||||||
|
- **后台单管理员**:现阶段就一个管理员账号,统览全部数据。
|
||||||
|
- 询盘记录里的 `manager`(负责人)字段天然标识业务员,后台按此字段统计/筛选,**不需要业务员各自登录或各自推送**。
|
||||||
|
- **数据时效 = 上次手动抓取的快照**:插件靠用户在阿里页点「开始抓取」触发,**不是实时同步**;后台看到的是最近一次抓取的数据。后续可考虑定时自动抓(需浏览器保持阿里页打开)。
|
||||||
|
|
||||||
|
### 1.3 MVP 范围
|
||||||
|
**纳入**:插件自动推送;后端入库(三实体幂等);管理后台(列表/详情/导出/统计)。
|
||||||
|
**不纳入(后续)**:多业务员登录/权限;跟进管理/CRM;客户合并/标签;聊天全文搜索;复杂报表;增量推送;**后台数据编辑/删除(MVP 只读,错误数据需重抓或直接改库)**。
|
||||||
|
|
||||||
|
## 2. 背景:现有插件能力
|
||||||
|
|
||||||
|
`d:\alibb\` 的 Chrome MV3 插件已实现并验证:列表/详情/行为数据抓取;聊天 API 直拉(`querySummary.htm` → `getOpMessages.htm` 游标循环,单询盘实测 8000+ 条);chrome.storage 状态持久化。
|
||||||
|
|
||||||
|
**本方案对抓取主流程增量扩展,但必须改动消息映射**:
|
||||||
|
- 现有 `apiMsgToChat()` 只产出 `{sender, time, original, translation, direction}`,**丢弃了 messageId 和毫秒时间戳**。
|
||||||
|
- 本方案要求扩展为:`{messageId, sendTime(ISO UTC), sender, content, direction, translation}`,并从 `querySummary.htm` 响应顺带提取询盘权威时间 `gmtCreate`/`gmtModified`、买家国家等。
|
||||||
|
- 因此 §8 的插件改造**不是纯增量**,包含对 `apiMsgToChat` 与 `fetchChatByApi` 的字段扩展。
|
||||||
|
|
||||||
|
## 3. 需求
|
||||||
|
|
||||||
|
### 3.1 功能需求
|
||||||
|
| ID | 需求 |
|
||||||
|
|---|---|
|
||||||
|
| F1 | 插件每抓完一个询盘(主体 + 全量聊天)→ 自动 POST 到后端 |
|
||||||
|
| F2 | 后端按 客户/询盘/聊天 三表存储,重复推送幂等无副作用 |
|
||||||
|
| F3 | 后台列表:分页 + 筛选(国家/业务员/状态/时间范围)+ 关键词搜索(询价单号/买家名/公司)+ 排序 |
|
||||||
|
| F4 | 后台详情:客户基本信息 + 行为数据卡片 + 完整聊天(虚拟滚动,左右气泡按方向) |
|
||||||
|
| F5 | 导出:按当前筛选导出 CSV(**仅列表字段,不含聊天**;聊天在详情页单独导出) |
|
||||||
|
| F6 | 统计:总数、按国家 Top10、按业务员分布、按月趋势(基于询盘 gmtCreate) |
|
||||||
|
|
||||||
|
### 3.2 非功能需求
|
||||||
|
| ID | 需求 |
|
||||||
|
|---|---|
|
||||||
|
| NF1 可靠 | 推送不丢(队列持久化 + 重试 + 淘汰策略不丢 pending);入库幂等(唯一键 upsert) |
|
||||||
|
| NF2 简单好维护 | TypeScript;Prisma schema 单一数据定义源;前后端共享类型 |
|
||||||
|
| NF3 稳定 | 单操作者低并发;Docker 部署;MySQL 每日备份 |
|
||||||
|
| NF4 安全 | 正式 HTTPS;插件 Bearer token;管理员 JWT(带过期 + 刷新) |
|
||||||
|
|
||||||
|
## 4. 技术栈
|
||||||
|
|
||||||
|
| 层 | 选型 | 理由 |
|
||||||
|
|---|---|---|
|
||||||
|
| 后端 | **Node.js 18 + TypeScript + Express + Prisma** | 和插件同语言;Prisma schema 直观;TS 类型安全 |
|
||||||
|
| 数据库 | **MySQL 8** | 事务 + 唯一约束保证幂等 |
|
||||||
|
| 前端 | **Vue3 + TS + Element Plus + Vite + Pinia + Vue Router** | 管理后台组件成熟 |
|
||||||
|
| 部署 | **Docker Compose + Nginx** | 一键起 |
|
||||||
|
| 插件 | 现有 MV3 + 推送模块(chrome.alarms + 队列 + optional permissions) | 见 §8 |
|
||||||
|
|
||||||
|
**决策记录:为何 Node 不选 Python**:用户优先级是"简单 + 好维护 + 稳定 + 好实现(主导搭建)",Node 全栈与插件同语言、可复用数据转换逻辑、搭建排错最快;Python 数据生态优势对 MVP 简单统计是过度设计,且引入前后端两语言复杂度。后续复杂分析可加 Python 微服务。
|
||||||
|
|
||||||
|
## 5. 架构与数据流
|
||||||
|
|
||||||
|
### 5.1 组件拓扑
|
||||||
|
```
|
||||||
|
[操作者电脑 · 登录阿里主账号]
|
||||||
|
Chrome 插件
|
||||||
|
① 抓取(保留) → 扩展消息映射(messageId/sendTime ISO/gmtCreate)
|
||||||
|
② 每抓完一条 → 经 SW 写入【待推队列】(payload 存扩展 IndexedDB,queue_index 存 chrome.storage.local;content script 不能直接写扩展 IDB,见 §8.2)
|
||||||
|
③ chrome.alarms(1 分钟)唤醒 SW → 消费队列 → POST /api/ingest
|
||||||
|
↓ 失败指数退避重试,成功才出队
|
||||||
|
[Linux 服务器 · Docker Compose]
|
||||||
|
Nginx(client_max_body_size 50m)─┬─ /api/* → Backend
|
||||||
|
└─ /* → Frontend 静态
|
||||||
|
MySQL 8(数据卷 + 每日 mysqldump)
|
||||||
|
↓ 幂等写入(唯一键: inquiry_id / customer_id / message_id)
|
||||||
|
[管理员浏览器] 登录(JWT)→ 列表/详情/导出/统计
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 数据流时序(/api/ingest 处理)
|
||||||
|
1. zod 校验 payload 结构。
|
||||||
|
2. **幂等早返回**:若 `inquiry_id` 已存在 **且 `has_failed_batches=false`** 且 `messages_count` + `last_send_time` 均未变 → 直接 200 返回,跳过消息批写(避免无效重写几千条)。`has_failed_batches` 必须参与判断,否则上次失败的批次会被早返回永远跳过(详见 R6)。
|
||||||
|
3. 一个事务 upsert customer + inquiry(customer_id 可空时跳过 customer)。
|
||||||
|
4. 聊天消息用**原生批量** `INSERT INTO chat_messages (...) VALUES (...),(...)... ON DUPLICATE KEY UPDATE content=VALUES(content), send_time=VALUES(send_time), ...`,**分批**(每批 500 行,见 §10)执行,每批独立事务;失败批次记入失败列表,不回滚整条询盘。累计 `actualInserted` = 各批实际落盘行数之和。
|
||||||
|
5. 更新 inquiry:`messages_count = actualInserted`(实际插入数,**非 payload 长度**)、`last_send_time = max(sendTime)`、`has_failed_batches = (失败批次>0)`。用实际插入数才能让"失败重推时 count 不一致 → 不早返回 → 失败批次自然补写"(v2 用 payload 长度是逻辑反转)。
|
||||||
|
6. 返回 `{ok, inserted: actualInserted, failedBatches}`。
|
||||||
|
|
||||||
|
## 6. 数据模型
|
||||||
|
|
||||||
|
四张表。关系:customers 1→多 inquiries 1→多 chat_messages。
|
||||||
|
|
||||||
|
### 6.1 customers(客户)
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK auto | |
|
||||||
|
| customer_id | VARCHAR(64) **UNIQUE** | 阿里 customerId(加密串) |
|
||||||
|
| name | VARCHAR(128) | |
|
||||||
|
| country | VARCHAR(64) **INDEX** | 国家(统计/筛选用) |
|
||||||
|
| email / company | VARCHAR | |
|
||||||
|
| register_time | DATETIME NULL | UTC,可空 |
|
||||||
|
| avatar_url | VARCHAR(512) | |
|
||||||
|
| buyer_tags / buyer_features | VARCHAR(255) | 逗号分隔 |
|
||||||
|
| behavior_product_views / valid_inquiries / valid_rfq / login_days / spam_inquiries | INT **NULL** | 可空(抓不到时 null,不存 0 以区分"无数据"与"真为 0") |
|
||||||
|
| behavior_top_industry | VARCHAR(255) | |
|
||||||
|
| created_at / updated_at | DATETIME | UTC |
|
||||||
|
|
||||||
|
### 6.2 inquiries(询盘)
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK auto | |
|
||||||
|
| inquiry_id | VARCHAR(32) **UNIQUE** | 询价单号 |
|
||||||
|
| customer_id | VARCHAR(64) **NULL** INDEX | FK→customers.customer_id,**可空**(抓不到 customerId 时留空) |
|
||||||
|
| manager | VARCHAR(128) **INDEX** | 业务员名 |
|
||||||
|
| create_time | DATETIME | UTC,源自 API `gmtCreate` |
|
||||||
|
| update_time | DATETIME | UTC,源自 API `gmtModified`(缺失回落 gmtCreate) |
|
||||||
|
| status | VARCHAR(64) | |
|
||||||
|
| msg_preview | VARCHAR(512) | |
|
||||||
|
| is_flagged | BOOLEAN | |
|
||||||
|
| messages_count | INT DEFAULT 0 | 冗余,供幂等早返回 |
|
||||||
|
| last_send_time | DATETIME NULL | 冗余,供幂等早返回 |
|
||||||
|
| has_failed_batches | BOOLEAN DEFAULT FALSE | 上次推送是否有失败批次;为真则关闭早返回、强制重试(v3 新增) |
|
||||||
|
| created_at / updated_at | DATETIME | UTC |
|
||||||
|
|
||||||
|
### 6.3 chat_messages(聊天明细)
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | BIGINT PK auto | |
|
||||||
|
| message_id | VARCHAR(64) **UNIQUE** | 阿里 messageId(幂等键,全局唯一) |
|
||||||
|
| inquiry_id | VARCHAR(32) **INDEX** FK→inquiries.inquiry_id | |
|
||||||
|
| sender | VARCHAR(128) | |
|
||||||
|
| direction | ENUM('incoming','outgoing','system') | 插件 apiMsgToChat 在 messageType 非 send/rec 时兜底产出 'system'(样本暂未出现,保留以防插入失败) |
|
||||||
|
| send_time | DATETIME | UTC,源自毫秒戳 |
|
||||||
|
| content | **MEDIUMTEXT** | 原文(已解码实体)。卡片消息体量待实测确认(样本中为 URL ~500 字符);MEDIUMTEXT 上限 16MB 留足余量 |
|
||||||
|
| translation | TEXT | 翻译,从 `extInfo.icbuData.extend.extParams.inputTranslateInfo.translate` 提取(可空) |
|
||||||
|
| created_at | DATETIME | UTC |
|
||||||
|
|
||||||
|
> **索引**:`UNIQUE(message_id)`、`INDEX(inquiry_id, send_time, message_id)`(游标分页复合键)。
|
||||||
|
|
||||||
|
### 6.4 users(管理员账号)
|
||||||
|
| 字段 | 类型 |
|
||||||
|
|---|---|
|
||||||
|
| id PK / username UNIQUE / password_hash (bcrypt) / created_at |
|
||||||
|
|
||||||
|
> **bootstrap**:后端启动时若 users 表空,根据 `ADMIN_USERNAME` / `ADMIN_PASSWORD` 环境变量 seed 一条记录。推送 token 写 `.env`(`INGEST_TOKEN`),不建表。
|
||||||
|
|
||||||
|
### 6.5 时间来源与时区(修订)
|
||||||
|
- **询盘 create_time / update_time**:用 `querySummary.htm` 返回的 `gmtCreate` / `gmtModified` 毫秒戳 → `new Date(ms).toISOString()`。**不再用"最早消息兜底"**(语义错:询盘创建 ≠ 首条消息)。列表页那个只有时分的文本时间不入库,仅必要时回退展示。
|
||||||
|
- **消息 send_time**:用 API 毫秒戳 → ISO UTC。
|
||||||
|
- 所有 DATETIME 存 UTC;前端用 dayjs 转本地时区展示。
|
||||||
|
|
||||||
|
## 7. API 设计
|
||||||
|
|
||||||
|
基地址 `/api`,JSON。
|
||||||
|
|
||||||
|
| 方法 | 路径 | 鉴权 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/ingest` | Bearer token | 插件推送。zod 校验。限流 60/min/token。幂等早返回(见 §5.2) |
|
||||||
|
| POST | `/api/auth/login` | - | `{username,password}` → `{token, expiresIn}` |
|
||||||
|
| POST | `/api/auth/refresh` | JWT | 刷新 token |
|
||||||
|
| GET | `/api/inquiries` | JWT | 列表,query: page,size,country,manager,status,dateFrom,dateTo,q(搜 inquiry_id/msg_preview/customer.name),sort |
|
||||||
|
| GET | `/api/inquiries/:id` | JWT | 详情 + 客户 + 行为 |
|
||||||
|
| GET | `/api/inquiries/:id/messages` | JWT | **游标分页**:query `afterSendTime` + `afterMessageId`(默认空=从最早)、size;`WHERE inquiry_id=? AND (send_time, message_id) > (?, ?) ORDER BY send_time, message_id LIMIT ?`(复合游标,避免同时间戳消息丢失) |
|
||||||
|
| GET | `/api/inquiries/:id/messages/export` | JWT | 单询盘聊天导出(stream) |
|
||||||
|
| GET | `/api/stats/overview` | JWT | 总数/国家 Top10/业务员/月趋势 |
|
||||||
|
| GET | `/api/export` | JWT | **仅列表字段**(不含聊天),按筛选 stream 导出 |
|
||||||
|
|
||||||
|
### 7.1 `/api/ingest` payload 结构
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"inquiry": {
|
||||||
|
"inquiryId": "13776617920",
|
||||||
|
"manager": "cara Hu",
|
||||||
|
"createTime": "2026-03-13T10:11:50Z", // 来自 gmtCreate, ISO UTC
|
||||||
|
"updateTime": "2026-07-13T10:11:50Z", // 来自 gmtModified
|
||||||
|
"status": "洽谈中", "msgPreview": "...", "isFlagged": false
|
||||||
|
},
|
||||||
|
"customer": { // customerId 为空时整个 customer 省略
|
||||||
|
"customerId": "259b4e6f...", "name": "Carlos Loayza Marin",
|
||||||
|
"country": "PE", "email": null, "company": "World Shop",
|
||||||
|
"registerTime": "2025-06-23T00:00:00Z",
|
||||||
|
"avatarUrl": "...", "buyerTags": "TM沟通", "buyerFeatures": "...",
|
||||||
|
"behavior": { "productViews": null, "validInquiries": null, "validRfq": 39 } // 捕不到为 null
|
||||||
|
},
|
||||||
|
"messages": [
|
||||||
|
{ "messageId": "4198482017852", "sendTime": "2026-07-13T02:11:50Z",
|
||||||
|
"sender": "cara Hu", "direction": "outgoing", "content": "hello...",
|
||||||
|
"translation": "你好..." }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 空字符串数字字段在插件侧 coerce 为 null;后端 zod 接受 `number | null`。
|
||||||
|
|
||||||
|
## 8. 插件改造(在 `d:\alibb\` 基础上)
|
||||||
|
|
||||||
|
### 8.1 配置页
|
||||||
|
popup 增「后端设置」:服务器 URL、Token、启用开关。存 chrome.storage.local。
|
||||||
|
**首次保存 URL 时**,调用 `chrome.permissions.request({origins:[serverUrl]})`(需在用户点击手势中触发,会弹 Chrome 授权框)——因为 host_permissions 运行时不可改,见 §8.5。
|
||||||
|
|
||||||
|
### 8.2 待推队列(修订:IndexedDB 存 payload + 不丢 pending)
|
||||||
|
- **payload 默认存扩展 IndexedDB**(`idb` 库),每条一个 record(key=id);chrome.storage.local 只存轻量 `queue_index`(有序 id 列表)+ 每项元数据 `{id, retries, nextRetry, status}`。避免 chrome.storage.local 在 GB 级的读写性能问题。
|
||||||
|
- **写入上下文(关键,v3)**:content script 的 `indexedDB` 是**页面源**(`message.alibaba.com`)的,不是扩展的;只有 service worker / popup / options 才能访问扩展自己的 IndexedDB。因此正确链路是:**content.js 抓完一条 → `chrome.runtime.sendMessage` 把 payload 传给 SW → SW 落盘到扩展 IndexedDB**。绝不能让 content.js 直接写 IDB(会写到页面的库里)。
|
||||||
|
- **双存储一致性(v3)**:IndexedDB 与 chrome.storage 之间无跨库事务。写入顺序固定为**先写 IDB payload,再追加 queue_index**(保证 index 永不引用不存在的 payload);删除反之(先标 success → 删 IDB → 移 index)。SW 启动时跑一次 GC,清理 IDB 中无 index 引用的孤儿 payload。
|
||||||
|
- manifest 声明 `"unlimited_storage"`(IndexedDB 容量也受其约束)。
|
||||||
|
- **不丢未推数据**:推送成功 → 立即从 IndexedDB + queue_index 删除该队列项;`pending/failed` 永不淘汰。触顶(默认 500 条 pending)→ **暂停抓取**(content.js 每抓一条前向 SW 查队列深度)+ popup 红字提示"队列拥堵,请检查后端"。
|
||||||
|
|
||||||
|
### 8.3 推送 worker(修订:alarms 1 分钟)
|
||||||
|
- 用 **`chrome.alarms`**(`periodInMinutes: 1`),**不用 setInterval**(MV3 SW 会休眠)。
|
||||||
|
- 注:打包/正式版 alarms 最短 1 分钟(开发版可临时 30s)。
|
||||||
|
- alarm 触发 → 取最多 N(默认 3)条 pending 且到 nextRetry 的项 → POST → 成功则删除该队列项 / 失败 `retries++`、`nextRetry = now + min(2^retries, 3600)s`,超 8 次标 failed 保留。
|
||||||
|
- **吞吐量(v3)**:1 分钟 × 3 条 = 180 条/小时 ≈ 4320 条/天。全量几千条要好几天才推完,这是异步后蹲、与"慢抓"主题一致;**抓完 ≠ 入库**,popup 的"已推/待推"计数要让使用者意识到。瓶颈在 worker 的 3/min,服务器限流 60/min(§7)永远用不到;日后想加速可提高每 tick 的 N。
|
||||||
|
|
||||||
|
### 8.4 状态展示
|
||||||
|
popup 显示「已推 N / 待推 M / 失败 K」,失败可查看/重试。
|
||||||
|
|
||||||
|
### 8.5 manifest 改动(修订:optional permissions)
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"permissions": ["storage", "activeTab", "alarms", "unlimitedStorage"],
|
||||||
|
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://message.alibaba.com/*", "*://alicrm.alibaba.com/*", "*://onetalk.alibaba.com/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- 后端服务器地址**不写死在 host_permissions**(运行时不可改,且占位符非法会拒载)。
|
||||||
|
- 用 `optional_host_permissions: ["http://*/*","https://*/*"]` + 用户在配置页通过 `chrome.permissions.request({origins:[serverUrl]})` **运行时授权**具体后端地址。
|
||||||
|
- 替代:若插件私有/不上架,可直接 `host_permissions: ["*://*/*"]` 一劳永逸(但上架审核会问)。
|
||||||
|
|
||||||
|
### 8.6 抓取侧字段扩展(修订)
|
||||||
|
- `fetchChatByApi()` 在调 `querySummary.htm` 后,**提取 `gmtCreate`/`gmtModified`/`country` 等字段**并随 payload 返回。**country 改用 `querySummary.contact.countryCode`**(样本中 `"countryCode":"PE"`,比 DOM 的 `detailCountry` 可靠),是**替换**而非新增字段。
|
||||||
|
- `apiMsgToChat()` 扩展产出 `{messageId: String(m.messageId), sendTime: new Date(m.sendTime).toISOString(), sender, content, direction, translation}`,其中 translation 从 `m.extInfo.icbuData.extend.extParams.inputTranslateInfo.translate` 解析(失败留空)。**注意双重 JSON.parse(v3)**:`extend` 本身是 JSON 字符串,`extParams.inputTranslateInfo` 又是嵌套 JSON 字符串,需 `JSON.parse(JSON.parse(extend).extParams.inputTranslateInfo).translate` 两层解析,直接对象访问会拿到 undefined。
|
||||||
|
- **behavior 数字字段强制转 null(v3)**:`productViews/validInquiries/validRfq/loginDays/spamInquiries` 在 content.js 现为字符串('' 或数字串),payload 序列化时空串与抓不到的值都要 coerce 为 `null`,匹配后端 zod 的 `number | null`。
|
||||||
|
|
||||||
|
## 9. 后台前端
|
||||||
|
|
||||||
|
| 路由 | 页面 | 要点 |
|
||||||
|
|---|---|---|
|
||||||
|
| `/login` | 登录 | |
|
||||||
|
| `/inquiries` | 列表 | Element Plus 表格 + 筛选栏(国家/业务员/状态/时间)+ 搜索 + 排序 + 分页 + 导出按钮 |
|
||||||
|
| `/inquiries/:id` | 详情 | 客户信息+行为卡片 + 完整聊天(虚拟滚动,游标加载) |
|
||||||
|
| `/stats` | 统计 | 数字卡片 + 国家 Top10 饼图 + 业务员柱状图 + 月趋势折线(ECharts) |
|
||||||
|
|
||||||
|
性能:聊天用虚拟滚动(vue-virtual-scroller);列表分页默认 20/页。
|
||||||
|
|
||||||
|
## 10. 可靠性与安全
|
||||||
|
|
||||||
|
| # | 措施 |
|
||||||
|
|---|---|
|
||||||
|
| 幂等 | 唯一键 upsert + 早返回(inquiry 存在 **且 has_failed_batches=false** 且 count/lastSendTime 未变则跳过) |
|
||||||
|
| 队列不丢 | 独立 key 持久化 + 只淘汰 success + 触顶暂停抓取 |
|
||||||
|
| 批量写入 | 原生 `INSERT ON DUPLICATE KEY` 分批 500 行/批(见 §5.2),独立事务,失败批次隔离 |
|
||||||
|
| 鉴权 | 插件 Bearer token(env);管理员 JWT(access 短期 + refresh) |
|
||||||
|
| 密码 | bcrypt |
|
||||||
|
| CORS | `/api/ingest` 用 token 鉴权,`Access-Control-Allow-Origin: *` 不做 origin 校验;后台 API 限前端域名 |
|
||||||
|
| XSS | Vue 默认转义,禁用 v-html 渲染用户数据 |
|
||||||
|
| body limit | Express `limit:50mb`;**nginx `client_max_body_size 50m`** |
|
||||||
|
| MySQL | `max_allowed_packet` 设为 128MB(**MySQL 8 服务端默认 64MB,非 4MB**;4MB 是老版本/客户端库默认值,易误传);部署前 `SHOW VARIABLES LIKE 'max_allowed_packet'` 实测确认。单批 500 行(含 MEDIUMTEXT 时 500 行 × 极端 64KB ≈ 32MB,留余量) |
|
||||||
|
| 备份 | 每日 mysqldump → gzip,保留 14 天,异地复制 |
|
||||||
|
|
||||||
|
## 11. 部署
|
||||||
|
|
||||||
|
### 11.1 docker-compose(结构)
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
mysql: # mysql:8, 卷, healthcheck, max_allowed_packet 调大
|
||||||
|
backend: # Node API, depends_on mysql, .env
|
||||||
|
nginx: # 反代 + 静态 + client_max_body_size 50m; 正式加 HTTPS
|
||||||
|
cron: # 备份 sidecar(或宿主 crontab),每日 mysqldump
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 测试环境(无域名)
|
||||||
|
- 公网 IP + 端口,后端 HTTP。
|
||||||
|
- **扩展 SW 的 fetch 起源于 `chrome-extension://` 上下文,不受网页 mixed-content 规则约束**,只要 optional_host_permissions 授权了该 HTTP URL,推送正常。
|
||||||
|
- **私网 IP(192.168/10.x)注意**:Chrome 的 Private Network Access 会要求预检;优先用公网 IP 测试。
|
||||||
|
|
||||||
|
### 11.3 正式环境(有域名)
|
||||||
|
- 域名 + Nginx + Let's Encrypt(自动续期)+ 全程 HTTPS。
|
||||||
|
|
||||||
|
## 12. 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
d:\alibb\ # 现有插件(增量改造:推送模块 + 字段扩展)
|
||||||
|
d:\alibb-server\
|
||||||
|
backend\ prisma\schema.prisma + src\{routes,services,middleware,utils} + Dockerfile
|
||||||
|
frontend\ Vue3 工程 + Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.env.example (DATABASE_URL / INGEST_TOKEN / JWT_SECRET / JWT_REFRESH_SECRET / ADMIN_USERNAME / ADMIN_PASSWORD)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 13. 分期
|
||||||
|
|
||||||
|
**MVP**:后端骨架(Express+Prisma+MySQL)+ `/api/ingest`(校验+幂等+批量)+ auth/JWT + 列表/详情/消息/统计/导出 API + 前端四件套 + 插件推送模块 + Docker + 测试部署 + 备份。
|
||||||
|
**后续**:增量推送、聊天全文搜索、跟进管理、客户合并/标签、复杂报表、多管理员、存量 CSV 导入。
|
||||||
|
|
||||||
|
## 14. 风险与待确认
|
||||||
|
|
||||||
|
- **R1 payload 体量**:单询盘几千条约 2-5MB;body limit 50MB + nginx 50m + max_allowed_packet 64MB。>1万条极端询盘 MVP 后改分批推送。
|
||||||
|
- **R2 增量**:MVP 全量重推 + 早返回优化(未变则跳过);日抓量大后做真增量(插件记 last sendTime)。
|
||||||
|
- **R3 manager 重名**:字符串统计,同名合并;后续加 employees 表。
|
||||||
|
- **R4 测试环境网络**:公网 IP+HTTP 推送无混合内容问题(扩展 fetch 独立上下文);私网 IP 需注意 Private Network Access 预检。已删除"自签 HTTPS"复杂方案。
|
||||||
|
- **R5 customerId 缺失**:customer_id 可空,无 customerId 的询盘只存 inquiry+messages,customer 留空。
|
||||||
|
- **R6 早返回的边界**:幂等早返回条件为 `has_failed_batches=false` 且 `messages_count + last_send_time` 未变。
|
||||||
|
- **失败批次重试(v3 修正)**:早返回已加入 `has_failed_batches`——上次有批次失败时强制不走早返回,确保失败批次被补写。inquiries 表新增 `has_failed_batches` 列;`messages_count` 存实际插入数(payload 长度会让重推 count 恒定一致,反而触发早返回——这是 v2 的逻辑反转)。
|
||||||
|
- **内容修订**:同 messageId 内容(如 translation)被上游修订时,早返回仍会跳过更新。MVP 接受(重推主场景是新增消息)。
|
||||||
|
- **R7 首次全量回填耗时**:首次把现有几千询盘全抓入库,按"每询盘几千条聊天 × API 拉取 + 推送"估算需 **数小时**,且**浏览器必须一直打开阿里页**不能关。要求插件支持**分批抓取 + 可中断续抓**(基于 queue/inquiryId 进度持久化,中断后重开继续,而非重头来)。日常增量抓取不受此影响。
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# 聊天记录按时间范围抓取 设计文档
|
||||||
|
|
||||||
|
- 日期: 2026-08-25
|
||||||
|
- 状态: 待评审
|
||||||
|
- 关联: `d:\alibb\` Chrome 扩展(content.js / popup.html / popup.js)
|
||||||
|
- 前置讨论: 吸收三轮审查 —— ①自评 2 漏洞(§4.5 水位倒退、§4.3 until 跳游标未实测);②subagent 评审 4 建议;③深度复查 6 项(P1×3:空 list 静默 0 条 / fallback 与 RISK 抛出次序冲突 / date-only UTC 解析陷阱;P2×2:未来止日、水位检测顺序;优化:停止原因日志)及复查细化 2 项(§4.5 更新条件简化、§4.3 任务级降级标志)
|
||||||
|
|
||||||
|
## 1. 概述
|
||||||
|
|
||||||
|
### 1.1 目标
|
||||||
|
给聊天记录抓取增加**时间范围**控制:近 1 个月 / 2 个月 / 3 个月 / 自定义起止日期。选定范围后,翻页**到范围起点即停**(不只是抓完再滤),从而大幅减少对 `getOpMessages` 的请求量 —— 时间范围同时是**风控减压手段**。
|
||||||
|
|
||||||
|
### 1.2 动机
|
||||||
|
现有唯一限制是条数(`chatLimit`,换算成翻页轮数上限)。条数与"我要最近 N 个月"的真实意图不匹配;且全量翻页对老客户动辄几百轮请求(每轮 2-5s 慢速),风控暴露面大。
|
||||||
|
|
||||||
|
## 2. 已确认决策(用户拍板)
|
||||||
|
|
||||||
|
| # | 决策点 | 结论 |
|
||||||
|
|---|---|---|
|
||||||
|
| D1 | 时间范围 vs 条数 | **时间替代条数**:选时间模式后条数输入忽略、置灰 |
|
||||||
|
| D2 | 与增量机制关系 | **增量仍然生效**:增量命中 / 时间边界到,任一先到先停 |
|
||||||
|
| D3 | UI 形态 | 下拉:全部(默认,同现状)/ 近1月 / 近2月 / 近3月 / 自定义;自定义显示起止日期框 |
|
||||||
|
| D4 | 抓取路径 | **API 慢速 + 时间边界停**(否决 DOM 滚动为主 / CDP,理由见 §3) |
|
||||||
|
|
||||||
|
## 3. 方案选型(为什么是 API + 时间边界)
|
||||||
|
|
||||||
|
- **DOM 滚动为主被否决**:① DOM 滚动触发的底层请求仍是 `getOpMessages`(页面自己发),并非"不调 API";② 驱动事件是合成 `WheelEvent`,`isTrusted=false` 是**阿里已确认检测的指纹**(现有代码注释记载:折叠面板只接受真实点击);③ 慢 10 倍以上、图片懒加载丢数据、平台期误判截断。
|
||||||
|
- **CDP(chrome.debugger)可信事件被否决**:常驻"正在调试此浏览器"提示条、实现复杂、速度仍慢。
|
||||||
|
- **后端过滤被否决**:不省请求、白传数据、旧数据管不了。
|
||||||
|
- **API 慢速路径实测战绩**:7-15 全量 4576 询盘 + 68058 条消息零风控触发。
|
||||||
|
|
||||||
|
## 4. 详细设计
|
||||||
|
|
||||||
|
### 4.1 UI(popup.html / popup.js)
|
||||||
|
|
||||||
|
options 区"每询盘聊天上限"行下方新增一行 **聊天时间范围**:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<select id="chatTimeRange">
|
||||||
|
<option value="all" selected>全部(不限时间)</option>
|
||||||
|
<option value="1m">近 1 个月</option>
|
||||||
|
<option value="2m">近 2 个月</option>
|
||||||
|
<option value="3m">近 3 个月</option>
|
||||||
|
<option value="custom">自定义日期…</option>
|
||||||
|
</select>
|
||||||
|
<!-- custom 时显示 -->
|
||||||
|
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
|
||||||
|
```
|
||||||
|
|
||||||
|
- 选 `custom` → 显示起止框,**止默认今天**;
|
||||||
|
- 选任何时间模式(非 all)→ `chatLimitInput` 置灰 + 旁注"(时间模式下忽略)";切回 all → 恢复可用。两模式互斥。
|
||||||
|
- popup.js 在 start 时计算并传参(**本地时区**):
|
||||||
|
- `all` → `chatSince: null, chatUntil: null`(完全等同现状,回归安全);
|
||||||
|
- `1m/2m/3m` → `chatSince = minusMonths(今日0点, N)`(**自然月整天边界**,起始日 00:00:00.000,不保留当前时分秒 —— 否则起始日当天早间消息被切),`chatUntil = 今日 23:59:59.999`("至今"取今天整天,与 custom 止=今天同一路径);
|
||||||
|
- `custom` → `chatSince = 起日 T00:00:00.000`,`chatUntil = 止日 T23:59:59.999`(含止日全天);
|
||||||
|
- **校验(popup 报错不发 start)**:起 > 止 / 起或止为空 / **止日 > 今天**(未来止日会造出未来 cursor,属未实测形态);
|
||||||
|
- ⚠️ **实现陷阱(date-only UTC)**:`new Date('2026-08-25')` 按 **UTC** 解析,中国时区差 8 小时,止日凌晨的消息会被静默滤掉。必须写 `new Date(v + 'T00:00:00')`(带时间部分 → 本地解析)或 `new Date(y, m-1, d)`。verify 脚本有用例锁定。
|
||||||
|
|
||||||
|
### 4.2 参数传递与状态(content.js)
|
||||||
|
|
||||||
|
- start 消息新增 `chatSince` / `chatUntil`(ISO 字符串或 null),存入 `state` 并随 `saveState()` 持久化 —— 复用现有跨导航/暂停恢复/扩展重载的状态链路,零额外机制。注意:现有 start 分支是**全量重建 state 对象**,需把这两个字段显式加入重建清单(漏写会变 undefined,虽按 null 容错可用,但显式更清晰)。
|
||||||
|
- 进入抓取时派生一次毫秒值:`sinceMs = chatSince ? Date.parse(chatSince) : null`(untilMs 同理),后续循环只用毫秒比较。
|
||||||
|
- **时间模式下 popup 一律发 `chatLimit: 0`**:使 API 路径(MAX_ROUNDS→2000)与 DOM 回退路径(→300)两条现有公式都自然解析为全量轮数,无需对称修改换算逻辑。
|
||||||
|
|
||||||
|
### 4.3 API 主路径(fetchChatByApi)改动
|
||||||
|
|
||||||
|
翻页循环(现有停止条件:增量命中 hitLastTop / list.length < 20 到顶 / MAX_ROUNDS)增加:
|
||||||
|
|
||||||
|
1. **初始游标**:`cursor = (untilMs != null) ? untilMs + 1 : Date.now()`
|
||||||
|
- +1ms 缓冲:宁可多取一轮,由批内过滤兜底,确保 until 邻近消息不漏(§6.2);
|
||||||
|
- 自定义过去截止日时直接从截止日开始往回翻,**省掉 now→until 之间的全部请求**。
|
||||||
|
2. **收集过滤**(批内跨边界):`raw.push(m)` 仅当
|
||||||
|
`m.sendTime == null || ((!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs))`
|
||||||
|
—— sendTime 为空的消息**防御性保留**(与 §5 约定一致;若不显式加此 guard,`undefined >= sinceMs` 为 false 会把无时间消息错误丢弃)。
|
||||||
|
**顺序约定:水位命中检测遍历未过滤的原始 list(现有口径),命中即停;未命中的消息再套范围过滤决定 push。** 禁止先 `list.filter(范围)` 再查水位 —— 水位消息落在范围外时行为会漂移。
|
||||||
|
3. **翻页停止**:每轮游标更新后,`if (sinceMs && minT < sinceMs) → break`,日志记 `时间边界提前停止(早于 <since ISO>)`。
|
||||||
|
4. MAX_ROUNDS 在时间模式下固定 2000(安全阀防死循环;正常由时间/增量/到顶停)。
|
||||||
|
5. 日志:范围模式下每询盘开始抓聊天时输出当前范围(`聊天范围: 2026-07-25 ~ 至今` / `~ 2026-08-31`;all 模式无此日志);完成日志统一带**停止原因**:`聊天 API 完成: N 条(停:时间边界/增量命中/到顶/空列表/轮数上限)` —— 手动验证一眼分辨是哪种停。
|
||||||
|
|
||||||
|
**until 跳游标的保守 fallback(首轮专项)**:**2026-08-25 已实测**(附录 B):过去时间戳 cursor 完全受支持,语义 = 返回 sendTime **严格小于** timeStamp 的 N 条(方向与现网一致);`untilMs+1` 缓冲恰好覆盖含止日全天。fallback 保留为**保险**(实测样本单一:1 账号 1 会话,阿里行为可能变化),并按实测精化:
|
||||||
|
|
||||||
|
- **触发条件**(仅 `untilMs != null` 时的**首轮**,且仅首轮):
|
||||||
|
1. `PROBE_FAIL` 形态:非对象 / code≠200 / data=null —— 参数异常;
|
||||||
|
2. **返回空 list** —— 实测(附录 B 组)表明这大概率是"该会话在 until 前确无消息"(真空,如新询盘),但单次响应无法与"不支持"区分,仍降级以保正确(空 list 会被现有"正常到顶"分支 [content.js:816](content.js#L816) 静默吞掉,是真静默丢数据形态)。
|
||||||
|
- **实现次序(P1-2)**:首轮结构断言单独 try 包住,抛**独立前缀** `PROBE_FAIL`(勿复用 RISK 前缀,避免误吞 `RISK:结构异常(non-JSON)`);降级集 = {PROBE_FAIL, 空 list};postForm 自身抛的 RISK(HTTP 非 200 / 关键词 / non-JSON)在 try 之外,照常冒泡暂停,不得吞掉。
|
||||||
|
- **降级动作**:cursor 重置 `Date.now()`,**同循环 continue** 从现在往回翻到 since 停(批内过滤兜 until 上界);重试首批仍空 list = 真空(正常到顶),不递归降级。
|
||||||
|
- **任务级标志(final review 再收紧)**:仅当**降级原因为 'fail'(结构异常)且降级重试首批非空**才设 `state.cursorJumpUnsupported = true`。'empty'(空 list)多为"会话在 until 前本无消息"的真空(附录 B 组形态)—— 据此设标志会把跳游标误禁一整个任务(所有询盘退化为 now 全量回翻,风控暴露面回升),故 empty 降级保正确性但不设标志。实现以局部变量 `downgradeKind` 记录原因。
|
||||||
|
|
||||||
|
### 4.4 DOM 回退路径(loadAllChatHistory,API 失败才走)
|
||||||
|
|
||||||
|
- **全量分支**:滚动循环每轮**轻量探测**——只读**第一个** `.message-item-wrapper` 的时间 span(聊天 DOM 顶部=最旧;不要全量提取,几千条时每轮全提性能差),若其 sendTime 早于 sinceMs → 停止往上滚(再滚加载的全是范围外)。
|
||||||
|
- 滚动结束后 `extractDomChatMessages()` 提取,再按 since/until 过滤后才挂到 `state._incDomMessages` / chatMessages。
|
||||||
|
- **增量分支**:现有 `sendTime > lastDomTime` 过滤后,**再叠加** since/until 过滤(两条件都满足才收)。
|
||||||
|
- **预期降级(非 bug)**:增量分支只取页面已显示的最新 N 条、从不向过去滚动 —— 自定义**过去**截止日时,叠加过滤后可能产出 0 条。这是回退路径的可接受退化(API 路径才是主路径),勿当 bug 上报。
|
||||||
|
- `lastDomTime` 水位计算沿用**未过滤**集合的 max(水位反映"DOM 已见到的最新",过滤只影响推送)—— 现有 max 逻辑天然单调前进,无需改动。
|
||||||
|
|
||||||
|
### 4.5 增量水位只前进修复(自评漏洞 #1)
|
||||||
|
|
||||||
|
**问题**:自定义过去截止日(如 6-01~6-30)抓完后,`lastTopMsgId` 会被覆盖成本轮翻到的最新(≈6-30),把原 7-28 的水位**倒退** → 下次增量要多翻整月已抓过的消息(数据不脏,后端 skipDuplicates,但请求白费、风控暴露面白增)。
|
||||||
|
|
||||||
|
**修复**:`lastTopMsg_<inquiryId>` 存储结构改为 JSON `{ id, sendTime }`:
|
||||||
|
- 读取兼容旧格式(纯 string 视为 `sendTime` 未知 → 允许覆盖一次,升级到新格式);
|
||||||
|
- 更新条件简化为:`候选水位.sendTime > 旧水位.sendTime` 才写入(**水位只前进不倒退**)。原实现的 `raw.length > 0` 附加条件**移除** —— 它在旧格式(id 不可比时间)下防误清,新格式的只前进比较本身已安全;保留反而导致"本轮全被 until 滤空、但确实见到更新消息"时水位保守不前,下次多翻;
|
||||||
|
- 候选水位取**各轮见过的最大 sendTime**(未过滤 list 口径)。注意与现状的差异:现有实现只在首批设一次(`if (newestMsgIdThisRound === null && ...)`,[content.js:818-823](content.js#L818-L823)),fallback 降级重翻后首批(更新侧)无法再上调候选 → 水位保守停在 probe 轮的旧值,下次多翻。改为逐轮取 max 后此问题消除。
|
||||||
|
|
||||||
|
注:DOM 侧 `lastDomTime` 是毫秒时间戳比较,天然单调,无需此修复。
|
||||||
|
|
||||||
|
### 4.6 不改动的部分
|
||||||
|
|
||||||
|
- 同客户聊天去重(`seenChatCustomer`)、`chatSkipped` 标记、后端归代表:不受影响;
|
||||||
|
- API 返回空数组 `[]` = 成功无新增(不触发 DOM 回退)的现有语义:不变;
|
||||||
|
- CSV 导出、enqueue 推送、payload.js 映射、后端/前端:**零改动**(过滤在扩展侧完成,messages 照常流转);
|
||||||
|
- 询盘列表抓取、详情页客户资料/行为数据:**不受时间范围影响**(时间范围只作用于聊天)。
|
||||||
|
|
||||||
|
## 5. 边界语义与容错
|
||||||
|
|
||||||
|
| 项 | 约定 |
|
||||||
|
|---|---|
|
||||||
|
| 含边界 | `sendTime >= since` 且 `<= until`;自定义含止日全天(23:59:59.999) |
|
||||||
|
| 近 N 月 | 自然月,非 30 天,**整天边界**:since=起始日 00:00:00.000、until=今日 23:59:59.999(需求变更 2026-08-25:不保留当前时分秒)。⚠️ 不能直接 `setMonth(-N)`:月末日期溢出(3-31 − 1月 → "2-31" 溢出成 3-02,since 晚一个月**漏抓整月**)。须用 `minusMonths`(退到月初再钳制到目标月最后一天);verify 脚本有用例锁定 |
|
||||||
|
| 时区 | since/until 按用户**本地时区**构造(popup `new Date()`),与 API 毫秒时间戳、DOM 本地时间解析一致 |
|
||||||
|
| parse 失败 | chatSince/chatUntil 非法 → 视为 null(降级为不限),日志提示,**不中断抓取** |
|
||||||
|
| 消息无 sendTime | 防御性保留(宁多勿漏);实际 API 消息均带 sendTime |
|
||||||
|
| 起止校验 | custom 起>止 / 空 / 止日>今天 → popup 报错不发 start |
|
||||||
|
|
||||||
|
## 6. 语义澄清(用户预期管理)
|
||||||
|
|
||||||
|
1. **范围 ≠ 删除**:时间范围只控制"这次抓什么",**不会删除后端已有历史**。库内数据 = 历次抓取的并集(后端按 messageId skipDuplicates)。改选更短范围重抓,旧数据仍在。
|
||||||
|
2. **增量水位对"过去区间"天然无效**:水位只优化"追加新消息"场景;自定义过去区间翻不到水位属正常,由 since 边界停。
|
||||||
|
3. **抓取正确性承诺**:范围内一条不漏(边界含)、范围外一条不进(批内过滤)、已抓过的不重复翻(水位只前进 + 增量命中停)。
|
||||||
|
|
||||||
|
## 7. 验证方案
|
||||||
|
|
||||||
|
### 7.1 自动验证(content.js 为 IIFE,无法被 vitest import → 沿用复刻脚本模式)
|
||||||
|
新增 `scripts/verify-chat-range.mjs`(与 `verify-risk-detect.mjs` 同模式,文件头注明与 content.js 同步义务),覆盖:
|
||||||
|
- 自然月计算(跨年:3 月 31 日 - 1 月 = 2 月 28/29?边界行为锁定);
|
||||||
|
- 边界含/不含(since 当天消息收、since 前一天不收);
|
||||||
|
- 跨边界批过滤(一页 20 条横跨 since);
|
||||||
|
- 水位只前进(旧 {id,sendTime=7-28} 不被新 sendTime=6-30 覆盖;旧 string 格式兼容);
|
||||||
|
- cursor 缓冲(untilMs+1)与 parse 容错(null → 不限);
|
||||||
|
- date 输入**本地时区**构造(`new Date(v+'T00:00:00')` 非 UTC 解析,锁定 8-25T00:00 本地毫秒);
|
||||||
|
- fallback 触发判定(空 list / 结构异常 code、data=null 两形态 → 降级;HTTP/关键词/non-JSON 真风控形态不降级);
|
||||||
|
- 水位更新:候选**逐轮取 max**(覆盖 fallback 降级重翻场景)+ 只前进写入(sendTime 更大才写;raw 为空也可前进)。
|
||||||
|
|
||||||
|
### 7.2 手动验证清单
|
||||||
|
1. 老客户询盘选"近 1 个月" → 日志出现 `时间边界提前停止`,条数远小于全量;
|
||||||
|
2. 自定义 起>止 → popup 报错不发;
|
||||||
|
3. 选"全部" → 行为与当前版本完全一致(回归);
|
||||||
|
4. **custom 含过去截止日 → 首轮盯 SW Network**(附录 B 已实测通过,此项为实现上线后的复测):`getOpMessages` 请求体 timeStamp 是否为 untilMs+1、返回是否正常;
|
||||||
|
5. 同询盘先"近3月"再"近1月"重抓 → 第二次条数显著少(增量)且无重复推送。
|
||||||
|
|
||||||
|
## 8. 风险与开放问题
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| ~~cursor 传过去时间戳的 API 行为未实测~~ → **已实测(2026-08-25,附录 B)**:受支持,语义=严格小于,+1ms 缓冲正确;残余风险 = 样本单一(1 账号/1 会话) | 保留首轮 fallback 保险(PROBE_FAIL / 空 list → 降级 now)+ 标志仅在"降级重试首批非空"时设置 + 实现后手动清单 #4 复测 |
|
||||||
|
| 复刻验证脚本与 content.js 漂移 | 脚本头注明同步义务(同 verify-risk-detect 现状,可接受的架构权衡) |
|
||||||
|
| 时间模式 + 很老的自定义起点 | MAX_ROUNDS=2000 安全阀兜底;用户自选范围,预期内 |
|
||||||
|
|
||||||
|
## 9. 涉及文件
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| popup.html | 时间范围下拉 + 自定义日期框 + 条数置灰逻辑的 UI |
|
||||||
|
| popup.js | 计算 since/until(自然月/含全天/校验)、置灰切换、start 传参 |
|
||||||
|
| content.js | state 字段、fetchChatByApi(游标/过滤/停止/水位)、loadAllChatHistory(探测/过滤)、lastTopMsg 存储结构 |
|
||||||
|
| scripts/verify-chat-range.mjs | 新增,复刻判定逻辑的自动验证 |
|
||||||
|
|
||||||
|
## 附录 A:§4.3 修订后翻页循环伪代码(实现基准)
|
||||||
|
|
||||||
|
§4.3 的 7 处改动(游标/探测降级/过滤/顺序/停止/日志/水位)在此串成完整循环,实现以此为准:
|
||||||
|
|
||||||
|
```text
|
||||||
|
// 前置:sinceMs/untilMs 已从 state.chatSince/chatUntil 派生(§4.2);lastTop = 读水位(§4.5)
|
||||||
|
jump = untilMs != null && !state.cursorJumpUnsupported
|
||||||
|
cursor = jump ? untilMs + 1 : Date.now()
|
||||||
|
probeRound = jump // 仅跳游标首轮为探测轮
|
||||||
|
candidate = null // 候选水位 {id, sendTime},逐轮取 max(§4.5)
|
||||||
|
stopReason = '轮数上限'
|
||||||
|
for i in 0..MAX_ROUNDS-1:
|
||||||
|
if state.phase == 'paused': return null
|
||||||
|
await waitUntilWorkTime()
|
||||||
|
json = postForm(getOpMessages, { timeStamp: cursor, forward: false, pageSize: 20, ... })
|
||||||
|
if probeRound:
|
||||||
|
probeRound = false
|
||||||
|
try:
|
||||||
|
assertStructure(json) // 非对象 / code≠200 / data=null → throw PROBE_FAIL(独立前缀,勿复用 RISK 前缀)
|
||||||
|
if emptyList(json): throw PROBE_FAIL
|
||||||
|
catch e:
|
||||||
|
if e 是 PROBE_FAIL: // 仅降级集;postForm 的 RISK(non-JSON/HTTP/关键词)在 try 外照常冒泡
|
||||||
|
// ⚠️ 勘误(实现以 §4.3 收紧版为准):此处不立即设 cursorJumpUnsupported;
|
||||||
|
// 实现记 downgradeKind,仅 kind='fail' 且降级重试首批非空才设(empty 多为真空,勿误禁全任务)
|
||||||
|
downgradeKind = kind
|
||||||
|
log('until 跳游标不受支持,降级 cursor=now')
|
||||||
|
cursor = Date.now()
|
||||||
|
await randSleep(2000, 5000)
|
||||||
|
continue // 重试按常规逻辑,不再探测;仍空 list = 正常到顶,不递归降级
|
||||||
|
throw e // 真风控(HTTP/关键词/non-JSON)照常抛出暂停
|
||||||
|
else:
|
||||||
|
assertStructure(json) // 常规轮:结构异常直接 RISK
|
||||||
|
list = json.data?.list || []
|
||||||
|
if list 为空: stopReason = '到顶(空列表)'; break
|
||||||
|
for m of list: // 候选水位:逐轮最大 sendTime,未过滤口径(§4.5)
|
||||||
|
if m.sendTime != null && (candidate == null || m.sendTime > candidate.sendTime):
|
||||||
|
candidate = { id: String(m.messageId), sendTime: m.sendTime }
|
||||||
|
hit = false
|
||||||
|
for m of list: // 顺序:水位命中查未过滤 list,再范围过滤收集(§4.3.2)
|
||||||
|
if lastTop && String(m.messageId) == lastTop.id: hit = true; break
|
||||||
|
if m.sendTime == null || (!sinceMs || m.sendTime >= sinceMs) && (!untilMs || m.sendTime <= untilMs):
|
||||||
|
raw.push(m)
|
||||||
|
cursor = min(list 中非 null 的 sendTime) // null 必须跳过(与现状 [content.js:830-831] 一致);Math.min 含 null 得 0 → 游标跳 1970
|
||||||
|
if hit: stopReason = '增量命中'; break
|
||||||
|
if sinceMs && cursor < sinceMs: stopReason = '时间边界'; break
|
||||||
|
if list.length < 20: stopReason = '到顶'; break
|
||||||
|
await randSleep(2000, 5000)
|
||||||
|
log(`聊天 API 完成: ${raw.length} 条(停:${stopReason})`)
|
||||||
|
if candidate && (旧水位为旧格式string || candidate.sendTime > 旧水位.sendTime):
|
||||||
|
写入 candidate // 水位只前进(§4.5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附录 B:过去时间戳 cursor 实测记录(2026-08-25)
|
||||||
|
|
||||||
|
**环境**:用户登录态,询盘详情页(imInquiryId=13908460211,会话共 25 条消息),页面上下文复刻 content.js 的 postForm(**无签名头**),请求间隔 2.5s,共 4 次请求,未触发任何风控。
|
||||||
|
|
||||||
|
| 组 | cursor | HTTP/code | 返回 | 结论 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A 对照 | `now` | 200/"200" | 20 条(最新 20 条,oldest=8-24T13:06:27.788Z) | 现网行为正常 |
|
||||||
|
| B 过去30天 | `now−30d` | 200/"200" | **空 list,完全静默** | 该会话是新询盘,30 天前确无消息 → 空是**正确语义的空**;同时证明空 list 形态真实存在且与"不支持"无法从单次响应区分(P1-1 降级必要性成立) |
|
||||||
|
| C 边界 | =A 批最旧消息 sendTime | 200/"200" | 5 条,全部**严格小于** cursor(不含 cursor 那条),与 A 批无缝衔接 | ① 语义 = `sendTime < timeStamp`(严格小于,向前翻);② **过去时间戳受支持、方向正确**;③ 翻页连续(cursor=minT 下批不重不漏);④ `untilMs+1` 恰好覆盖 `<= untilMs` 全部消息 |
|
||||||
|
|
||||||
|
**衍生结论**:① until 跳游标可用(省 now→until 全部请求);② +1ms 缓冲正确且必要;③ 空 list 二义性真实 → fallback 保留,但任务级标志仅在"降级重试首批非空"时设置(§4.3 实测精化);④ 复刻 postForm 无签名头直接可用,佐证 §3 选型。
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "Alibaba 询盘数据抓取",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "抓取阿里国际站询盘列表、客户资料、行为数据、聊天记录,支持 customerId 去重,导出为 CSV",
|
||||||
|
"permissions": ["storage", "activeTab", "alarms", "unlimitedStorage", "scripting"],
|
||||||
|
"host_permissions": [
|
||||||
|
"*://message.alibaba.com/*",
|
||||||
|
"*://alicrm.alibaba.com/*",
|
||||||
|
"*://onetalk.alibaba.com/*",
|
||||||
|
"http://localhost:8080/*"
|
||||||
|
],
|
||||||
|
"optional_host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["*://message.alibaba.com/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "popup.html",
|
||||||
|
"default_title": "阿里询盘抓取"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1279
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "alibaba-inquiry-plugin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"fake-indexeddb": "^6.2.5",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { width: 360px; font-family: -apple-system, 'Segoe UI', sans-serif; font-size: 13px; color: #333; }
|
||||||
|
.header { background: #ff6a00; color: #fff; padding: 12px 16px; }
|
||||||
|
.header h1 { font-size: 15px; font-weight: 600; }
|
||||||
|
.header p { font-size: 11px; opacity: 0.85; margin-top: 2px; }
|
||||||
|
.status-bar { padding: 10px 16px; background: #f8f8f8; border-bottom: 1px solid #e5e5e5; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.status-bar .label { color: #888; font-size: 11px; }
|
||||||
|
.status-bar .value { font-weight: 600; color: #ff6a00; }
|
||||||
|
.controls { padding: 12px 16px; }
|
||||||
|
.controls select, .controls button { width: 100%; padding: 8px; margin-bottom: 8px; border-radius: 6px; font-size: 13px; }
|
||||||
|
.controls select { border: 1px solid #ddd; background: #fff; }
|
||||||
|
.btn-primary { background: #ff6a00; color: #fff; border: none; cursor: pointer; }
|
||||||
|
.btn-primary:hover { background: #e55d00; }
|
||||||
|
.btn-primary:disabled { background: #ccc; cursor: not-allowed; }
|
||||||
|
.btn-secondary { background: #fff; color: #333; border: 1px solid #ddd; cursor: pointer; }
|
||||||
|
.btn-secondary:hover { background: #f5f5f5; }
|
||||||
|
.btn-secondary:disabled { color: #ccc; cursor: not-allowed; }
|
||||||
|
.log { padding: 8px 16px; max-height: 200px; overflow-y: auto; background: #fafafa; border-top: 1px solid #e5e5e5; }
|
||||||
|
.log p { font-size: 11px; color: #666; margin-bottom: 2px; word-break: break-all; }
|
||||||
|
.log .success { color: #52c41a; }
|
||||||
|
.log .error { color: #f5222d; }
|
||||||
|
.log .info { color: #1890ff; }
|
||||||
|
.progress-bar { height: 3px; background: #e5e5e5; }
|
||||||
|
.progress-bar .fill { height: 100%; background: #ff6a00; transition: width 0.3s; width: 0%; }
|
||||||
|
.options { padding: 8px 16px; border-top: 1px solid #e5e5e5; }
|
||||||
|
.options label { font-size: 11px; color: #666; display: block; margin-bottom: 4px; }
|
||||||
|
.options label input { margin-right: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>阿里询盘数据抓取</h1>
|
||||||
|
<p>message.alibaba.com 询盘列表 + 详情</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="riskBanner" style="display:none;background:#fff1f0;color:#cf1322;padding:8px 16px;font-size:12px;line-height:1.4;border-bottom:1px solid #ffa39e;">
|
||||||
|
⚠️ 疑似触发 alibaba 风控,抓取已暂停。请到 alibaba 页面检查是否有验证码,处理后再继续。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-bar">
|
||||||
|
<span class="label">状态</span>
|
||||||
|
<span class="value" id="statusText">就绪</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar"><div class="fill" id="progressFill"></div></div>
|
||||||
|
|
||||||
|
<div class="push-status" id="pushStatus" style="padding:6px 16px;font-size:11px;color:#666;background:#fafafa;border-bottom:1px solid #e5e5e5;cursor:pointer;">
|
||||||
|
推送:加载中...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<select id="modeSelect">
|
||||||
|
<option value="list">仅抓取列表页数据</option>
|
||||||
|
<option value="detail" selected>列表 + 详情页完整数据</option>
|
||||||
|
</select>
|
||||||
|
<select id="limitSelect">
|
||||||
|
<option value="0" selected>全部抓取</option>
|
||||||
|
<option value="10">测试:前 10 条</option>
|
||||||
|
<option value="50">前 50 条</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button class="btn-primary" id="btnStart">开始抓取</button>
|
||||||
|
<button class="btn-secondary" id="btnPause" disabled>暂停</button>
|
||||||
|
<button class="btn-secondary" id="btnExport" disabled>导出 CSV</button>
|
||||||
|
<button class="btn-secondary" id="btnClear">清空数据</button>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<label><input type="checkbox" id="optDedup" checked> customerId 去重(保留最新询价单)</label>
|
||||||
|
<label><input type="checkbox" id="optChat"> 包含聊天记录</label>
|
||||||
|
<label>每询盘聊天上限(0=全量;测试可设小值加快): <input type="number" id="chatLimitInput" value="0" min="0" style="width:64px;"> 条 <span id="chatLimitHint" style="color:#999;"></span></label>
|
||||||
|
<label>聊天时间范围:
|
||||||
|
<select id="chatTimeRange" style="width:120px;">
|
||||||
|
<option value="all" selected>全部(不限)</option>
|
||||||
|
<option value="1m">近 1 个月</option>
|
||||||
|
<option value="2m">近 2 个月</option>
|
||||||
|
<option value="3m">近 3 个月</option>
|
||||||
|
<option value="custom">自定义日期…</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div id="chatCustomDates" style="display:none;margin:4px 0;">
|
||||||
|
<input type="date" id="chatSinceDate"> ~ <input type="date" id="chatUntilDate">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="push-config" style="padding:8px 16px;border-top:1px solid #e5e5e5;">
|
||||||
|
<div style="font-size:11px;color:#888;margin-bottom:4px;">后端推送设置</div>
|
||||||
|
<input id="cfgServerUrl" placeholder="https://your-server.com" style="width:100%;padding:6px;margin-bottom:6px;border:1px solid #ddd;border-radius:6px;font-size:12px;">
|
||||||
|
<input id="cfgToken" placeholder="推送 Token" style="width:100%;padding:6px;margin-bottom:6px;border:1px solid #ddd;border-radius:6px;font-size:12px;">
|
||||||
|
<label style="font-size:11px;color:#666;display:block;margin-bottom:6px;"><input type="checkbox" id="cfgEnabled"> 启用推送</label>
|
||||||
|
<button id="btnAuthorize" class="btn-secondary" style="margin-bottom:6px;">授权域名</button>
|
||||||
|
<button id="btnSaveConfig" class="btn-primary">保存设置</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="log" id="logBox"></div>
|
||||||
|
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// popup.js — 控制面板逻辑
|
||||||
|
const btnStart = document.getElementById('btnStart');
|
||||||
|
const btnPause = document.getElementById('btnPause');
|
||||||
|
const btnExport = document.getElementById('btnExport');
|
||||||
|
const btnClear = document.getElementById('btnClear');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
const logBox = document.getElementById('logBox');
|
||||||
|
const modeSelect = document.getElementById('modeSelect');
|
||||||
|
const optDedup = document.getElementById('optDedup');
|
||||||
|
const optChat = document.getElementById('optChat');
|
||||||
|
const limitSelect = document.getElementById('limitSelect');
|
||||||
|
const chatTimeRangeSelect = document.getElementById('chatTimeRange');
|
||||||
|
const chatCustomDates = document.getElementById('chatCustomDates');
|
||||||
|
const chatSinceDate = document.getElementById('chatSinceDate');
|
||||||
|
const chatUntilDate = document.getElementById('chatUntilDate');
|
||||||
|
const chatLimitHint = document.getElementById('chatLimitHint');
|
||||||
|
|
||||||
|
function addLog(msg, type = '') {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.className = type;
|
||||||
|
p.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
||||||
|
logBox.appendChild(p);
|
||||||
|
logBox.scrollTop = logBox.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProgress(current, total) {
|
||||||
|
const pct = total > 0 ? Math.round((current / total) * 100) : 0;
|
||||||
|
progressFill.style.width = pct + '%';
|
||||||
|
statusText.textContent = `${current} / ${total} (${pct}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtons(phase) {
|
||||||
|
const running = phase === 'list' || phase === 'detail';
|
||||||
|
const paused = phase === 'paused';
|
||||||
|
|
||||||
|
btnStart.disabled = running || paused;
|
||||||
|
btnPause.disabled = !running && !paused;
|
||||||
|
btnPause.textContent = paused ? '恢复' : '暂停';
|
||||||
|
btnExport.disabled = running || paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带重试的 send,content script 注入需要时间
|
||||||
|
function send(action, data = {}, retries = 3) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
function attempt(remaining) {
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
|
||||||
|
if (!tabs[0]) { addLog('未找到活动标签页', 'error'); resolve(); return; }
|
||||||
|
|
||||||
|
// 检查是否在正确的域名
|
||||||
|
if (!tabs[0].url || !tabs[0].url.includes('message.alibaba.com')) {
|
||||||
|
addLog('请在 message.alibaba.com 页面操作', 'error');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
chrome.tabs.sendMessage(tabs[0].id, { action, ...data }, resp => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
// content script 未注入(扩展重载后旧页面失效/新标签注入慢)→ 程序化注入兜底。
|
||||||
|
// content.js 自带 __alibabaScraperInit 防重复注入,重复执行安全。
|
||||||
|
chrome.scripting.executeScript({ target: { tabId: tabs[0].id }, files: ['content.js'] }, () => {
|
||||||
|
if (chrome.runtime.lastError) {
|
||||||
|
addLog('自动注入失败: ' + chrome.runtime.lastError.message, 'error');
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (remaining > 0) {
|
||||||
|
setTimeout(() => attempt(remaining - 1), 800); // 注入完成后再试
|
||||||
|
} else {
|
||||||
|
addLog('连接失败,请刷新页面后重试', 'error');
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve(resp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
attempt(retries);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查当前状态
|
||||||
|
send('getState').then(resp => {
|
||||||
|
if (resp) {
|
||||||
|
if (resp.running) {
|
||||||
|
addLog(`有进行中的任务 (列表${resp.listCount}条, 详情${resp.detailCount}条, 队列${resp.queueIndex}/${resp.queueTotal})`, 'info');
|
||||||
|
statusText.textContent = `运行中 (${resp.queueIndex}/${resp.queueTotal})`;
|
||||||
|
updateButtons(resp.phase);
|
||||||
|
} else if (resp.phase === 'paused') {
|
||||||
|
addLog('有暂停的任务,点击"恢复"继续', 'info');
|
||||||
|
statusText.textContent = '已暂停';
|
||||||
|
updateButtons('paused');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnStart.addEventListener('click', async () => {
|
||||||
|
const range = computeChatRange(chatTimeRangeSelect.value, chatSinceDate.value, chatUntilDate.value);
|
||||||
|
if (range.error) { addLog(range.error, 'error'); statusText.textContent = '就绪'; updateButtons('idle'); return; }
|
||||||
|
// 与 range 同步快照:导航等待 5s 内用户仍可改下拉,不能让部分参数冻结、部分活读(参数漂移)
|
||||||
|
const chatLimitSnap = chatTimeRangeSelect.value === 'all' ? (parseInt(document.getElementById('chatLimitInput').value, 10) || 0) : 0;
|
||||||
|
updateButtons('list');
|
||||||
|
hideRiskBanner(); // 开始新任务时隐藏风控红条
|
||||||
|
addLog('开始抓取...', 'info');
|
||||||
|
statusText.textContent = '启动中...';
|
||||||
|
chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
||||||
|
const tab = tabs && tabs[0];
|
||||||
|
// 不在消息页 → 自动跳转,等 content.js 注入后再发 start
|
||||||
|
if (tab && !(tab.url && tab.url.includes('message.alibaba.com'))) {
|
||||||
|
addLog('当前不在消息页,自动跳转...', 'info');
|
||||||
|
statusText.textContent = '跳转消息页...';
|
||||||
|
await chrome.tabs.update(tab.id, { url: 'https://message.alibaba.com/message/default.htm#feedback/all' });
|
||||||
|
await new Promise(r => setTimeout(r, 5000)); // 等 document_idle + content 注入
|
||||||
|
addLog('消息页就绪,开始抓取', 'info');
|
||||||
|
}
|
||||||
|
await send('start', {
|
||||||
|
mode: modeSelect.value,
|
||||||
|
dedup: optDedup.checked,
|
||||||
|
chat: optChat.checked,
|
||||||
|
limit: parseInt(limitSelect.value, 10),
|
||||||
|
chatLimit: chatLimitSnap,
|
||||||
|
chatSince: range.since,
|
||||||
|
chatUntil: range.until,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPause.addEventListener('click', async () => {
|
||||||
|
const phase = btnPause.textContent === '恢复' ? 'resume' : 'pause';
|
||||||
|
await send(phase);
|
||||||
|
if (phase === 'pause') {
|
||||||
|
updateButtons('paused');
|
||||||
|
statusText.textContent = '已暂停';
|
||||||
|
btnPause.textContent = '恢复';
|
||||||
|
addLog('已暂停(支持跨页面恢复)', 'info');
|
||||||
|
} else {
|
||||||
|
updateButtons('list');
|
||||||
|
hideRiskBanner(); // 恢复时隐藏风控红条(用户已处理验证码)
|
||||||
|
statusText.textContent = '恢复中...';
|
||||||
|
btnPause.textContent = '暂停';
|
||||||
|
addLog('恢复抓取...', 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnExport.addEventListener('click', async () => {
|
||||||
|
addLog('生成 CSV...', 'info');
|
||||||
|
const resp = await send('export');
|
||||||
|
if (resp && resp.success) {
|
||||||
|
addLog(`导出 ${resp.count} 条数据`, 'success');
|
||||||
|
const blob = new Blob(['\uFEFF' + resp.csv], { type: 'text/csv;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `alibaba_inquiry_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClear.addEventListener('click', async () => {
|
||||||
|
await send('clear');
|
||||||
|
hideRiskBanner(); // 清空时隐藏风控红条
|
||||||
|
logBox.innerHTML = '';
|
||||||
|
statusText.textContent = '就绪';
|
||||||
|
progressFill.style.width = '0%';
|
||||||
|
btnPause.textContent = '暂停';
|
||||||
|
updateButtons('idle');
|
||||||
|
addLog('数据已清空', 'info');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听 content script 推送
|
||||||
|
chrome.runtime.onMessage.addListener(msg => {
|
||||||
|
if (msg.type === 'log') addLog(msg.text, msg.level || '');
|
||||||
|
if (msg.type === 'progress') setProgress(msg.current, msg.total);
|
||||||
|
if (msg.type === 'done') { updateButtons('done'); btnExport.disabled = false; statusText.textContent = '完成'; }
|
||||||
|
if (msg.type === 'error') { updateButtons('idle'); statusText.textContent = '出错'; }
|
||||||
|
// 安全层 #6:风控报警 —— 顶部红条 + 日志,按钮切到 paused 态
|
||||||
|
if (msg.type === 'risk') {
|
||||||
|
const banner = document.getElementById('riskBanner');
|
||||||
|
if (banner) banner.style.display = 'block';
|
||||||
|
addLog('⚠️ 疑似触发风控: ' + (msg.msg || ''), 'error');
|
||||||
|
updateButtons('paused');
|
||||||
|
statusText.textContent = '风控暂停';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 隐藏风控红条(开始新任务/恢复时调用)
|
||||||
|
function hideRiskBanner() {
|
||||||
|
const banner = document.getElementById('riskBanner');
|
||||||
|
if (banner) banner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==== 聊天时间范围(spec 2026-08-25 §4.1)====
|
||||||
|
// minusMonths/computeChatRange 与 scripts/verify-chat-range.mjs 基准实现逐字同步
|
||||||
|
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
|
||||||
|
function minusMonths(base, n) {
|
||||||
|
const d = new Date(base.getFullYear(), base.getMonth(), 1,
|
||||||
|
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
|
||||||
|
d.setMonth(d.getMonth() - n);
|
||||||
|
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||||
|
d.setDate(Math.min(base.getDate(), lastDay));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
|
||||||
|
function computeChatRange(mode, sinceStr, untilStr) {
|
||||||
|
if (mode === 'all') return { since: null, until: null };
|
||||||
|
if (mode === '1m' || mode === '2m' || mode === '3m') {
|
||||||
|
// 整天边界:since=起始日 00:00:00(完整自然月天数),until=今日 23:59:59.999("至今"取今天整天,与 custom 止=今天一致)
|
||||||
|
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(); dayEnd.setHours(23, 59, 59, 999);
|
||||||
|
return { since: minusMonths(dayStart, Number(mode[0])).toISOString(), until: dayEnd.toISOString() };
|
||||||
|
}
|
||||||
|
if (mode === 'custom') {
|
||||||
|
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
|
||||||
|
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
|
||||||
|
const u = new Date(untilStr + 'T23:59:59.999');
|
||||||
|
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
|
||||||
|
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
|
||||||
|
const uDay = new Date(untilStr + 'T00:00:00.000');
|
||||||
|
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
|
||||||
|
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
|
||||||
|
return { since: s.toISOString(), until: u.toISOString() };
|
||||||
|
}
|
||||||
|
return { error: '未知时间范围模式' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncChatModeUI() {
|
||||||
|
const mode = chatTimeRangeSelect.value;
|
||||||
|
const custom = mode === 'custom';
|
||||||
|
chatCustomDates.style.display = custom ? 'block' : 'none';
|
||||||
|
if (custom && !chatUntilDate.value) {
|
||||||
|
const t = new Date();
|
||||||
|
chatUntilDate.value = `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
const timeMode = mode !== 'all';
|
||||||
|
document.getElementById('chatLimitInput').disabled = timeMode; // D1: 时间替代条数
|
||||||
|
chatLimitHint.textContent = timeMode ? '(时间模式下忽略)' : '';
|
||||||
|
}
|
||||||
|
chatTimeRangeSelect.addEventListener('change', syncChatModeUI);
|
||||||
|
syncChatModeUI();
|
||||||
|
|
||||||
|
// === 后端推送配置 ===
|
||||||
|
const cfgServerUrl = document.getElementById('cfgServerUrl');
|
||||||
|
const cfgToken = document.getElementById('cfgToken');
|
||||||
|
const cfgEnabled = document.getElementById('cfgEnabled');
|
||||||
|
|
||||||
|
// 加载现有配置(background 返回 { ok, config })
|
||||||
|
chrome.runtime.sendMessage({ type: 'getPushConfig' }, resp => {
|
||||||
|
if (chrome.runtime.lastError) { addLog('读取推送配置失败: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||||
|
const cfg = resp && resp.ok ? resp.config : null;
|
||||||
|
cfgServerUrl.value = (cfg && cfg.serverUrl) || 'http://219.157.255.213:23010';
|
||||||
|
cfgToken.value = (cfg && cfg.token) || '688cbb4b6e880b340b2360559c4ad29d01a39ccbb3c31c4d';
|
||||||
|
cfgEnabled.checked = cfg ? !!cfg.enabled : true; // 默认勾「启用推送」(首次未配置时)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 授权域名(chrome.permissions.request 必须在用户手势内)
|
||||||
|
document.getElementById('btnAuthorize').addEventListener('click', () => {
|
||||||
|
const url = cfgServerUrl.value.trim();
|
||||||
|
if (!url) { addLog('请先填写服务器 URL', 'error'); return; }
|
||||||
|
let origin;
|
||||||
|
try { const u = new URL(url); origin = `${u.origin}/*`; }
|
||||||
|
catch { addLog('URL 格式错误', 'error'); return; }
|
||||||
|
chrome.permissions.request({ origins: [origin] }, granted => {
|
||||||
|
if (chrome.runtime.lastError) { addLog('授权请求失败: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||||
|
if (granted) addLog('已授权 ' + origin, 'success');
|
||||||
|
else addLog('授权被拒绝', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 保存设置
|
||||||
|
document.getElementById('btnSaveConfig').addEventListener('click', () => {
|
||||||
|
const config = {
|
||||||
|
serverUrl: cfgServerUrl.value.trim().replace(/\/+$/, ''), // 去尾斜杠
|
||||||
|
token: cfgToken.value.trim(),
|
||||||
|
enabled: cfgEnabled.checked,
|
||||||
|
};
|
||||||
|
chrome.runtime.sendMessage({ type: 'setPushConfig', config }, resp => {
|
||||||
|
if (chrome.runtime.lastError) { addLog('保存失败: ' + chrome.runtime.lastError.message, 'error'); return; }
|
||||||
|
if (resp && resp.ok) addLog('推送设置已保存' + (config.enabled ? '(已启用)' : ''), 'success');
|
||||||
|
else addLog('保存失败', 'error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// === 推送状态显示 ===
|
||||||
|
const pushStatus = document.getElementById('pushStatus');
|
||||||
|
function refreshPushStatus() {
|
||||||
|
chrome.runtime.sendMessage({ type: 'getPushStats' }, resp => {
|
||||||
|
if (chrome.runtime.lastError || !resp || !resp.ok || !resp.stats) {
|
||||||
|
pushStatus.style.color = '#999';
|
||||||
|
pushStatus.textContent = '推送:未启用';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const stats = resp.stats;
|
||||||
|
const pending = stats.pending || 0;
|
||||||
|
const failed = stats.failed || 0;
|
||||||
|
const sent = stats.sent || 0;
|
||||||
|
pushStatus.style.color = failed > 0 ? '#f5222d' : '#666';
|
||||||
|
pushStatus.textContent = `推送:已推 ${sent} / 待推 ${pending} / 失败 ${failed}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refreshPushStatus();
|
||||||
|
const statusTimer = setInterval(refreshPushStatus, 2000);
|
||||||
|
window.addEventListener('unload', () => clearInterval(statusTimer));
|
||||||
|
|
||||||
|
// 失败时点击查看提示
|
||||||
|
pushStatus.addEventListener('click', () => {
|
||||||
|
if (pushStatus.textContent.includes('失败 0')) return;
|
||||||
|
alert('有推送失败项。可在扩展后台日志查看错误,或检查后端是否在线。');
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# 插件 → 后端 端到端验证
|
||||||
|
|
||||||
|
## 自动化验证(push 模块 → 后端链路,已验证 ✅)
|
||||||
|
|
||||||
|
前置:Plan 1 后端在跑(`cd d:/alibb-server/backend && docker compose up -d`,`/api/health` ok)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd d:/alibb && node scripts/e2e-push.mjs
|
||||||
|
# 预期:ingest status 200, inserted 1
|
||||||
|
# 然后查:
|
||||||
|
JWT=$(curl -s -XPOST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin-password-1234"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
|
||||||
|
curl -s "http://localhost:8080/api/inquiries/E2E-TEST2" -H "Authorization: Bearer $JWT"
|
||||||
|
# 预期:看到 inquiry + customer + _count.messages=1
|
||||||
|
```
|
||||||
|
|
||||||
|
这验证 `push/payload.js` 组装的 payload 能被 Plan 1 后端 `/api/ingest` 正确接收入库(询盘+客户+消息)。
|
||||||
|
|
||||||
|
## 完整插件 e2e(需手动,Chrome 加载扩展)
|
||||||
|
|
||||||
|
1. **起后端**:`cd d:/alibb-server/backend && docker compose up -d --build`,等 `curl http://localhost:8080/api/health` → `{"ok":true}`
|
||||||
|
2. **加载扩展**:Chrome 打开 `chrome://extensions` → 开启「开发者模式」→「加载已解压的扩展程序」→ 选 `d:/alibb`
|
||||||
|
3. **配置推送**:点插件图标 →「后端设置」区填:
|
||||||
|
- 服务器 URL:`http://localhost:8080`
|
||||||
|
- Token:`dev-ingest-token-1234`
|
||||||
|
- 勾「启用推送」
|
||||||
|
- 点「授权域名」→ Chrome 弹框点「允许」
|
||||||
|
- 点「保存设置」→ 看到「推送设置已保存(已启用)」
|
||||||
|
4. **抓取**:打开 `https://message.alibaba.com/message/default.htm`(登录阿里主账号)→ 点插件「开始抓取」(detail 模式,测试 10 条)
|
||||||
|
5. **观察**:popup 顶部状态条「推送:已推 N / 待推 M / 失败 K」随抓取增长
|
||||||
|
6. **验证后端有数据**:
|
||||||
|
```bash
|
||||||
|
JWT=$(curl -s -XPOST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin-password-1234"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
|
||||||
|
curl -s "http://localhost:8080/api/inquiries?size=5" -H "Authorization: Bearer $JWT" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const d=JSON.parse(s);console.log('total:',d.total);d.items.forEach(i=>console.log(' ',i.inquiryId,i.customer?.country,'msgs:',i.messagesCount))})"
|
||||||
|
```
|
||||||
|
预期:看到刚抓的询盘,带 country + messagesCount
|
||||||
|
7. **验证幂等**:再抓同一询盘 → 后端 `messages_count` 不变(早返回生效)
|
||||||
|
|
||||||
|
## 断网/重试验证(防丢)
|
||||||
|
|
||||||
|
1. 停后端:`docker compose down`
|
||||||
|
2. 抓一条 → popup 状态「待推 1」(入 IndexedDB 队列,未丢)
|
||||||
|
3. 起后端:`docker compose up -d`
|
||||||
|
4. 等 ~1 分钟(chrome.alarms 触发 worker)→ popup「已推 1」(自动补推)
|
||||||
|
|
||||||
|
## 收尾
|
||||||
|
|
||||||
|
`cd d:/alibb-server/backend && docker compose down`(volume 保留)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// 模拟插件推送:用 push/payload.js 组装 payload,POST 到后端 /api/ingest
|
||||||
|
// 用法: node scripts/e2e-push.mjs (可选 SERVER/TOKEN 环境变量)
|
||||||
|
import { buildIngestPayload } from '../push/payload.js';
|
||||||
|
|
||||||
|
const record = {
|
||||||
|
inquiryId: 'E2E-TEST2', manager: 'tester',
|
||||||
|
createTimeFromApi: '2026-07-14T10:00:00Z', updateTimeFromApi: '2026-07-14T10:00:00Z',
|
||||||
|
status: 'new', customerId: 'c-e2e', detailCountry: 'US', detailBuyerName: 'Test Buyer',
|
||||||
|
behavior: { validRfq: '5' },
|
||||||
|
};
|
||||||
|
const messages = [{
|
||||||
|
messageId: 'e2e-test-m1', sendTime: '2026-07-14T10:00:00Z',
|
||||||
|
sender: 'tester', original: 'hello e2e', direction: 'outgoing',
|
||||||
|
}];
|
||||||
|
|
||||||
|
const payload = buildIngestPayload(record, messages);
|
||||||
|
const SERVER = process.env.SERVER || 'http://localhost:8080';
|
||||||
|
const TOKEN = process.env.TOKEN || 'dev-ingest-token-1234';
|
||||||
|
|
||||||
|
const res = await fetch(`${SERVER}/api/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
console.log('ingest status:', res.status);
|
||||||
|
console.log('ingest body:', JSON.stringify(await res.json()));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 根因验证:后端 customers.customer_id 是 VARCHAR(64)(spec §6.1),
|
||||||
|
// 而插件对无真 customerId 的询盘用 sec_ + secTradeId 兜底(75 字符)。
|
||||||
|
// 本脚本推两条测试数据对比:超长 sec_ vs 短 id,看后端 ingest 的实际反应。
|
||||||
|
// 用法: node scripts/test-cid-length.mjs
|
||||||
|
import { buildIngestPayload } from '../push/payload.js';
|
||||||
|
|
||||||
|
const SERVER = process.env.SERVER || 'http://219.157.255.213:23010';
|
||||||
|
const TOKEN = process.env.TOKEN || '688cbb4b6e880b340b2360559c4ad29d01a39ccbb3c31c4d';
|
||||||
|
|
||||||
|
async function push(label, customerId) {
|
||||||
|
const record = {
|
||||||
|
inquiryId: `TEST-CID-${label}`,
|
||||||
|
customerId,
|
||||||
|
detailBuyerName: 'Test Buyer', detailCountry: 'US', company: 'Test Co',
|
||||||
|
createTimeFromApi: '2026-07-20T00:00:00Z', updateTimeFromApi: '2026-07-20T00:00:00Z',
|
||||||
|
behavior: { productViews: '1' },
|
||||||
|
};
|
||||||
|
const payload = buildIngestPayload(record, []);
|
||||||
|
const res = await fetch(`${SERVER}/api/ingest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
let body = '';
|
||||||
|
try { body = await res.text(); } catch (e) { body = '(no body)'; }
|
||||||
|
console.log(`[${label}] customerId(len=${customerId.length}): status=${res.status}`);
|
||||||
|
console.log(` body=${body.slice(0, 400)}`);
|
||||||
|
console.log(` payload.customer.customerId = ${JSON.stringify(payload.customer && payload.customer.customerId)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const longCid = 'sec_MC1IDX1yqSSuFKld-CG7npDfPBic6Iv1S_ZJecxnw8b7kTejq_GNIDRuGTmXE-sjnZMUpze';
|
||||||
|
console.log('=== longCid length:', longCid.length, '===');
|
||||||
|
await push('LONG75', longCid);
|
||||||
|
await push('SHORT5', '12345');
|
||||||
|
console.log('\n说明:若 LONG75 失败(4xx)而 SHORT5 成功(200) → 证实后端因长度拒绝 sec_ customerId');
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// 验证聊天时间范围判定逻辑(纯函数基准实现)。
|
||||||
|
// content.js/popup.js 为 IIFE/非 module,无法被 vitest import —— 本文件是判定逻辑的
|
||||||
|
// 基准实现+用例锁定;实现侧照搬函数体。⚠️ 改任一侧必须同步另一侧。
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
const ok = (name, cond) => { console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); cond ? pass++ : fail++; };
|
||||||
|
|
||||||
|
// ---- 基准实现(实现照搬区)----
|
||||||
|
// 自然月回退:直接 setMonth(-n) 月末溢出(3-31−1月→"2-31"→3-02),先退月初再钳制天数
|
||||||
|
function minusMonths(base, n) {
|
||||||
|
const d = new Date(base.getFullYear(), base.getMonth(), 1,
|
||||||
|
base.getHours(), base.getMinutes(), base.getSeconds(), base.getMilliseconds());
|
||||||
|
d.setMonth(d.getMonth() - n);
|
||||||
|
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
||||||
|
d.setDate(Math.min(base.getDate(), lastDay));
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
// popup 范围计算:mode=all|1m|2m|3m|custom → {since,until}(ISO|null) 或 {error}
|
||||||
|
function computeChatRange(mode, sinceStr, untilStr) {
|
||||||
|
if (mode === 'all') return { since: null, until: null };
|
||||||
|
if (mode === '1m' || mode === '2m' || mode === '3m') {
|
||||||
|
// 整天边界:since=起始日 00:00:00(完整自然月天数),until=今日 23:59:59.999("至今"取今天整天,与 custom 止=今天一致)
|
||||||
|
const dayStart = new Date(); dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(); dayEnd.setHours(23, 59, 59, 999);
|
||||||
|
return { since: minusMonths(dayStart, Number(mode[0])).toISOString(), until: dayEnd.toISOString() };
|
||||||
|
}
|
||||||
|
if (mode === 'custom') {
|
||||||
|
if (!sinceStr || !untilStr) return { error: '请填写自定义起止日期' };
|
||||||
|
const s = new Date(sinceStr + 'T00:00:00.000'); // ⚠️ 必须拼时间:裸 date-only 按 UTC 解析差 8h
|
||||||
|
const u = new Date(untilStr + 'T23:59:59.999');
|
||||||
|
if (isNaN(s.getTime()) || isNaN(u.getTime())) return { error: '日期格式错误' };
|
||||||
|
if (s.getTime() > u.getTime()) return { error: '起始日期不能晚于截止日期' };
|
||||||
|
const uDay = new Date(untilStr + 'T00:00:00.000');
|
||||||
|
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
|
||||||
|
if (uDay.getTime() > t0.getTime()) return { error: '截止日期不能晚于今天' };
|
||||||
|
return { since: s.toISOString(), until: u.toISOString() };
|
||||||
|
}
|
||||||
|
return { error: '未知时间范围模式' };
|
||||||
|
}
|
||||||
|
// 范围过滤(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; }
|
||||||
|
|
||||||
|
// ---- 用例 ----
|
||||||
|
ok('3-31 − 1月 = 2-28(月末钳制,setMonth 溢出防护)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 2, 31, 15, 30), 1);
|
||||||
|
return r.getMonth() === 1 && r.getDate() === 28 && r.getHours() === 15;
|
||||||
|
})());
|
||||||
|
ok('8-25 − 1月 = 7-25(普通日期)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 7, 25), 1);
|
||||||
|
return r.getMonth() === 6 && r.getDate() === 25;
|
||||||
|
})());
|
||||||
|
ok('12-31 − 2月 = 10-31(同年跨月)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 11, 31), 2);
|
||||||
|
return r.getMonth() === 9 && r.getDate() === 31;
|
||||||
|
})());
|
||||||
|
ok('1-31 − 2月 = 上年 11-30(跨年+钳制)', (() => {
|
||||||
|
const r = minusMonths(new Date(2026, 0, 31), 2);
|
||||||
|
return r.getFullYear() === 2025 && r.getMonth() === 10 && r.getDate() === 30;
|
||||||
|
})());
|
||||||
|
ok('闰年:2028-03-31 − 1月 = 2028-02-29', (() => {
|
||||||
|
const r = minusMonths(new Date(2028, 2, 31), 1);
|
||||||
|
return r.getMonth() === 1 && r.getDate() === 29;
|
||||||
|
})());
|
||||||
|
|
||||||
|
ok('all → 双 null(完全同现状)', computeChatRange('all').since === null && computeChatRange('all').until === null);
|
||||||
|
ok('1m → since=起始日 00:00:00.000(25~32天前),until=今日 23:59:59.999(整天边界)', (() => {
|
||||||
|
const r = computeChatRange('1m');
|
||||||
|
const s = new Date(r.since), u = new Date(r.until);
|
||||||
|
const ageDays = (Date.now() - s.getTime()) / 86400e3;
|
||||||
|
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
|
||||||
|
return ageDays > 25 && ageDays < 32
|
||||||
|
&& s.getHours() === 0 && s.getMinutes() === 0 && s.getSeconds() === 0 && s.getMilliseconds() === 0
|
||||||
|
&& u.getTime() - t0.getTime() === 86399999; // = 今日 0 点 + 全天末毫秒
|
||||||
|
})());
|
||||||
|
ok('custom 本地时区(since=本地0点,非 UTC)', (() => {
|
||||||
|
const r = computeChatRange('custom', '2026-08-25', '2026-08-25');
|
||||||
|
const s = new Date(r.since);
|
||||||
|
return s.getMonth() === 7 && s.getDate() === 25 && s.getHours() === 0 && s.getMinutes() === 0;
|
||||||
|
})());
|
||||||
|
ok('custom until=止日 23:59:59.999 本地(含全天)', (() => {
|
||||||
|
const r = computeChatRange('custom', '2026-08-01', '2026-08-25');
|
||||||
|
const u = new Date(r.until);
|
||||||
|
return u.getDate() === 25 && u.getHours() === 23 && u.getMinutes() === 59 && u.getMilliseconds() === 999;
|
||||||
|
})());
|
||||||
|
ok('custom 起>止 → error', !!computeChatRange('custom', '2026-08-10', '2026-08-01').error);
|
||||||
|
ok('custom 起为空 → error', !!computeChatRange('custom', '', '2026-08-01').error);
|
||||||
|
ok('custom 止>今天 → error', !!computeChatRange('custom', '2026-01-01', '2099-01-01').error);
|
||||||
|
ok('custom 垃圾日期 → error', !!computeChatRange('custom', 'not-a-date', '2026-08-01').error);
|
||||||
|
ok('未知模式 → error', !!computeChatRange('6m').error);
|
||||||
|
|
||||||
|
const SINCE = Date.parse('2026-07-25T00:00:00'), UNTIL = Date.parse('2026-08-25T23:59:59');
|
||||||
|
ok('since 当天消息收(含边界)', inChatRange(SINCE, SINCE, null));
|
||||||
|
ok('since 前一天不收', !inChatRange(SINCE - 86400e3, SINCE, null));
|
||||||
|
ok('until 末毫秒收(含边界)', inChatRange(UNTIL, null, UNTIL));
|
||||||
|
ok('until 次日不收', !inChatRange(UNTIL + 1, null, UNTIL));
|
||||||
|
ok('sendTime=null 防御性保留', inChatRange(null, SINCE, UNTIL));
|
||||||
|
ok('范围不限(null)全收', inChatRange(1, null, null));
|
||||||
|
|
||||||
|
ok('新格式水位解析', (() => { const w = parseWatermark('{"id":"a1","sendTime":1787626659384}'); return w.id === 'a1' && w.sendTime === 1787626659384; })());
|
||||||
|
ok('旧格式(纯string)→ legacy,sendTime 未知', (() => { const w = parseWatermark('abc123'); return w.id === 'abc123' && w.sendTime === null; })());
|
||||||
|
ok('空值(null/undefined/空串)→ null(首次抓取必走)', parseWatermark(null) === null && parseWatermark(undefined) === null && parseWatermark('') === null);
|
||||||
|
ok('只前进:候选6-30 不覆盖 7-28', !shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-06-30')));
|
||||||
|
ok('只前进:候选8-25 覆盖 7-28', shouldWriteWatermark({ id: 'o', sendTime: Date.parse('2026-07-28') }, 'n', Date.parse('2026-08-25')));
|
||||||
|
ok('legacy 允许覆盖一次(升级)', shouldWriteWatermark({ id: 'o', sendTime: null }, 'n', Date.parse('2026-06-30')));
|
||||||
|
ok('无旧水位 → 写入', shouldWriteWatermark(null, 'n', 1));
|
||||||
|
ok('候选缺 sendTime → 不写', !shouldWriteWatermark({ id: 'o', sendTime: 5 }, 'n', null));
|
||||||
|
|
||||||
|
ok('probe 正常(200+非空) → null', probeFailKind({ code: '200', data: { list: [{}] } }) === null);
|
||||||
|
ok('probe code≠200 → fail 降级', probeFailKind({ code: '500' }) === 'fail');
|
||||||
|
ok('probe data=null → fail 降级', probeFailKind({ code: '200', data: null }) === 'fail');
|
||||||
|
ok('probe 非对象 → fail 降级', probeFailKind('ok') === 'fail');
|
||||||
|
ok('probe 空 list → empty 降级(实测形态)', probeFailKind({ code: '200', data: { list: [] } }) === 'empty');
|
||||||
|
ok('probe list 缺失 → empty 降级', probeFailKind({ code: '200', data: {} }) === 'empty');
|
||||||
|
|
||||||
|
ok('toMs 非法/空 → null,合法 → 毫秒', toMs('garbage') === null && toMs(null) === null && toMs('2026-08-25T00:00:00Z') === Date.parse('2026-08-25T00:00:00Z'));
|
||||||
|
// (文档性说明,非行为锁定)until 游标 = untilMs+1:附录B实测语义=返回 sendTime < cursor,恰好覆盖 <= untilMs;真实验证靠 Task 8 #4 SW Network
|
||||||
|
ok('文档性:until 游标 +1ms 覆盖含止日末毫秒(真实验证靠 Task 8 #4)', (() => {
|
||||||
|
const untilMs = Date.parse('2026-06-30T23:59:59.999');
|
||||||
|
const cursor = untilMs + 1;
|
||||||
|
return (untilMs < cursor) && !(untilMs + 1 < cursor);
|
||||||
|
})());
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// 验证 content.js postForm 的风控检测逻辑(纯函数复刻版,须与 content.js 内 postForm 保持一致)。
|
||||||
|
// content.js 是 IIFE 闭包 + content_script 不能 ES module import,内部函数无法被 vitest 直接测,
|
||||||
|
// 故在此复刻逻辑、跑关键 case 佐证正确性。改 content.js 的检测逻辑时务必同步本文件。
|
||||||
|
const RISK_KEYWORDS = ['验证码', 'captcha', '滑动验证', '安全验证', 'blocked', '访问频繁', '限制访问', '风控'];
|
||||||
|
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()));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复刻 content.js 的账号注销/管控跳过检测(与 hitsSkipKeyword 保持一致)
|
||||||
|
const SKIP_KEYWORDS = ['对方账号已自行注销', '违反平台规则被管控'];
|
||||||
|
const hitsSkipKeyword = s => {
|
||||||
|
const lower = String(s == null ? '' : s).toLowerCase();
|
||||||
|
return SKIP_KEYWORDS.find(kw => lower.includes(String(kw).toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复刻 postForm 的检测分支(去掉 fetch,直接吃响应 text)。返回 {risk} 或 {ok, json}。
|
||||||
|
function detect(text) {
|
||||||
|
let j;
|
||||||
|
try { j = JSON.parse(text); }
|
||||||
|
catch (e) {
|
||||||
|
const kw = hitsRiskKeyword(text);
|
||||||
|
if (kw) return { risk: 'RISK:关键词(' + kw + ')' };
|
||||||
|
return { risk: 'RISK:结构异常(non-JSON)' };
|
||||||
|
}
|
||||||
|
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) return { risk: 'RISK:关键词(' + kw + '@' + f + ')' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, json: j };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cases = [
|
||||||
|
{
|
||||||
|
name: '正常 getOpMessages:正文含 captcha/blocked/验证码 不应误报(核心回归)',
|
||||||
|
text: JSON.stringify({ code: '200', data: { list: [
|
||||||
|
{ messageId: '1', content: 'are you blocked? the captcha 验证码 is wrong', sendTime: 1700000000000 },
|
||||||
|
] } }),
|
||||||
|
expectRisk: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'HTML 滑块验证页(非JSON,含关键词)→ 应报风控',
|
||||||
|
text: '<html><body>请完成滑动验证 security check captcha required</body></html>',
|
||||||
|
expectRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'JSON 顶层 msg="访问频繁" → 应报风控',
|
||||||
|
text: JSON.stringify({ code: '200', msg: '您访问频繁,请稍后再试' }),
|
||||||
|
expectRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'JSON 顶层 errMsg 含 captcha → 应风控(字段名大小写无关)',
|
||||||
|
text: JSON.stringify({ errMsg: 'captcha verification failed' }),
|
||||||
|
expectRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '非JSON 无关键词(登录页)→ 结构异常,应暂停',
|
||||||
|
text: '<html><body>please login</body></html>',
|
||||||
|
expectRisk: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '正常 querySummary → 不报',
|
||||||
|
text: JSON.stringify({ code: 200, data: { owner: { accountIdEncrypt: 'x' } } }),
|
||||||
|
expectRisk: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let pass = 0, fail = 0;
|
||||||
|
for (const c of cases) {
|
||||||
|
const r = detect(c.text);
|
||||||
|
const got = !!r.risk;
|
||||||
|
const ok = got === c.expectRisk;
|
||||||
|
console.log((ok ? 'PASS' : 'FAIL') + ' | ' + c.name + (r.risk ? ' -> ' + r.risk : ' -> ok'));
|
||||||
|
ok ? pass++ : fail++;
|
||||||
|
}
|
||||||
|
console.log(`\n--- SKIP 检测(账号注销/管控) ---`);
|
||||||
|
const skipCases = [
|
||||||
|
{ name: '账号注销完整提示 → 应命中 SKIP', s: '对方账号已自行注销或者因违反平台规则被管控,当前无法回复。', expect: true },
|
||||||
|
{ name: '仅“违反平台规则被管控”片段 → 应命中 SKIP', s: '该成员因违反平台规则被管控', expect: true },
|
||||||
|
{ name: '正常聊天 → 不应命中 SKIP', s: 'hello, please send price', expect: false },
|
||||||
|
{ name: '含“管控”但非违规语境(管控措施) → 不应误命中', s: '我们公司有严格的管控措施', expect: false },
|
||||||
|
];
|
||||||
|
for (const c of skipCases) {
|
||||||
|
const got = !!hitsSkipKeyword(c.s);
|
||||||
|
const ok = got === c.expect;
|
||||||
|
console.log((ok ? 'PASS' : 'FAIL') + ' | ' + c.name + ' -> ' + (got ? 'SKIP' : 'ok'));
|
||||||
|
ok ? pass++ : fail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${pass} passed, ${fail} failed`);
|
||||||
|
process.exit(fail ? 1 : 0);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { loadConfig, saveConfig } from '../push/config.js';
|
||||||
|
|
||||||
|
describe('config', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 内存版 chrome.storage.local,验证 load/save 往返(不依赖真实 chrome)
|
||||||
|
const store = {};
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn(async key => {
|
||||||
|
const k = typeof key === 'string' ? key : key[0];
|
||||||
|
return k in store ? { [k]: store[k] } : {};
|
||||||
|
}),
|
||||||
|
set: vi.fn(async obj => { Object.assign(store, obj); }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadConfig returns disabled default when unset', async () => {
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
expect(cfg.enabled).toBe(false);
|
||||||
|
expect(cfg.serverUrl).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saveConfig then loadConfig round-trips', async () => {
|
||||||
|
await saveConfig({ enabled: true, serverUrl: 'http://x', token: 't' });
|
||||||
|
const cfg = await loadConfig();
|
||||||
|
expect(cfg.enabled).toBe(true);
|
||||||
|
expect(cfg.serverUrl).toBe('http://x');
|
||||||
|
expect(cfg.token).toBe('t');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildIngestPayload } from '../push/payload.js';
|
||||||
|
|
||||||
|
describe('buildIngestPayload', () => {
|
||||||
|
it('maps record + messages to ingest schema', () => {
|
||||||
|
const record = {
|
||||||
|
inquiryId: '13776617920',
|
||||||
|
manager: 'cara Hu',
|
||||||
|
createTimeFromApi: '2026-03-13T10:11:50Z',
|
||||||
|
updateTimeFromApi: '2026-07-13T10:11:50Z',
|
||||||
|
status: '洽谈中',
|
||||||
|
msgPreview: 'hi',
|
||||||
|
isFlagged: false,
|
||||||
|
customerId: 'cid-1',
|
||||||
|
detailBuyerName: 'Carlos',
|
||||||
|
detailCountry: 'PE',
|
||||||
|
company: 'World Shop',
|
||||||
|
registerTime: '2025-06-23T00:00:00Z',
|
||||||
|
behavior: { productViews: '0', validRFQ: '39' },
|
||||||
|
};
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
messageId: 'm1',
|
||||||
|
sendTime: '2026-07-13T02:11:50Z',
|
||||||
|
sender: 'cara Hu',
|
||||||
|
original: 'hello',
|
||||||
|
direction: 'outgoing',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const out = buildIngestPayload(record, messages);
|
||||||
|
expect(out.inquiry.inquiryId).toBe('13776617920');
|
||||||
|
expect(out.inquiry.createTime).toBe('2026-03-13T10:11:50Z');
|
||||||
|
expect(out.customer.customerId).toBe('cid-1');
|
||||||
|
expect(out.customer.country).toBe('PE');
|
||||||
|
expect(out.customer.behavior.productViews).toBe(0); // numeric string -> number
|
||||||
|
expect(out.customer.behavior.validRfq).toBe(39);
|
||||||
|
expect(out.messages[0].messageId).toBe('m1');
|
||||||
|
expect(out.messages[0].content).toBe('hello'); // original -> content
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null customer when no customerId', () => {
|
||||||
|
const out = buildIngestPayload(
|
||||||
|
{
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messageId: 'm',
|
||||||
|
sendTime: '2026-01-01T00:00:00Z',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'c',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(out.customer).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows empty messages (chat deferred to later phase)', () => {
|
||||||
|
const out = buildIngestPayload(
|
||||||
|
{ inquiryId: 'X', createTimeFromApi: '2026-01-01T00:00:00Z', updateTimeFromApi: '2026-01-01T00:00:00Z' },
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(out.messages).toEqual([]);
|
||||||
|
expect(out.inquiry.inquiryId).toBe('X');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-ISO createTime (list page "11:58") falls back to current ISO', () => {
|
||||||
|
const out = buildIngestPayload({ inquiryId: 'X', createTime: '11:58', updateTime: '13:39' }, []);
|
||||||
|
expect(out.inquiry.createTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
||||||
|
expect(out.inquiry.updateTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('empty string -> null for optional fields', () => {
|
||||||
|
const out = buildIngestPayload(
|
||||||
|
{
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
customerId: 'c',
|
||||||
|
email: '',
|
||||||
|
company: '',
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
messageId: 'm',
|
||||||
|
sendTime: '2026-01-01T00:00:00Z',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'c',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
expect(out.customer.email).toBeNull();
|
||||||
|
expect(out.customer.company).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { addItem, getPending, markStatus, remove, getStats } from '../push/queue.js';
|
||||||
|
|
||||||
|
const DB_NAME = 'inquiry_push';
|
||||||
|
|
||||||
|
describe('queue', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts with a clean DB. fake-indexeddb exposes indexedDB
|
||||||
|
// globally via the setupFiles entry. databases() may be missing on
|
||||||
|
// some builds, so fall back to the known DB name we use in queue.js.
|
||||||
|
const dbs = (await indexedDB.databases?.().catch(() => null)) || [];
|
||||||
|
const names = dbs.length ? dbs.map(d => d.name) : [DB_NAME];
|
||||||
|
await Promise.all(names.map(name => new Promise(r => {
|
||||||
|
if (!name) { r(); return; }
|
||||||
|
const req = indexedDB.deleteDatabase(name);
|
||||||
|
req.onsuccess = req.onerror = req.onblocked = () => r();
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addItem then getPending returns it', async () => {
|
||||||
|
const id = await addItem({
|
||||||
|
payload: { record: { inquiryId: 'X1' }, messages: [{ messageId: 'm1' }] },
|
||||||
|
inquiryId: 'X1',
|
||||||
|
});
|
||||||
|
const pending = await getPending(5);
|
||||||
|
expect(pending.length).toBe(1);
|
||||||
|
expect(pending[0].id).toBe(id);
|
||||||
|
expect(pending[0].status).toBe('pending');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markStatus pending->sent then not in pending', async () => {
|
||||||
|
const id = await addItem({
|
||||||
|
payload: { record: {}, messages: [] },
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
await markStatus(id, 'sent');
|
||||||
|
expect((await getPending(5)).length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getStats counts by status', async () => {
|
||||||
|
await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'A' });
|
||||||
|
const id2 = await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'B' });
|
||||||
|
await markStatus(id2, 'failed', { retries: 8 });
|
||||||
|
const stats = await getStats();
|
||||||
|
expect(stats.pending).toBe(1);
|
||||||
|
expect(stats.failed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remove deletes item', async () => {
|
||||||
|
const id = await addItem({ payload: { record: {}, messages: [] }, inquiryId: 'A' });
|
||||||
|
await remove(id);
|
||||||
|
expect((await getPending(5)).length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// 复刻函数漂移防护:content.js / popup.js 的内联函数块必须与 scripts/verify-chat-range.mjs
|
||||||
|
// 基准照搬区逐字一致。比对前统一 CRLF→LF(content.js 全文件 CRLF,基准/popup 为 LF,
|
||||||
|
// 行尾风格非漂移信号)并去尾部空白(切块端点的空行/缩进差异非漂移信号);
|
||||||
|
// 除此之外必须逐字节一致 —— 改基准或实现任一侧后此测试变红 = 你忘了同步另一侧。
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const read = p => readFileSync(new URL(p, import.meta.url), 'utf8');
|
||||||
|
const verify = read('../scripts/verify-chat-range.mjs');
|
||||||
|
const content = read('../content.js');
|
||||||
|
const popup = read('../popup.js');
|
||||||
|
|
||||||
|
// CRLF→LF + 去尾部空白(仅此两项豁免,内容仍须逐字一致)
|
||||||
|
const norm = s => s.replace(/\r\n/g, '\n').replace(/\s+$/, '');
|
||||||
|
|
||||||
|
// 从 src 中切出 [startMarker, endMarker) 之间的文本(含 start,不含 end)
|
||||||
|
function sliceBetween(src, startMarker, endMarker) {
|
||||||
|
const s = src.indexOf(startMarker);
|
||||||
|
if (s < 0) return null;
|
||||||
|
const e = endMarker ? src.indexOf(endMarker, s) : -1;
|
||||||
|
return src.slice(s, e < 0 ? undefined : e);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('replica sync drift guard', () => {
|
||||||
|
it('content.js 5-function block byte-matches verify baseline region', () => {
|
||||||
|
const base = sliceBetween(verify, '// 范围过滤(null sendTime 防御性保留', '// ---- 用例');
|
||||||
|
const inl = sliceBetween(content, '// 范围过滤(null sendTime 防御性保留', '// ============ 安全层:风控关键词');
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
expect(inl).toBeTruthy();
|
||||||
|
expect(norm(inl)).toBe(norm(base));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('popup.js minusMonths+computeChatRange byte-matches verify baseline region', () => {
|
||||||
|
const base = sliceBetween(verify, '// 自然月回退:直接 setMonth', '// 范围过滤(null sendTime 防御性保留');
|
||||||
|
const inl = sliceBetween(popup, '// 自然月回退:直接 setMonth', 'function syncChatModeUI');
|
||||||
|
expect(base).toBeTruthy();
|
||||||
|
expect(inl).toBeTruthy();
|
||||||
|
expect(norm(inl)).toBe(norm(base));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { addItem } from '../push/queue.js';
|
||||||
|
import { processQueue } from '../push/worker.js';
|
||||||
|
|
||||||
|
const DB_NAME = 'inquiry_push';
|
||||||
|
|
||||||
|
async function dumpAll() {
|
||||||
|
// Bypass getPending (which filters by status/nextRetry) to inspect raw rows.
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = indexedDB.open(DB_NAME);
|
||||||
|
req.onsuccess = () => {
|
||||||
|
const db = req.result;
|
||||||
|
const tx = db.transaction('queue', 'readonly').objectStore('queue').getAll();
|
||||||
|
tx.onsuccess = () => { db.close(); resolve(tx.result); };
|
||||||
|
tx.onerror = () => { db.close(); reject(tx.error); };
|
||||||
|
};
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker 无状态:每次 processQueue 自己从 chrome.storage 读配置(模拟已保存的启用配置)。
|
||||||
|
// 不再用 configure() 注入 —— 模块级配置会在 SW 休眠重启后丢失(正是 B 的根因)。
|
||||||
|
function mockStorage(config) {
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: vi.fn().mockResolvedValue({ push_config: config }) } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const dbs = (await indexedDB.databases?.().catch(() => null)) || [];
|
||||||
|
const names = dbs.length ? dbs.map(d => d.name) : [DB_NAME];
|
||||||
|
await Promise.all(names.map(name => new Promise(r => {
|
||||||
|
if (!name) { r(); return; }
|
||||||
|
const req = indexedDB.deleteDatabase(name);
|
||||||
|
req.onsuccess = req.onerror = req.onblocked = () => r();
|
||||||
|
})));
|
||||||
|
mockStorage({ enabled: true, serverUrl: 'http://test', token: 'tok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('worker', () => {
|
||||||
|
it('pushes when SW resumed without configure() — reads config from storage (regression for B)', async () => {
|
||||||
|
// SW 休眠重启后模块级配置丢失;worker 必须自己从 storage 读,不能依赖 configure()。
|
||||||
|
// beforeEach 已 mock 启用配置,此处故意不调 configure —— 复现并锁定 B 的修复。
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
await addItem({
|
||||||
|
payload: {
|
||||||
|
record: {
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
const r = await processQueue();
|
||||||
|
expect(r.processed).toBe(1);
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POSTs pending, marks sent on 200', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
globalThis.fetch = fetchMock;
|
||||||
|
const id = await addItem({
|
||||||
|
payload: {
|
||||||
|
record: {
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
messageId: 'm1',
|
||||||
|
sendTime: '2026-01-01T00:00:00Z',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'c',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
const r = await processQueue();
|
||||||
|
expect(r.processed).toBe(1);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMock.mock.calls[0][0]).toBe('http://test/api/ingest');
|
||||||
|
const all = await dumpAll();
|
||||||
|
expect(all.find(x => x.id === id).status).toBe('sent');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('successful push counts as sent in getStats (not silently removed)', async () => {
|
||||||
|
// 显示 bug 回归:成功推送的记录必须能在 getStats 里看到(sent),
|
||||||
|
// 否则 popup 永远显示"已推 0"(旧实现成功即 remove → 统计不到)。
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
||||||
|
await addItem({
|
||||||
|
payload: {
|
||||||
|
record: {
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
await processQueue();
|
||||||
|
const { getStats } = await import('../push/queue.js');
|
||||||
|
const stats = await getStats();
|
||||||
|
expect(stats.sent).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on failure, retries++ and nextRetry in future', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'));
|
||||||
|
const id = await addItem({
|
||||||
|
payload: {
|
||||||
|
record: {
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
messageId: 'm',
|
||||||
|
sendTime: '2026-01-01T00:00:00Z',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'c',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
await processQueue();
|
||||||
|
const all = await dumpAll();
|
||||||
|
const item = all.find(x => x.id === id);
|
||||||
|
expect(item.status).toBe('pending');
|
||||||
|
expect(item.retries).toBe(1);
|
||||||
|
expect(item.nextRetry).toBeGreaterThan(Date.now());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after MAX_RETRIES, mark failed', async () => {
|
||||||
|
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'));
|
||||||
|
const id = await addItem({
|
||||||
|
payload: {
|
||||||
|
record: {
|
||||||
|
inquiryId: 'X',
|
||||||
|
createTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
updateTimeFromApi: '2026-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
messageId: 'm',
|
||||||
|
sendTime: '2026-01-01T00:00:00Z',
|
||||||
|
direction: 'outgoing',
|
||||||
|
content: 'c',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
inquiryId: 'X',
|
||||||
|
});
|
||||||
|
// Pre-seed retries=7 so one more failure pushes it to 8 -> failed.
|
||||||
|
const { markStatus } = await import('../push/queue.js');
|
||||||
|
await markStatus(id, 'pending', { retries: 7, nextRetry: 0 });
|
||||||
|
await processQueue();
|
||||||
|
const all = await dumpAll();
|
||||||
|
const item = all.find(x => x.id === id);
|
||||||
|
expect(item.status).toBe('failed');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
testTimeout: 30000,
|
||||||
|
// Vitest 4: `test.poolOptions.forks.singleFork` was removed; run all test
|
||||||
|
// files in a single fork sequentially (maxWorkers=1) to keep fake-indexeddb
|
||||||
|
// in-memory state consistent across tests.
|
||||||
|
fileParallelism: false,
|
||||||
|
setupFiles: ['fake-indexeddb/auto'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,629 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>阿里询盘数据抓取 · 使用指南</title>
|
||||||
|
<meta name="description" content="普通用户图文操作指南 — 五步学会使用阿里询盘数据抓取浏览器扩展。" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&family=Noto+Serif+SC:wght@500;700;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f7f3ec;
|
||||||
|
--bg-2: #f1ead9;
|
||||||
|
--paper: #ffffff;
|
||||||
|
--line: #e7deca;
|
||||||
|
--line-2: #efe8d6;
|
||||||
|
--ink: #2b2620;
|
||||||
|
--ink-2: #5e5648;
|
||||||
|
--ink-3: #8a8170;
|
||||||
|
--ink-4: #b3a994;
|
||||||
|
--accent: #c75d3a;
|
||||||
|
--accent-d: #a8492a;
|
||||||
|
--accent-l: #f0c4b2;
|
||||||
|
--accent-bg: #fbe9e1;
|
||||||
|
--teal: #3d7a76;
|
||||||
|
--green: #5d8a5e;
|
||||||
|
--blue: #4a6fa5;
|
||||||
|
--amber: #d99a3e;
|
||||||
|
--serif: "Noto Serif SC", "Songti SC", "STSong", serif;
|
||||||
|
--sans: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||||
|
--mono: "JetBrains Mono", "SF Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.8;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
body::before {
|
||||||
|
content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(800px 500px at 85% -5%, rgba(199,93,58,.07), transparent 60%),
|
||||||
|
radial-gradient(600px 400px at -5% 8%, rgba(61,122,118,.05), transparent 55%);
|
||||||
|
}
|
||||||
|
a { color: var(--accent-d); }
|
||||||
|
.wrap { max-width: 760px; margin: 0 auto; padding: 0 24px; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* —— Header —— */
|
||||||
|
header.bar {
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
background: rgba(247,243,236,.88);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.bar-in { max-width: 760px; margin: 0 auto; padding: 13px 24px;
|
||||||
|
display: flex; align-items: center; gap: 12px; }
|
||||||
|
.logo {
|
||||||
|
width: 32px; height: 32px; flex: 0 0 32px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
font-family: var(--serif); font-weight: 900; font-size: 17px;
|
||||||
|
border-radius: 7px; transform: rotate(-4deg);
|
||||||
|
box-shadow: 0 4px 10px -3px rgba(199,93,58,.5);
|
||||||
|
}
|
||||||
|
.bar b { font-family: var(--serif); font-weight: 700; font-size: 15.5px; color: var(--ink); }
|
||||||
|
.bar .ver { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); margin-left: auto; }
|
||||||
|
|
||||||
|
/* —— Hero —— */
|
||||||
|
.hero { padding: 64px 0 40px; text-align: center; }
|
||||||
|
.kicker {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--accent-d);
|
||||||
|
letter-spacing: 3px; text-transform: uppercase;
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--accent-l);
|
||||||
|
padding: 5px 13px; border-radius: 999px; margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-family: var(--serif); font-weight: 900;
|
||||||
|
font-size: clamp(34px, 6vw, 50px);
|
||||||
|
line-height: 1.15; margin: 0 0 14px; color: var(--ink);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.hero h1 em { font-style: normal; color: var(--accent); }
|
||||||
|
.hero p.sub { font-size: 17px; color: var(--ink-2); max-width: 520px; margin: 0 auto 28px; }
|
||||||
|
.steps-overview {
|
||||||
|
display: flex; flex-wrap: wrap; justify-content: center; gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
font-size: 12.5px; color: var(--ink-2);
|
||||||
|
background: var(--paper); border: 1px solid var(--line);
|
||||||
|
padding: 6px 13px; border-radius: 999px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
transition: border-color .15s, color .15s;
|
||||||
|
}
|
||||||
|
.chip:hover { border-color: var(--accent-l); color: var(--accent-d); text-decoration: none; }
|
||||||
|
.chip b { color: var(--accent); font-family: var(--mono); font-size: 11px; }
|
||||||
|
|
||||||
|
/* —— 通用 section —— */
|
||||||
|
section { padding: 36px 0 8px; }
|
||||||
|
.lead-card {
|
||||||
|
background: var(--paper); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; padding: 22px 26px; margin: 8px 0 0;
|
||||||
|
box-shadow: 0 6px 20px -10px rgba(80,55,25,.12);
|
||||||
|
}
|
||||||
|
.lead-card h2 {
|
||||||
|
font-family: var(--serif); font-weight: 700; font-size: 19px;
|
||||||
|
margin: 0 0 12px; color: var(--ink);
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
}
|
||||||
|
.lead-card h2 .ic { color: var(--accent); }
|
||||||
|
.lead-card p { margin: 0; color: var(--ink-2); font-size: 15px; }
|
||||||
|
.lead-card ul { margin: 10px 0 0; padding-left: 20px; color: var(--ink-2); }
|
||||||
|
.lead-card li { margin-bottom: 5px; }
|
||||||
|
.lead-card li::marker { color: var(--accent); }
|
||||||
|
|
||||||
|
/* —— 步骤 —— */
|
||||||
|
.step { padding: 44px 0 8px; border-top: 1px dashed var(--line); }
|
||||||
|
.step:first-of-type { border-top: none; }
|
||||||
|
.step-head { display: flex; align-items: center; gap: 16px; margin-bottom: 6px; }
|
||||||
|
.step-num {
|
||||||
|
width: 48px; height: 48px; flex: 0 0 48px;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--ink); color: var(--bg);
|
||||||
|
font-family: var(--serif); font-weight: 900; font-size: 21px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 6px 14px -5px rgba(43,38,32,.4);
|
||||||
|
}
|
||||||
|
.step-head h2 {
|
||||||
|
font-family: var(--serif); font-weight: 700; font-size: 25px;
|
||||||
|
margin: 0; color: var(--ink); line-height: 1.2;
|
||||||
|
}
|
||||||
|
.step-head .when {
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--ink-3);
|
||||||
|
margin-top: 3px; display: block;
|
||||||
|
}
|
||||||
|
.step-goal { color: var(--ink-2); font-size: 16px; margin: 4px 0 18px; padding-left: 64px; }
|
||||||
|
.step-goal strong { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
.points { list-style: none; padding: 0; margin: 18px 0 0; counter-reset: pt; }
|
||||||
|
.points li {
|
||||||
|
position: relative; padding: 4px 0 4px 34px; margin-bottom: 9px;
|
||||||
|
color: var(--ink-2); font-size: 15.5px;
|
||||||
|
}
|
||||||
|
.points li::before {
|
||||||
|
counter-increment: pt;
|
||||||
|
content: counter(pt);
|
||||||
|
position: absolute; left: 0; top: 7px;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
background: var(--accent-bg); color: var(--accent-d);
|
||||||
|
border: 1px solid var(--accent-l);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-family: var(--mono); font-size: 11px; font-weight: 700;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.points li b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
.note {
|
||||||
|
margin-top: 16px; padding: 12px 16px;
|
||||||
|
background: #fbf5e6; border: 1px solid #ecd9a8; border-left: 3px solid var(--amber);
|
||||||
|
border-radius: 8px; font-size: 14px; color: var(--ink-2);
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
}
|
||||||
|
.note .g { color: var(--amber); font-weight: 700; flex: 0 0 auto; }
|
||||||
|
.note.ok { background: #eef5ec; border-color: #c7dcc6; border-left-color: var(--green); }
|
||||||
|
.note.ok .g { color: var(--green); }
|
||||||
|
.note code { font-family: var(--mono); background: rgba(0,0,0,.05); padding: 1px 6px; border-radius: 3px; font-size: 12.5px; color: var(--ink); }
|
||||||
|
|
||||||
|
/* —— 配图(屏幕 mock 通用)—— */
|
||||||
|
.fig { margin: 4px 0 4px; }
|
||||||
|
.screen {
|
||||||
|
background: var(--paper);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 14px 36px -16px rgba(80,55,25,.22), 0 3px 8px -3px rgba(80,55,25,.08);
|
||||||
|
}
|
||||||
|
.screen-bar {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 9px 13px;
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.dots { display: flex; gap: 5px; flex: 0 0 auto; }
|
||||||
|
.dots i { width: 10px; height: 10px; border-radius: 50%; display: block; }
|
||||||
|
.dots i:nth-child(1) { background: #e0705f; }
|
||||||
|
.dots i:nth-child(2) { background: #e5b14a; }
|
||||||
|
.dots i:nth-child(3) { background: #6ba86a; }
|
||||||
|
.addr {
|
||||||
|
flex: 1; background: var(--paper); border: 1px solid var(--line);
|
||||||
|
border-radius: 8px; padding: 4px 11px;
|
||||||
|
font-family: var(--mono); font-size: 11px; color: var(--ink-3);
|
||||||
|
display: flex; align-items: center; gap: 6px; min-width: 0;
|
||||||
|
}
|
||||||
|
.addr .lock { color: var(--green); }
|
||||||
|
.addr .puzzle {
|
||||||
|
margin-left: auto; width: 16px; height: 16px;
|
||||||
|
background: var(--accent-bg); border: 1px solid var(--accent-l);
|
||||||
|
border-radius: 3px; display: grid; place-items: center;
|
||||||
|
color: var(--accent-d); font-size: 9px;
|
||||||
|
}
|
||||||
|
.screen-body { padding: 16px; }
|
||||||
|
|
||||||
|
/* —— 图1:扩展管理页 —— */
|
||||||
|
.ext-toolbar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--line-2); flex-wrap: wrap; }
|
||||||
|
.ext-btn {
|
||||||
|
font-size: 12px; color: var(--ink-2); background: var(--paper);
|
||||||
|
border: 1px solid var(--line); border-radius: 6px; padding: 6px 11px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.ext-btn.hi { border-color: var(--accent); color: var(--accent-d); background: var(--accent-bg); font-weight: 500; position: relative; }
|
||||||
|
.ext-btn .folder { color: var(--amber); }
|
||||||
|
.dev-toggle { margin-left: auto; display: inline-flex; align-items: center; gap: 7px; font-size: 12px; color: var(--ink-2); }
|
||||||
|
.switch { width: 34px; height: 19px; background: var(--green); border-radius: 999px; position: relative; }
|
||||||
|
.switch::after { content: ""; position: absolute; right: 2px; top: 2px; width: 15px; height: 15px; background: #fff; border-radius: 50%; box-shadow: 0 1px 2px rgba(0,0,0,.2); }
|
||||||
|
.switch.off { background: #cccba8; }
|
||||||
|
.switch.off::after { right: auto; left: 2px; }
|
||||||
|
.ext-card {
|
||||||
|
display: flex; align-items: center; gap: 12px; padding: 14px;
|
||||||
|
border-bottom: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.ext-card .ic {
|
||||||
|
width: 38px; height: 38px; flex: 0 0 38px; border-radius: 8px;
|
||||||
|
background: var(--accent); color: #fff; display: grid; place-items: center;
|
||||||
|
font-family: var(--serif); font-weight: 900; font-size: 18px; transform: rotate(-4deg);
|
||||||
|
}
|
||||||
|
.ext-card .meta { flex: 1; min-width: 0; }
|
||||||
|
.ext-card .meta b { display: block; font-size: 13.5px; color: var(--ink); }
|
||||||
|
.ext-card .meta span { font-size: 11.5px; color: var(--ink-3); }
|
||||||
|
.ext-card .meta .v { font-family: var(--mono); font-size: 10.5px; color: var(--ink-4); margin-left: 6px; }
|
||||||
|
.ext-card .en { font-size: 11px; color: var(--green); display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.ext-card .en .switch { transform: scale(.85); }
|
||||||
|
|
||||||
|
/* —— popup 通用(图2/3/4/5)—— */
|
||||||
|
.pm { max-width: 340px; margin: 0 auto; }
|
||||||
|
.pm-title { display: flex; align-items: center; gap: 10px; padding: 13px 15px; border-bottom: 1px solid var(--line-2); }
|
||||||
|
.pm-title .mk { width: 28px; height: 28px; flex: 0 0 28px; background: var(--accent); color: #fff; display: grid; place-items: center; font-family: var(--serif); font-weight: 900; font-size: 14px; border-radius: 6px; transform: rotate(-4deg); }
|
||||||
|
.pm-title .mt b { display: block; font-family: var(--serif); font-weight: 700; font-size: 13px; color: var(--ink); line-height: 1.2; }
|
||||||
|
.pm-title .mt span { font-family: var(--mono); font-size: 9.5px; color: var(--ink-3); }
|
||||||
|
.pm-pad { padding: 13px 15px; }
|
||||||
|
.pm-status { background: var(--bg-2); border: 1px solid var(--line); border-radius: 6px; padding: 7px 11px; font-size: 11.5px; color: var(--ink-2); display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.pm-status b { color: var(--accent-d); font-family: var(--mono); }
|
||||||
|
.pm-status .ready { color: var(--ink-3); }
|
||||||
|
.pm-bar { height: 6px; background: var(--bg-2); border-radius: 4px; border: 1px solid var(--line); overflow: hidden; margin-bottom: 14px; }
|
||||||
|
.pm-bar i { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--amber)); border-radius: 4px; }
|
||||||
|
.pm-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.pm-row label { font-size: 11px; color: var(--ink-3); width: 58px; flex: 0 0 58px; }
|
||||||
|
.pm-sel { flex: 1; background: var(--paper); border: 1px solid var(--line); border-radius: 5px; padding: 6px 9px; font-size: 11.5px; color: var(--ink-2); display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.pm-sel::after { content: "▾"; color: var(--ink-4); font-size: 9px; }
|
||||||
|
.pm-checkrow { display: flex; gap: 16px; margin: 6px 0 4px; font-size: 11.5px; color: var(--ink-2); }
|
||||||
|
.pm-checkrow span { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ck { width: 13px; height: 13px; border-radius: 3px; border: 1.5px solid var(--accent); background: var(--accent); position: relative; flex: 0 0 13px; }
|
||||||
|
.ck::after { content: "✓"; position: absolute; inset: 0; color: #fff; font-size: 9px; display: grid; place-items: center; line-height: 1; }
|
||||||
|
.ck.empty { background: var(--paper); }
|
||||||
|
.ck.empty::after { content: none; }
|
||||||
|
.pm-bigbtn { text-align: center; background: var(--accent); color: #fff; font-weight: 500; font-size: 13px; padding: 10px; border-radius: 6px; margin: 12px 0; position: relative; box-shadow: 0 4px 10px -3px rgba(199,93,58,.5); }
|
||||||
|
.pm-btnrow { display: flex; gap: 8px; margin: 10px 0; }
|
||||||
|
.pm-mini { flex: 1; text-align: center; font-size: 11.5px; padding: 7px; border-radius: 5px; border: 1px solid var(--line); color: var(--ink-2); background: var(--paper); }
|
||||||
|
.pm-mini.pause { color: var(--ink); border-color: var(--ink-3); }
|
||||||
|
.pm-mini.disabled { color: var(--ink-4); background: var(--bg-2); }
|
||||||
|
.pm-push { font-family: var(--mono); font-size: 10px; background: var(--bg-2); border: 1px solid var(--line); border-radius: 5px; padding: 6px 9px; color: var(--ink-2); display: flex; justify-content: space-between; }
|
||||||
|
.pm-push .ok { color: var(--green); } .pm-push .w { color: var(--amber); }
|
||||||
|
.cfg-row { margin-bottom: 9px; }
|
||||||
|
.cfg-row label { display: block; font-size: 10.5px; color: var(--ink-3); margin-bottom: 3px; }
|
||||||
|
.cfg-in { width: 100%; background: var(--paper); border: 1px solid var(--line); border-radius: 5px; padding: 6px 9px; font-family: var(--mono); font-size: 10.5px; color: var(--ink-2); }
|
||||||
|
.cfg-btns { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.cfg-btns .b { flex: 1; text-align: center; font-size: 11.5px; padding: 7px; border-radius: 5px; border: 1px solid var(--line); background: var(--paper); color: var(--ink-2); }
|
||||||
|
.cfg-btns .b.save { background: var(--ink); color: var(--bg); border-color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
/* 高亮指引标注 */
|
||||||
|
.point-at { position: relative; }
|
||||||
|
.point-at::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute; top: -9px; right: -10px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
font-family: var(--sans); font-size: 11px; font-weight: 500;
|
||||||
|
padding: 4px 10px; border-radius: 999px;
|
||||||
|
box-shadow: 0 4px 10px -3px rgba(199,93,58,.55);
|
||||||
|
white-space: nowrap; z-index: 4;
|
||||||
|
}
|
||||||
|
.pulse { position: relative; }
|
||||||
|
.pulse::before {
|
||||||
|
content: ""; position: absolute; inset: -5px; border-radius: 9px;
|
||||||
|
border: 2px solid var(--accent); opacity: 0;
|
||||||
|
animation: pulse 1.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0% { opacity: .7; transform: scale(.96); } 100% { opacity: 0; transform: scale(1.12); } }
|
||||||
|
|
||||||
|
/* —— 图6 三段式 —— */
|
||||||
|
.flow3 { display: grid; grid-template-columns: 1fr auto 1fr; gap: 0; align-items: center; }
|
||||||
|
.flow3 .arr { color: var(--accent); font-size: 22px; padding: 0 6px; font-family: var(--mono); }
|
||||||
|
.mini-card { background: var(--paper); border: 1px solid var(--line); border-radius: 10px; padding: 14px; text-align: center; box-shadow: 0 8px 22px -12px rgba(80,55,25,.18); }
|
||||||
|
.mini-card .ico { font-size: 30px; margin-bottom: 6px; }
|
||||||
|
.mini-card b { display: block; font-size: 12.5px; color: var(--ink); margin-bottom: 3px; }
|
||||||
|
.mini-card span { font-size: 11px; color: var(--ink-3); font-family: var(--mono); }
|
||||||
|
.file-icon {
|
||||||
|
width: 46px; height: 56px; margin: 0 auto 8px; position: relative;
|
||||||
|
background: var(--green); border-radius: 5px;
|
||||||
|
}
|
||||||
|
.file-icon::before { content: ""; position: absolute; top: 0; right: 0; border-style: solid; border-width: 0 14px 14px 0; border-color: transparent #fff transparent transparent; }
|
||||||
|
.file-icon::after { content: "CSV"; position: absolute; inset: 0; display: grid; place-items: center; color: #fff; font-family: var(--mono); font-size: 11px; font-weight: 700; padding-top: 8px; }
|
||||||
|
.db-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; margin: 8px auto 0; max-width: 70px; }
|
||||||
|
.db-grid i { height: 8px; background: var(--teal); border-radius: 2px; opacity: .8; }
|
||||||
|
.db-grid i:nth-child(1), .db-grid i:nth-child(5), .db-grid i:nth-child(9) { opacity: .4; }
|
||||||
|
|
||||||
|
/* —— 小提示 —— */
|
||||||
|
.tips { background: var(--paper); border: 1px solid var(--line); border-radius: 14px; padding: 24px 28px; margin-top: 8px; box-shadow: 0 6px 20px -10px rgba(80,55,25,.12); }
|
||||||
|
.tips h2 { font-family: var(--serif); font-weight: 700; font-size: 20px; margin: 0 0 16px; color: var(--ink); display: flex; align-items: center; gap: 9px; }
|
||||||
|
.tips h2 .ic { color: var(--amber); }
|
||||||
|
.tips ul { list-style: none; padding: 0; margin: 0; }
|
||||||
|
.tips li { padding: 11px 0 11px 32px; border-bottom: 1px dashed var(--line-2); position: relative; color: var(--ink-2); font-size: 15px; }
|
||||||
|
.tips li:last-child { border-bottom: none; }
|
||||||
|
.tips li::before { content: "💡"; position: absolute; left: 0; top: 11px; font-size: 15px; }
|
||||||
|
.tips li b { color: var(--ink); font-weight: 500; }
|
||||||
|
|
||||||
|
footer.foot { text-align: center; padding: 50px 0 60px; color: var(--ink-4); font-size: 12.5px; }
|
||||||
|
footer.foot .ln { font-family: var(--serif); font-weight: 700; color: var(--ink-3); }
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.step-goal { padding-left: 0; }
|
||||||
|
.flow3 { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.flow3 .arr { transform: rotate(90deg); padding: 0; }
|
||||||
|
.pm { max-width: 100%; }
|
||||||
|
.point-at::after { font-size: 10px; padding: 3px 8px; right: -6px; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pulse::before { animation: none; }
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="bar">
|
||||||
|
<div class="bar-in">
|
||||||
|
<div class="logo">询</div>
|
||||||
|
<b>阿里询盘数据抓取</b>
|
||||||
|
<span class="ver">v1.0.0 · 使用指南</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<!-- ========== Hero ========== -->
|
||||||
|
<section class="hero">
|
||||||
|
<span class="kicker">图 文 操 作 指 南</span>
|
||||||
|
<h1>五步学会用<br/><em>询盘抓取</em>插件</h1>
|
||||||
|
<p class="sub">把阿里后台的询盘、客户资料和聊天记录,自动整理成表格、同步到你的数据库。不用懂代码,照着下图做就行。</p>
|
||||||
|
<div class="steps-overview">
|
||||||
|
<a href="#s1" class="chip"><b>01</b> 安装插件</a>
|
||||||
|
<a href="#s2" class="chip"><b>02</b> 配置地址</a>
|
||||||
|
<a href="#s3" class="chip"><b>03</b> 开始抓取</a>
|
||||||
|
<a href="#s4" class="chip"><b>04</b> 等待完成</a>
|
||||||
|
<a href="#s5" class="chip"><b>05</b> 拿到数据</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 开始之前 ========== -->
|
||||||
|
<section>
|
||||||
|
<div class="lead-card">
|
||||||
|
<h2><span class="ic">✓</span> 开始之前,请准备好</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>用 Chrome 或 Edge 浏览器</b>(360 极速、Brave 等也可以)。</li>
|
||||||
|
<li><b>登录你的阿里主管理员账号</b>——就是能看到所有业务员询盘的那个账号。</li>
|
||||||
|
<li>插件文件夹已经放在电脑上(默认 <code style="font-family:var(--mono);background:rgba(0,0,0,.05);padding:1px 6px;border-radius:3px;font-size:13px">d:\alibb</code>)。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 第1步 安装 ========== -->
|
||||||
|
<section class="step" id="s1">
|
||||||
|
<div class="step-head">
|
||||||
|
<div class="step-num">1</div>
|
||||||
|
<div>
|
||||||
|
<h2>安装插件</h2>
|
||||||
|
<span class="when">一次性操作 · 约 1 分钟</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="step-goal">把插件装进浏览器,<strong>只需做一次</strong>。</p>
|
||||||
|
|
||||||
|
<div class="fig">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="dots"><i></i><i></i><i></i></div>
|
||||||
|
<div class="addr"><span class="lock">🔒</span> chrome://extensions</div>
|
||||||
|
</div>
|
||||||
|
<div class="ext-toolbar">
|
||||||
|
<div class="ext-btn hi point-at" data-tip="← 点这个"><span class="folder">📂</span> 加载已解压的扩展程序</div>
|
||||||
|
<div class="dev-toggle">开发者模式 <span class="switch"></span></div>
|
||||||
|
</div>
|
||||||
|
<div class="ext-card">
|
||||||
|
<div class="ic">询</div>
|
||||||
|
<div class="meta">
|
||||||
|
<b>阿里询盘数据抓取 <span class="v">1.0.0</span></b>
|
||||||
|
<span>抓取询盘列表、客户资料、行为与聊天记录</span>
|
||||||
|
</div>
|
||||||
|
<div class="en">已启用 <span class="switch"></span></div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:14px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="points">
|
||||||
|
<li>在浏览器地址栏输入 <b>chrome://extensions</b> 回车,打开扩展管理页。</li>
|
||||||
|
<li>打开页面<b>右上角</b>的「<b>开发者模式</b>」开关。</li>
|
||||||
|
<li>点左上角的「<b>加载已解压的扩展程序</b>」,选择插件文件夹 <code style="font-family:var(--mono);background:rgba(0,0,0,.05);padding:1px 6px;border-radius:3px;font-size:13px">d:\alibb</code>。</li>
|
||||||
|
<li>点浏览器右上角的<b>拼图图标 🧩</b>,找到「阿里询盘抓取」,点<b>图钉📌</b>把它固定到工具栏,方便随时打开。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="note">
|
||||||
|
<span class="g">!</span>
|
||||||
|
<span>装好后<b>不要移动或删除</b> <code>d:\alibb</code> 文件夹,否则插件会失效。日后更新了代码,回这个页面点「↻ 重新加载」即可。</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 第2步 配置 ========== -->
|
||||||
|
<section class="step" id="s2">
|
||||||
|
<div class="step-head">
|
||||||
|
<div class="step-num">2</div>
|
||||||
|
<div>
|
||||||
|
<h2>配置推送地址</h2>
|
||||||
|
<span class="when">一次性操作 · 只需做一次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="step-goal">告诉插件数据要送到哪个后台。<strong>地址和口令默认已填好,你只需授权并保存。</strong></p>
|
||||||
|
|
||||||
|
<div class="fig">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="dots"><i></i><i></i><i></i></div>
|
||||||
|
<div class="addr">插件弹窗 · 后端推送设置</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div class="pm">
|
||||||
|
<div class="cfg-row">
|
||||||
|
<label>服务器 URL</label>
|
||||||
|
<div class="cfg-in">http://219.157.255.213:23010</div>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-row">
|
||||||
|
<label>推送 Token</label>
|
||||||
|
<div class="cfg-in">688cbb4b6e880b34…01a39ccbb3c31c4d</div>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-btns">
|
||||||
|
<div class="b">🔗 授权域名</div>
|
||||||
|
<div class="b save point-at" data-tip="← 最后点这个">💾 保存设置</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="points">
|
||||||
|
<li>点工具栏的<b>📌插件图标</b>,打开控制面板,往下滚动到「<b>后端推送设置</b>」。</li>
|
||||||
|
<li>地址和 Token <b>默认已填好,不用改</b>(除非你的后台换了地址)。</li>
|
||||||
|
<li>先点「<b>授权域名</b>」——浏览器会弹框问是否允许,点「<b>允许</b>」。</li>
|
||||||
|
<li>再点「<b>保存设置</b>」,看到绿色「已保存」提示就完成了。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="note ok">
|
||||||
|
<span class="g">✓</span>
|
||||||
|
<span><b>只想导出 Excel 表格、不需要联网同步?</b>那这一步可以<b>直接跳过</b>,去第 3 步开始抓取就行。</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 第3步 开始抓取 ========== -->
|
||||||
|
<section class="step" id="s3">
|
||||||
|
<div class="step-head">
|
||||||
|
<div class="step-num">3</div>
|
||||||
|
<div>
|
||||||
|
<h2>开始抓取</h2>
|
||||||
|
<span class="when">每次使用都从这里开始</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="step-goal">选好范围,<strong>点一下「开始」就行,剩下全自动</strong>。</p>
|
||||||
|
|
||||||
|
<div class="fig">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="dots"><i></i><i></i><i></i></div>
|
||||||
|
<div class="addr"><span class="lock">🔒</span> message.alibaba.com <span class="puzzle">询</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div class="pm">
|
||||||
|
<div class="pm-title">
|
||||||
|
<div class="mk">询</div>
|
||||||
|
<div class="mt"><b>阿里询盘数据抓取</b><span>message.alibaba.com</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="pm-pad">
|
||||||
|
<div class="pm-status"><span class="ready">就绪</span><span style="color:var(--ink-4)">等待开始</span></div>
|
||||||
|
<div class="pm-row"><label>抓取模式</label><div class="pm-sel">列表+详情</div></div>
|
||||||
|
<div class="pm-row"><label>抓取条数</label><div class="pm-sel">前 10 条</div></div>
|
||||||
|
<div class="pm-checkrow">
|
||||||
|
<span><span class="ck"></span>customerId 去重</span>
|
||||||
|
<span><span class="ck empty"></span>包含聊天记录</span>
|
||||||
|
</div>
|
||||||
|
<div class="pm-bigbtn pulse point-at" data-tip="← 点这里开始">▶ 开始抓取</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="points">
|
||||||
|
<li>确认你已经<b>登录阿里</b>,并停在 <b>message.alibaba.com</b> 询盘页面(不在的话,点开始后会自动跳过去)。</li>
|
||||||
|
<li><b>抓取模式</b>选「<b>列表+详情</b>」(数据最全);<b>抓取条数</b>第一次选「<b>前 10 条</b>」先试跑一下。</li>
|
||||||
|
<li>点橙色的「<b>▶ 开始抓取</b>」按钮,开始!</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 第4步 等待 ========== -->
|
||||||
|
<section class="step" id="s4">
|
||||||
|
<div class="step-head">
|
||||||
|
<div class="step-num">4</div>
|
||||||
|
<div>
|
||||||
|
<h2>等待完成</h2>
|
||||||
|
<span class="when">自动进行 · 你可以走开</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="step-goal">看进度条走到 <strong>100%</strong>。期间要离开就按暂停,<strong>下次能接着抓</strong>。</p>
|
||||||
|
|
||||||
|
<div class="fig">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="dots"><i></i><i></i><i></i></div>
|
||||||
|
<div class="addr"><span class="lock">🔒</span> message.alibaba.com <span class="puzzle">询</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div class="pm">
|
||||||
|
<div class="pm-pad">
|
||||||
|
<div class="pm-status">当前 <b>30 / 50</b> (60%)<span style="color:var(--green)">● 运行中</span></div>
|
||||||
|
<div class="pm-bar"><i style="width:60%"></i></div>
|
||||||
|
<div class="pm-btnrow">
|
||||||
|
<div class="pm-mini pause">⏸ 暂停</div>
|
||||||
|
<div class="pm-mini disabled">导出 CSV</div>
|
||||||
|
</div>
|
||||||
|
<div class="pm-push"><span>推送: <span class="ok">已推 20</span> / <span class="w">待推 10</span> / 失败 0</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="points">
|
||||||
|
<li>顶部会显示「<b>当前 30 / 50 (60%)</b>」和橙色进度条,一眼看到进度。</li>
|
||||||
|
<li>需要离开或休息,就点「<b>⏸ 暂停</b>」。下次打开插件点「恢复」,<b>从断点接着抓,不用重来</b>。</li>
|
||||||
|
<li>抓取期间<b>不要关闭阿里页面和浏览器</b>,最小化没关系。</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 第5步 拿到数据 ========== -->
|
||||||
|
<section class="step" id="s5">
|
||||||
|
<div class="step-head">
|
||||||
|
<div class="step-num">5</div>
|
||||||
|
<div>
|
||||||
|
<h2>拿到数据</h2>
|
||||||
|
<span class="when">抓完后 · 两种方式都用上了</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="step-goal">进度到 100% 后,<strong>导出 Excel 表格</strong>;同时数据已自动送进后台数据库。</p>
|
||||||
|
|
||||||
|
<div class="fig">
|
||||||
|
<div class="screen">
|
||||||
|
<div class="screen-bar">
|
||||||
|
<div class="dots"><i></i><i></i><i></i></div>
|
||||||
|
<div class="addr">完成 · 你的数据在这里</div>
|
||||||
|
</div>
|
||||||
|
<div class="screen-body">
|
||||||
|
<div class="flow3">
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="file-icon"></div>
|
||||||
|
<b>导出 CSV 表格</b>
|
||||||
|
<span>Excel 直接打开</span>
|
||||||
|
</div>
|
||||||
|
<div class="arr">→</div>
|
||||||
|
<div class="mini-card">
|
||||||
|
<div class="ico">🗄️</div>
|
||||||
|
<b>后台数据库</b>
|
||||||
|
<span>自动同步完成</span>
|
||||||
|
<div class="db-grid"><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:14px">
|
||||||
|
<div class="pm" style="max-width:100%">
|
||||||
|
<div class="pm-status">完成 <b style="color:var(--green)">✓ 50 / 50</b><span style="color:var(--green)">全部完成</span></div>
|
||||||
|
<div class="pm-bigbtn" style="background:var(--green);margin:10px 0">📥 导出 CSV</div>
|
||||||
|
<div class="pm-push"><span>推送: <span class="ok">已推 50</span> / 待推 0 / 失败 0</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="points">
|
||||||
|
<li>点绿色的「<b>📥 导出 CSV</b>」,下载一个 <code style="font-family:var(--mono);background:rgba(0,0,0,.05);padding:1px 6px;border-radius:3px;font-size:13px">alibaba_inquiry_日期.csv</code> 文件,<b>双击用 Excel 打开</b>就能看,不会乱码。</li>
|
||||||
|
<li>同时,抓到的数据已经<b>自动传到后台数据库</b>(看「推送」状态条,全部「已推」就对了),不用你操心。</li>
|
||||||
|
</ol>
|
||||||
|
<div class="note ok">
|
||||||
|
<span class="g">✓</span>
|
||||||
|
<span>数据量很大时,后台同步可能比抓取慢一些(每小时约 180 条),<b>这是正常的</b>——数据已安全存在本地,会慢慢自动补传完,关电脑也不丢。</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 小提示 ========== -->
|
||||||
|
<section>
|
||||||
|
<div class="tips">
|
||||||
|
<h2><span class="ic">★</span> 用好它的小窍门</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>第一次先抓 10 条试试</b>,确认没问题再抓全部,避免白忙一场。</li>
|
||||||
|
<li><b>大批量分几次抓</b>,每抓几百条暂停休息几分钟,对账号更安全。</li>
|
||||||
|
<li>数据会<b>自动传后台</b>,断网或关电脑都不会丢,下次打开会接着传。</li>
|
||||||
|
<li>有些客户的电话、手机可能显示为空——<b>这是阿里页面本身就没填</b>,不是插件漏抓。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<div class="ln">阿里询盘数据抓取</div>
|
||||||
|
<div style="margin-top:4px">使用指南 v1.0.0 · 内部使用文档</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user