1688-cli 0.1.24 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +125 -39
- package/CHANGELOG.md +50 -0
- package/README.md +141 -64
- package/dist/cli.js +13 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/cart-add.js +224 -133
- package/dist/commands/cart-add.js.map +1 -1
- package/dist/commands/cart-list.js +22 -3
- package/dist/commands/cart-list.js.map +1 -1
- package/dist/commands/cart-remove.js +5 -0
- package/dist/commands/cart-remove.js.map +1 -1
- package/dist/commands/image-search.js +44 -11
- package/dist/commands/image-search.js.map +1 -1
- package/dist/commands/offer.js +376 -26
- package/dist/commands/offer.js.map +1 -1
- package/dist/commands/order-list.js +29 -0
- package/dist/commands/order-list.js.map +1 -1
- package/dist/commands/order-logistics.js +10 -0
- package/dist/commands/order-logistics.js.map +1 -1
- package/dist/commands/search.js +212 -51
- package/dist/commands/search.js.map +1 -1
- package/dist/commands/seller-messages.js +515 -16
- package/dist/commands/seller-messages.js.map +1 -1
- package/dist/commands/similar.js +123 -0
- package/dist/commands/similar.js.map +1 -0
- package/dist/session/dispatch.js +1 -0
- package/dist/session/dispatch.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,219 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
1
2
|
import { dispatch } from '../session/dispatch.js';
|
|
2
|
-
import { emit, info } from '../io/output.js';
|
|
3
|
+
import { emit, info, isJson } from '../io/output.js';
|
|
3
4
|
import { CliError } from '../io/errors.js';
|
|
4
5
|
import { readState } from '../session/state.js';
|
|
5
6
|
const IM_BASE = 'https://air.1688.com/app/ocms-fusion-components-1688/def_cbu_web_im/index.html';
|
|
7
|
+
function formatBeijingTime(ms) {
|
|
8
|
+
// 1688 is a Chinese platform — format in Asia/Shanghai (UTC+8).
|
|
9
|
+
const d = new Date(ms + 8 * 3600_000);
|
|
10
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
11
|
+
return (`${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
|
|
12
|
+
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`);
|
|
13
|
+
}
|
|
14
|
+
function parseOneWsMessage(m, myLoginId) {
|
|
15
|
+
const msg = m.message;
|
|
16
|
+
if (!msg)
|
|
17
|
+
return null;
|
|
18
|
+
if (m.recallFeature?.code)
|
|
19
|
+
return null; // recalled — skip
|
|
20
|
+
const ext = msg.extension ?? {};
|
|
21
|
+
const content = msg.content ?? {};
|
|
22
|
+
const senderNick = ext.sender_nick ?? '';
|
|
23
|
+
const isMine = senderNick === `cnalichn${myLoginId}`;
|
|
24
|
+
let sender = ext.senderNickName ?? senderNick.replace(/^cnalichn/, '');
|
|
25
|
+
const colon = sender.indexOf(':');
|
|
26
|
+
if (colon > -1)
|
|
27
|
+
sender = sender.slice(0, colon);
|
|
28
|
+
const createAt = Number(msg.createAt) || 0;
|
|
29
|
+
const time = createAt ? formatBeijingTime(createAt) : null;
|
|
30
|
+
const read = Number(m.readStatus ?? 0) === 2;
|
|
31
|
+
let kind = 'text';
|
|
32
|
+
let contentText = '';
|
|
33
|
+
let card;
|
|
34
|
+
const ct = content.contentType;
|
|
35
|
+
if (ct === 1) {
|
|
36
|
+
contentText = String(content.text?.content ?? '');
|
|
37
|
+
const offerMatch = contentText.match(/https?:\/\/detail\.1688\.com\/offer\/(\d+)\.html/i);
|
|
38
|
+
if (offerMatch) {
|
|
39
|
+
kind = 'offerCard';
|
|
40
|
+
card = { title: null, price: null, image: null, url: contentText };
|
|
41
|
+
}
|
|
42
|
+
else if (/order[Ii]d=\d+/.test(contentText) &&
|
|
43
|
+
/1688\.com|alibaba\.com/i.test(contentText)) {
|
|
44
|
+
kind = 'orderCard';
|
|
45
|
+
card = { title: null, price: null, image: null, url: contentText };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (ct === 101) {
|
|
49
|
+
// Custom template — subtype determined by bizuniqueID prefix.
|
|
50
|
+
contentText = String(content.custom?.summary ?? content.custom?.title ?? '');
|
|
51
|
+
const biz = ext.bizuniqueID ?? '';
|
|
52
|
+
if (biz.startsWith('cbu_offer_reply'))
|
|
53
|
+
kind = 'autoReply';
|
|
54
|
+
else if (biz.startsWith('cbu_im_msg_assessment'))
|
|
55
|
+
kind = 'assessment';
|
|
56
|
+
else
|
|
57
|
+
kind = 'other';
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
contentText = `[contentType=${ct}]`;
|
|
61
|
+
kind = 'other';
|
|
62
|
+
}
|
|
63
|
+
const messageId = msg.messageId !== undefined ? String(msg.messageId) : undefined;
|
|
64
|
+
return {
|
|
65
|
+
sender: sender.trim(),
|
|
66
|
+
time,
|
|
67
|
+
isMine,
|
|
68
|
+
content: contentText,
|
|
69
|
+
read,
|
|
70
|
+
kind,
|
|
71
|
+
...(card ? { card } : {}),
|
|
72
|
+
...(messageId ? { messageId } : {}),
|
|
73
|
+
_sortMs: createAt,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function parseFromWsFrames(frames, myLoginId) {
|
|
77
|
+
const midToMethod = new Map();
|
|
78
|
+
const ourCids = new Set();
|
|
79
|
+
for (const f of frames) {
|
|
80
|
+
if (f.type === 'sent' && f.mid && f.method) {
|
|
81
|
+
midToMethod.set(f.mid, f.method);
|
|
82
|
+
}
|
|
83
|
+
if (f.type === 'sent' &&
|
|
84
|
+
f.method === '/r/MessageManager/listUserMessages') {
|
|
85
|
+
try {
|
|
86
|
+
const body = JSON.parse(f.payload).body;
|
|
87
|
+
if (Array.isArray(body) && typeof body[0] === 'string') {
|
|
88
|
+
ourCids.add(body[0]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const f of frames) {
|
|
99
|
+
if (f.type !== 'recv')
|
|
100
|
+
continue;
|
|
101
|
+
const reqMethod = f.mid ? midToMethod.get(f.mid) : undefined;
|
|
102
|
+
if (reqMethod !== '/r/MessageManager/listUserMessages')
|
|
103
|
+
continue;
|
|
104
|
+
let body;
|
|
105
|
+
try {
|
|
106
|
+
body = JSON.parse(f.payload).body;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const models = body?.userMessageModels;
|
|
112
|
+
if (!Array.isArray(models))
|
|
113
|
+
continue;
|
|
114
|
+
for (const m of models) {
|
|
115
|
+
if (ourCids.size > 0 &&
|
|
116
|
+
m.message?.cid &&
|
|
117
|
+
!ourCids.has(m.message.cid)) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const msgId = String(m.message?.messageId ?? '');
|
|
121
|
+
if (msgId && seen.has(msgId))
|
|
122
|
+
continue;
|
|
123
|
+
if (msgId)
|
|
124
|
+
seen.add(msgId);
|
|
125
|
+
const parsed = parseOneWsMessage(m, myLoginId);
|
|
126
|
+
if (parsed)
|
|
127
|
+
out.push(parsed);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
out.sort((a, b) => a._sortMs - b._sortMs);
|
|
131
|
+
return out.map(({ _sortMs: _ms, ...rest }) => rest);
|
|
132
|
+
}
|
|
133
|
+
async function waitForWsMessages(frames, timeoutMs) {
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
const midToMethod = new Map();
|
|
137
|
+
for (const f of frames) {
|
|
138
|
+
if (f.type === 'sent' && f.mid && f.method) {
|
|
139
|
+
midToMethod.set(f.mid, f.method);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const hit = frames.some((f) => f.type === 'recv' &&
|
|
143
|
+
f.mid &&
|
|
144
|
+
midToMethod.get(f.mid) === '/r/MessageManager/listUserMessages');
|
|
145
|
+
if (hit)
|
|
146
|
+
return true;
|
|
147
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
148
|
+
}
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
6
151
|
export async function execute(ctx, args) {
|
|
7
152
|
const page = await ctx.newPage();
|
|
153
|
+
// ALWAYS collect WebSocket frames (LWP protocol). Used as the primary data
|
|
154
|
+
// source for messages — DOM extraction is only the fallback.
|
|
155
|
+
const wsFrames = [];
|
|
156
|
+
page.on('websocket', (ws) => {
|
|
157
|
+
const record = (type, payloadRaw) => {
|
|
158
|
+
const payload = typeof payloadRaw === 'string' ? payloadRaw : payloadRaw.toString();
|
|
159
|
+
let method = '';
|
|
160
|
+
let mid = null;
|
|
161
|
+
try {
|
|
162
|
+
const j = JSON.parse(payload);
|
|
163
|
+
method = j?.lwp ?? '';
|
|
164
|
+
mid = j?.headers?.mid ?? null;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
/* not JSON — skip */
|
|
168
|
+
}
|
|
169
|
+
wsFrames.push({
|
|
170
|
+
type,
|
|
171
|
+
method,
|
|
172
|
+
mid,
|
|
173
|
+
payloadLen: payload.length,
|
|
174
|
+
payload,
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
ws.on('framesent', (frame) => record('sent', frame.payload));
|
|
178
|
+
ws.on('framereceived', (frame) => record('recv', frame.payload));
|
|
179
|
+
});
|
|
180
|
+
if (process.env.BB1688_PROBE === '1') {
|
|
181
|
+
// Probe: dump frames to file for offline analysis.
|
|
182
|
+
page.on('close', () => {
|
|
183
|
+
const midToMethod = new Map();
|
|
184
|
+
for (const f of wsFrames) {
|
|
185
|
+
if (f.type === 'sent' && f.mid && f.method) {
|
|
186
|
+
midToMethod.set(f.mid, f.method);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const enriched = wsFrames.map((f) => ({
|
|
190
|
+
...f,
|
|
191
|
+
reqMethod: f.type === 'recv' && f.mid ? midToMethod.get(f.mid) ?? null : null,
|
|
192
|
+
}));
|
|
193
|
+
const interesting = enriched.filter((f) => {
|
|
194
|
+
if (f.type === 'sent') {
|
|
195
|
+
return /Message|Conversation|SingleChat/i.test(f.method);
|
|
196
|
+
}
|
|
197
|
+
if (f.reqMethod &&
|
|
198
|
+
/Message|Conversation|SingleChat/i.test(f.reqMethod)) {
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
return /msgId|messageId|cardType|offerId|orderId|conversationCode|userConvs|listMessage/i.test(f.payload);
|
|
202
|
+
});
|
|
203
|
+
// Write full frames to file (truncation-free)
|
|
204
|
+
try {
|
|
205
|
+
writeFileSync('/tmp/1688-ws-frames.json', JSON.stringify(enriched, null, 2));
|
|
206
|
+
writeFileSync('/tmp/1688-ws-interesting.json', JSON.stringify(interesting, null, 2));
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
process.stderr.write(`[ws-frames] write failed: ${String(e)}\n`);
|
|
210
|
+
}
|
|
211
|
+
process.stderr.write(`[ws-frames] total=${wsFrames.length} interesting=${interesting.length}\n` +
|
|
212
|
+
`methods seen: ${[...new Set(wsFrames.map((f) => f.method).filter(Boolean))].join(', ')}\n` +
|
|
213
|
+
`full dump → /tmp/1688-ws-frames.json (${enriched.length} frames)\n` +
|
|
214
|
+
`interesting dump → /tmp/1688-ws-interesting.json (${interesting.length} frames)\n`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
8
217
|
try {
|
|
9
218
|
let url;
|
|
10
219
|
if (args.sellerLoginId && (args.orderId || args.offerId)) {
|
|
@@ -74,14 +283,150 @@ export async function execute(ctx, args) {
|
|
|
74
283
|
if (!activated) {
|
|
75
284
|
throw new CliError(26, 'CONVERSATION_NOT_SELECTED', `Conversation did not activate for ${matched}.`);
|
|
76
285
|
}
|
|
77
|
-
|
|
78
|
-
//
|
|
79
|
-
|
|
286
|
+
// Wait for IM page to fire `/r/MessageManager/listUserMessages` and the
|
|
287
|
+
// server's recv frame to arrive. Falls through after timeout so we can
|
|
288
|
+
// still fall back to DOM extraction.
|
|
289
|
+
const gotWsMessages = await waitForWsMessages(wsFrames, 8000);
|
|
290
|
+
if (gotWsMessages) {
|
|
291
|
+
// Give a tiny grace window in case a second page-load batch is in flight.
|
|
292
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// No WS response captured — wait a bit more for DOM render.
|
|
296
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
297
|
+
}
|
|
298
|
+
// Try the WebSocket path first — server-truth data (messageId, createAt,
|
|
299
|
+
// readStatus, URLs in plain text). If empty, fall back to DOM scraping.
|
|
300
|
+
const wsMessages = parseFromWsFrames(wsFrames, args.myLoginId);
|
|
301
|
+
const imFrameDoc = page
|
|
80
302
|
.frames()
|
|
81
303
|
.find((f) => /def_cbu_web_im_core/.test(f.url()));
|
|
304
|
+
if (wsMessages.length > 0) {
|
|
305
|
+
// WS data is clean but lacks visual card metadata (title/price/image).
|
|
306
|
+
// The IM client hydrates URL messages into cards in DOM — pull that
|
|
307
|
+
// enrichment so the human view shows product names instead of just IDs.
|
|
308
|
+
if (imFrameDoc && wsMessages.some((m) => m.kind === 'offerCard')) {
|
|
309
|
+
try {
|
|
310
|
+
const domCards = await imFrameDoc.evaluate(() => {
|
|
311
|
+
const out = [];
|
|
312
|
+
for (const item of Array.from(document.querySelectorAll('.message-item'))) {
|
|
313
|
+
const card = item.querySelector('.text-od-wrap, .od-wrap');
|
|
314
|
+
if (!card)
|
|
315
|
+
continue;
|
|
316
|
+
const timeEl = item.querySelector('.time');
|
|
317
|
+
const time = timeEl?.textContent?.trim() ?? null;
|
|
318
|
+
const titleEl = card.querySelector('.odName, .od-name, [class*="odName"]');
|
|
319
|
+
const priceEl = card.querySelector('.odPrice, .od-price, [class*="odPrice"]');
|
|
320
|
+
const imgEl = card.querySelector('img');
|
|
321
|
+
out.push({
|
|
322
|
+
time,
|
|
323
|
+
title: titleEl?.textContent?.trim().slice(0, 200) ?? null,
|
|
324
|
+
price: priceEl?.textContent
|
|
325
|
+
?.replace(/\s+/g, '')
|
|
326
|
+
.replace('¥', '¥')
|
|
327
|
+
.trim() ?? null,
|
|
328
|
+
image: imgEl?.getAttribute('src') ?? null,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return out;
|
|
332
|
+
});
|
|
333
|
+
// Pair by exact time match (YYYY-MM-DD HH:MM:SS). If duplicates
|
|
334
|
+
// (e.g. user re-sent same URL within the same second), consume
|
|
335
|
+
// each DOM card only once via shift().
|
|
336
|
+
const byTime = new Map();
|
|
337
|
+
for (const dc of domCards) {
|
|
338
|
+
if (!dc.time)
|
|
339
|
+
continue;
|
|
340
|
+
const list = byTime.get(dc.time) ?? [];
|
|
341
|
+
list.push(dc);
|
|
342
|
+
byTime.set(dc.time, list);
|
|
343
|
+
}
|
|
344
|
+
for (const msg of wsMessages) {
|
|
345
|
+
if (msg.kind !== 'offerCard' || !msg.card || !msg.time)
|
|
346
|
+
continue;
|
|
347
|
+
const bucket = byTime.get(msg.time);
|
|
348
|
+
const dc = bucket?.shift();
|
|
349
|
+
if (!dc)
|
|
350
|
+
continue;
|
|
351
|
+
msg.card.title = dc.title;
|
|
352
|
+
msg.card.price = dc.price;
|
|
353
|
+
msg.card.image = dc.image;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
/* enrichment is best-effort */
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
conversation: matched ?? '?',
|
|
362
|
+
total: wsMessages.length,
|
|
363
|
+
messages: wsMessages.slice(-Math.max(1, args.limit)),
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
// Fallback: scrape from .message-item DOM nodes.
|
|
367
|
+
const frame = imFrameDoc;
|
|
82
368
|
if (!frame) {
|
|
83
369
|
throw new CliError(22, 'CHAT_NOT_LOADED', 'IM iframe not available.');
|
|
84
370
|
}
|
|
371
|
+
// Probe mode: wait longer for response frames.
|
|
372
|
+
if (process.env.BB1688_PROBE === '1') {
|
|
373
|
+
await new Promise((r) => setTimeout(r, 15000));
|
|
374
|
+
}
|
|
375
|
+
// Probe: dump (1) suspected card item HTML, (2) all mtop calls fired
|
|
376
|
+
// during the IM load, (3) any WebSocket connections + global state.
|
|
377
|
+
if (typeof globalThis !== 'undefined' && typeof process !== 'undefined' && process.env.BB1688_PROBE === '1') {
|
|
378
|
+
// mtop trap was set up via addInitScript above? No — let's do it now
|
|
379
|
+
// post-hoc by reading page state if available.
|
|
380
|
+
const wsAndState = await page
|
|
381
|
+
.evaluate(() => {
|
|
382
|
+
const out = {
|
|
383
|
+
globalKeys: [],
|
|
384
|
+
imGlobals: {},
|
|
385
|
+
wsCount: 0,
|
|
386
|
+
};
|
|
387
|
+
// Top-level window keys that look IM/AMP/wangwang-related
|
|
388
|
+
const filter = /amp|wangwang|ww|chat|im|conversation|message/i;
|
|
389
|
+
for (const k of Object.keys(window)) {
|
|
390
|
+
if (filter.test(k)) {
|
|
391
|
+
out.globalKeys.push(k);
|
|
392
|
+
try {
|
|
393
|
+
const v = window[k];
|
|
394
|
+
if (v && typeof v === 'object') {
|
|
395
|
+
out.imGlobals[k] =
|
|
396
|
+
'obj{' +
|
|
397
|
+
Object.keys(v).slice(0, 8).join(',') +
|
|
398
|
+
'}';
|
|
399
|
+
}
|
|
400
|
+
else if (typeof v === 'string' || typeof v === 'number') {
|
|
401
|
+
out.imGlobals[k] = String(v).slice(0, 60);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
/* ignore */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
})
|
|
411
|
+
.catch(() => ({
|
|
412
|
+
globalKeys: [],
|
|
413
|
+
imGlobals: {},
|
|
414
|
+
wsCount: 0,
|
|
415
|
+
}));
|
|
416
|
+
process.stderr.write(`[im-state-probe]\n` + JSON.stringify(wsAndState, null, 2) + '\n');
|
|
417
|
+
}
|
|
418
|
+
if (typeof globalThis !== 'undefined' && typeof process !== 'undefined' && process.env.BB1688_PROBE === '1') {
|
|
419
|
+
const probe = await frame.evaluate(() => {
|
|
420
|
+
const items = Array.from(document.querySelectorAll('.message-item'));
|
|
421
|
+
return items.slice(-6).map((item) => {
|
|
422
|
+
const text = (item.innerText ?? '').replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
423
|
+
const html = item.outerHTML.slice(0, 800);
|
|
424
|
+
const classes = item.className?.toString?.() ?? '';
|
|
425
|
+
return { text, classes, html };
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
process.stderr.write(`[card-probe]\n` + JSON.stringify(probe, null, 2) + '\n');
|
|
429
|
+
}
|
|
85
430
|
const raw = await frame.evaluate(() => {
|
|
86
431
|
const items = Array.from(document.querySelectorAll('.message-item'));
|
|
87
432
|
return items.map((item) => {
|
|
@@ -102,12 +447,69 @@ export async function execute(ctx, args) {
|
|
|
102
447
|
const senderShort = colonIdx > -1 ? sender.slice(0, colonIdx) : sender;
|
|
103
448
|
const read = /已读\s*$/.test(content);
|
|
104
449
|
const cleanContent = content.replace(/\s*已读\s*$/, '').trim();
|
|
450
|
+
// Detect card-like content. 1688 IM auto-renders detail / order URLs
|
|
451
|
+
// into a card with image + title + price + link.
|
|
452
|
+
let kind = 'text';
|
|
453
|
+
let card;
|
|
454
|
+
// 1688 IM auto-renders offer-detail URLs and order URLs into custom
|
|
455
|
+
// card components. They have NO `<a href>` — detection is by class:
|
|
456
|
+
// - Offer card: div.text-od-wrap > img.headPic + div.infoWrap
|
|
457
|
+
// (.odName + .odPrice inside)
|
|
458
|
+
// - Order card: similar wrapper with order-specific classes
|
|
459
|
+
// - Auto-reply / template: div.im-template-msg
|
|
460
|
+
const offerCardEl = item.querySelector('.text-od-wrap, .od-wrap, .offer-card-wrap');
|
|
461
|
+
const orderCardEl = item.querySelector('.text-od-order-wrap, .order-card-wrap, [class*="OrderCard"]');
|
|
462
|
+
if (offerCardEl) {
|
|
463
|
+
kind = 'offerCard';
|
|
464
|
+
const titleEl = offerCardEl.querySelector('.odName, .od-name, [class*="odName"]');
|
|
465
|
+
const priceEl = offerCardEl.querySelector('.odPrice, .od-price, [class*="odPrice"]');
|
|
466
|
+
const cardImg = offerCardEl.querySelector('img');
|
|
467
|
+
card = {
|
|
468
|
+
title: titleEl?.textContent?.trim().slice(0, 200) ?? null,
|
|
469
|
+
price: priceEl?.textContent?.replace(/\s+/g, '').replace('¥', '¥').trim() ?? null,
|
|
470
|
+
image: cardImg?.getAttribute('src') ?? null,
|
|
471
|
+
// Offer cards don't expose URL in DOM — reconstruct from title
|
|
472
|
+
// hash isn't reliable. Leave null; agent can use cart-list or
|
|
473
|
+
// search to map title back to offerId if needed.
|
|
474
|
+
url: null,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
else if (orderCardEl) {
|
|
478
|
+
kind = 'orderCard';
|
|
479
|
+
const cardImg = orderCardEl.querySelector('img');
|
|
480
|
+
card = {
|
|
481
|
+
title: cleanContent.slice(0, 200),
|
|
482
|
+
price: null,
|
|
483
|
+
image: cardImg?.getAttribute('src') ?? null,
|
|
484
|
+
url: null,
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
else if (item.querySelector('.im-template-msg')) {
|
|
488
|
+
// Smart-bot auto-reply / rich template message (the "智能客户专员"
|
|
489
|
+
// welcome reply, promotional banners, etc.).
|
|
490
|
+
kind = 'other';
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
const contentImg = item.querySelector('.content img');
|
|
494
|
+
if (contentImg && cleanContent.length < 20) {
|
|
495
|
+
// Bare image in chat (no card wrapping, no card text).
|
|
496
|
+
kind = 'image';
|
|
497
|
+
card = {
|
|
498
|
+
title: null,
|
|
499
|
+
price: null,
|
|
500
|
+
image: contentImg.getAttribute('src') ?? null,
|
|
501
|
+
url: null,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
}
|
|
105
505
|
return {
|
|
106
506
|
sender: senderShort.trim(),
|
|
107
507
|
time,
|
|
108
508
|
isMine,
|
|
109
509
|
content: cleanContent,
|
|
110
510
|
read,
|
|
511
|
+
kind,
|
|
512
|
+
card,
|
|
111
513
|
};
|
|
112
514
|
});
|
|
113
515
|
});
|
|
@@ -168,25 +570,117 @@ export async function run(opts) {
|
|
|
168
570
|
limit,
|
|
169
571
|
};
|
|
170
572
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
...data,
|
|
176
|
-
messages: data.messages.filter((m) => {
|
|
573
|
+
const fetchOnce = async () => {
|
|
574
|
+
const result = await dispatch('seller-messages', args, { headed: opts.headed, profile: opts.profile });
|
|
575
|
+
if (sinceMs > 0) {
|
|
576
|
+
const filtered = result.messages.filter((m) => {
|
|
177
577
|
if (!m.time)
|
|
178
578
|
return false;
|
|
179
579
|
const t = Date.parse(m.time.replace(' ', 'T') + '+08:00');
|
|
180
580
|
return Number.isFinite(t) && t > sinceMs;
|
|
181
|
-
})
|
|
182
|
-
total:
|
|
183
|
-
}
|
|
581
|
+
});
|
|
582
|
+
return { ...result, messages: filtered, total: filtered.length };
|
|
583
|
+
}
|
|
584
|
+
return result;
|
|
585
|
+
};
|
|
586
|
+
// Watch mode: prime dedup set with current history, then poll and emit
|
|
587
|
+
// only newly-arrived messages. Ctrl+C to exit.
|
|
588
|
+
if (opts.watch) {
|
|
589
|
+
const intervalSec = Math.max(10, parseInt(opts.interval ?? '30', 10));
|
|
590
|
+
const seen = new Set();
|
|
591
|
+
info(`Watch mode: polling every ${intervalSec}s (Ctrl+C to stop)...`);
|
|
592
|
+
let baseline;
|
|
593
|
+
try {
|
|
594
|
+
baseline = await fetchOnce();
|
|
595
|
+
}
|
|
596
|
+
catch (e) {
|
|
597
|
+
throw new CliError(31, 'WATCH_BASELINE_FAILED', `Initial fetch failed: ${e.message}`);
|
|
598
|
+
}
|
|
599
|
+
for (const m of baseline.messages)
|
|
600
|
+
seen.add(messageKey(m));
|
|
601
|
+
info(`Baseline: ${baseline.conversation} — ${baseline.messages.length} messages in history`);
|
|
602
|
+
// Poll loop
|
|
603
|
+
// eslint-disable-next-line no-constant-condition
|
|
604
|
+
while (true) {
|
|
605
|
+
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
606
|
+
let next;
|
|
607
|
+
try {
|
|
608
|
+
next = await fetchOnce();
|
|
609
|
+
}
|
|
610
|
+
catch (e) {
|
|
611
|
+
process.stderr.write(`[watch] poll failed: ${e.message} — will retry next tick\n`);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const newMsgs = next.messages.filter((m) => !seen.has(messageKey(m)));
|
|
615
|
+
for (const m of newMsgs)
|
|
616
|
+
seen.add(messageKey(m));
|
|
617
|
+
if (newMsgs.length === 0)
|
|
618
|
+
continue;
|
|
619
|
+
if (isJson()) {
|
|
620
|
+
for (const m of newMsgs) {
|
|
621
|
+
process.stdout.write(JSON.stringify({ conversation: next.conversation, message: m }) +
|
|
622
|
+
'\n');
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
for (const m of newMsgs) {
|
|
627
|
+
process.stdout.write(formatOneMessage(m) + '\n');
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
184
631
|
}
|
|
632
|
+
const data = await fetchOnce();
|
|
185
633
|
emit({
|
|
186
634
|
human: () => printMessages(data),
|
|
187
635
|
data,
|
|
188
636
|
});
|
|
189
637
|
}
|
|
638
|
+
function formatOneMessage(m) {
|
|
639
|
+
const who = m.isMine ? '我' : m.sender || '对方';
|
|
640
|
+
const tail = m.read && m.isMine ? ' [已读]' : '';
|
|
641
|
+
const prefix = ` [${m.time ?? '?'}] ${who}:`;
|
|
642
|
+
if (m.kind === 'offerCard' && m.card) {
|
|
643
|
+
const offerMatch = m.card.url?.match(/detail\.1688\.com\/offer\/(\d+)\.html/i);
|
|
644
|
+
const offerId = offerMatch?.[1];
|
|
645
|
+
const title = m.card.title;
|
|
646
|
+
const price = m.card.price;
|
|
647
|
+
let body;
|
|
648
|
+
if (title) {
|
|
649
|
+
const priceStr = price ? ` ${price}` : '';
|
|
650
|
+
const idStr = offerId ? ` #${offerId}` : '';
|
|
651
|
+
body = `[商品] ${title}${priceStr}${idStr}`;
|
|
652
|
+
}
|
|
653
|
+
else if (offerId) {
|
|
654
|
+
body = `[商品 ${offerId}]`;
|
|
655
|
+
}
|
|
656
|
+
else {
|
|
657
|
+
body = `[商品卡] ${m.card.url ?? m.content}`;
|
|
658
|
+
}
|
|
659
|
+
return `${prefix} ${body}${tail}`;
|
|
660
|
+
}
|
|
661
|
+
if (m.kind === 'orderCard' && m.card) {
|
|
662
|
+
const orderMatch = m.card.url?.match(/order[Ii]d=(\d+)/);
|
|
663
|
+
const idTag = orderMatch ? `[订单 ${orderMatch[1]}]` : '[订单卡]';
|
|
664
|
+
const extra = m.card.title ? ` ${m.card.title}` : '';
|
|
665
|
+
return `${prefix} ${idTag}${extra}${tail}`;
|
|
666
|
+
}
|
|
667
|
+
if (m.kind === 'image' && m.card?.image) {
|
|
668
|
+
return `${prefix} [图片] ${m.card.image}${tail}`;
|
|
669
|
+
}
|
|
670
|
+
if (m.kind === 'autoReply' ||
|
|
671
|
+
m.kind === 'assessment' ||
|
|
672
|
+
m.kind === 'other') {
|
|
673
|
+
const tag = m.kind === 'autoReply'
|
|
674
|
+
? '[自动回复]'
|
|
675
|
+
: m.kind === 'assessment'
|
|
676
|
+
? '[客服评价]'
|
|
677
|
+
: '[模板]';
|
|
678
|
+
const compact = m.content.replace(/\s+/g, ' ').trim();
|
|
679
|
+
return `${prefix} ${tag} ${compact}${tail}`;
|
|
680
|
+
}
|
|
681
|
+
const compact = m.content.replace(/\s+/g, ' ').trim();
|
|
682
|
+
return `${prefix} ${compact}${tail}`;
|
|
683
|
+
}
|
|
190
684
|
function printMessages(r) {
|
|
191
685
|
process.stdout.write(`Conversation: ${r.conversation}\n`);
|
|
192
686
|
if (r.messages.length === 0) {
|
|
@@ -194,9 +688,14 @@ function printMessages(r) {
|
|
|
194
688
|
return;
|
|
195
689
|
}
|
|
196
690
|
for (const m of r.messages) {
|
|
197
|
-
|
|
198
|
-
const tail = m.read && m.isMine ? ' [已读]' : '';
|
|
199
|
-
process.stdout.write(` [${m.time ?? '?'}] ${who}: ${m.content}${tail}\n`);
|
|
691
|
+
process.stdout.write(formatOneMessage(m) + '\n');
|
|
200
692
|
}
|
|
201
693
|
}
|
|
694
|
+
/** Stable dedup key for watch mode. Prefers messageId (WS path); falls back
|
|
695
|
+
* to a (time + sender + content-prefix) tuple for the rare DOM-fallback case. */
|
|
696
|
+
function messageKey(m) {
|
|
697
|
+
if (m.messageId)
|
|
698
|
+
return m.messageId;
|
|
699
|
+
return `${m.time ?? '?'}|${m.sender}|${m.content.slice(0, 100)}`;
|
|
700
|
+
}
|
|
202
701
|
//# sourceMappingURL=seller-messages.js.map
|