channel-worker 2.5.142 → 2.5.144
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/lib/command-poller.js +222 -183
- package/lib/shopee-report.js +20 -11
- package/lib/shopee-scraper.js +48 -8
- package/package.json +1 -1
package/lib/command-poller.js
CHANGED
|
@@ -14,6 +14,20 @@ const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
|
14
14
|
// trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
|
|
15
15
|
const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
|
|
16
16
|
|
|
17
|
+
// Hạn chót cho CẢ một lệnh Shopee (nối profile + mọi lượt gọi của lệnh). Không
|
|
18
|
+
// có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lượt đọc báo
|
|
19
|
+
// cáo của Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả
|
|
20
|
+
// daemon (drain chờ `_handling` rỗng). Bốn loại dùng CHUNG một phiên và chung
|
|
21
|
+
// kiểu gọi nên chung một cửa treo. Số đo lượt thật: báo cáo 90 ngày ~1 phút
|
|
22
|
+
// (26/09: 808 đơn + 9.268 click trong 54s); lấy link 3-4 giây; quét từ khoá
|
|
23
|
+
// ~10-16 giây. API có bộ dọn riêng ở ngưỡng dài hơn giá trị lớn nhất ở đây.
|
|
24
|
+
const SHOPEE_DEADLINE_MS = {
|
|
25
|
+
fetch_affiliate_report: 10 * 60 * 1000,
|
|
26
|
+
scrape_affiliate_products: 10 * 60 * 1000,
|
|
27
|
+
get_affiliate_link: 3 * 60 * 1000,
|
|
28
|
+
ingest_shopee_product: 3 * 60 * 1000,
|
|
29
|
+
};
|
|
30
|
+
|
|
17
31
|
class CommandPoller {
|
|
18
32
|
constructor(api, config) {
|
|
19
33
|
this.api = api;
|
|
@@ -32,6 +46,7 @@ class CommandPoller {
|
|
|
32
46
|
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
33
47
|
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
34
48
|
this._pollsInFlight = 0;
|
|
49
|
+
this.shopeeDeadlineMs = { ...SHOPEE_DEADLINE_MS };
|
|
35
50
|
|
|
36
51
|
// Init Nstbrowser if API key available
|
|
37
52
|
if (config.nst_api_key) {
|
|
@@ -230,65 +245,102 @@ class CommandPoller {
|
|
|
230
245
|
}
|
|
231
246
|
|
|
232
247
|
// Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
248
|
+
// Chạy một lệnh Shopee trên profile `profileName`, chạy đua với hạn chót
|
|
249
|
+
// theo loại lệnh `type` (SHOPEE_DEADLINE_MS). `work(context)` làm việc thật — kể cả
|
|
250
|
+
// gửi dữ liệu về API — và trả bản vá cho lệnh ({status, result|error}).
|
|
251
|
+
// Hết hạn thì chốt `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ
|
|
252
|
+
// văng ra); kết nối nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
|
|
253
|
+
async _runShopeeCommand(command, { type, profileName, label, failLog, work }) {
|
|
254
|
+
const ms = this.shopeeDeadlineMs[type];
|
|
255
|
+
let conn = null;
|
|
256
|
+
let abandoned = false;
|
|
257
|
+
const job = (async () => {
|
|
258
|
+
const c = await this._connectNstProfileByName(profileName);
|
|
259
|
+
if (abandoned) { c.disconnect(); return null; }
|
|
260
|
+
conn = c;
|
|
261
|
+
return work(c.context);
|
|
262
|
+
})();
|
|
263
|
+
let timer;
|
|
264
|
+
const deadline = new Promise((_, reject) => {
|
|
265
|
+
timer = setTimeout(() => reject(new Error(
|
|
266
|
+
`quá ${Math.round(ms / 60000)} phút chưa ${label} [${profileName}] — huỷ lượt này`,
|
|
267
|
+
)), ms);
|
|
268
|
+
});
|
|
239
269
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
243
|
-
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
244
|
-
const keyword = String(payload.keyword || '').trim();
|
|
245
|
-
const res = await scraper.scrapeOffers(conn.context, {
|
|
246
|
-
keyword: keyword || null,
|
|
247
|
-
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
248
|
-
match_id: keyword ? null : (payload.match_id || null),
|
|
249
|
-
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
250
|
-
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
251
|
-
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
252
|
-
pages: payload.pages ?? 2,
|
|
253
|
-
page_limit: payload.page_limit ?? 20,
|
|
254
|
-
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
255
|
-
filters: payload.filters || {},
|
|
256
|
-
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
257
|
-
});
|
|
258
|
-
if (res.status === 'needs_verify') {
|
|
259
|
-
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
260
|
-
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
261
|
-
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
262
|
-
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
263
|
-
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
267
|
-
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
268
|
-
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
269
|
-
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
270
|
-
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
271
|
-
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
272
|
-
const slim = keyword
|
|
273
|
-
? res.products.slice(0, 20).map((r) => ({
|
|
274
|
-
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
275
|
-
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
276
|
-
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
277
|
-
product_url: r.product_url, category_group: r.category_group,
|
|
278
|
-
}))
|
|
279
|
-
: undefined;
|
|
280
|
-
await this.api.updateCommand(command._id, {
|
|
281
|
-
status: 'done',
|
|
282
|
-
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
283
|
-
});
|
|
270
|
+
const patch = await Promise.race([job, deadline]);
|
|
271
|
+
await this.api.updateCommand(command._id, patch);
|
|
284
272
|
} catch (err) {
|
|
285
|
-
console.error(
|
|
273
|
+
console.error(`${failLog}: ${err.message}`);
|
|
286
274
|
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
287
275
|
} finally {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
abandoned = true;
|
|
288
278
|
if (conn) conn.disconnect();
|
|
289
279
|
}
|
|
290
280
|
}
|
|
291
281
|
|
|
282
|
+
async handleScrapeAffiliateProducts(command) {
|
|
283
|
+
const payload = command.payload || {};
|
|
284
|
+
if (!(await this._ensureNst(command))) return;
|
|
285
|
+
const scraper = require('./shopee-scraper');
|
|
286
|
+
const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
|
|
287
|
+
await this._runShopeeCommand(command, {
|
|
288
|
+
type: 'scrape_affiliate_products',
|
|
289
|
+
profileName,
|
|
290
|
+
label: 'quét xong sản phẩm Shopee',
|
|
291
|
+
failLog: '[shopee] scrape failed',
|
|
292
|
+
work: async (context) => {
|
|
293
|
+
// Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
|
|
294
|
+
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
295
|
+
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
296
|
+
const keyword = String(payload.keyword || '').trim();
|
|
297
|
+
const res = await scraper.scrapeOffers(context, {
|
|
298
|
+
keyword: keyword || null,
|
|
299
|
+
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
300
|
+
match_id: keyword ? null : (payload.match_id || null),
|
|
301
|
+
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
302
|
+
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
303
|
+
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
304
|
+
pages: payload.pages ?? 2,
|
|
305
|
+
page_limit: payload.page_limit ?? 20,
|
|
306
|
+
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
307
|
+
filters: payload.filters || {},
|
|
308
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
309
|
+
});
|
|
310
|
+
if (res.status === 'needs_verify') {
|
|
311
|
+
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
312
|
+
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
313
|
+
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
314
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
315
|
+
return { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } };
|
|
316
|
+
}
|
|
317
|
+
if (res.status !== 'ok') {
|
|
318
|
+
// Shopee ngừng trả lời giữa chừng: món đã quét được vẫn vào kho.
|
|
319
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
320
|
+
return { status: 'failed', error: String(res.reason || 'Không quét được sản phẩm Shopee').slice(0, 500) };
|
|
321
|
+
}
|
|
322
|
+
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
323
|
+
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
324
|
+
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
325
|
+
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
326
|
+
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
327
|
+
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
328
|
+
const slim = keyword
|
|
329
|
+
? res.products.slice(0, 20).map((r) => ({
|
|
330
|
+
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
331
|
+
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
332
|
+
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
333
|
+
product_url: r.product_url, category_group: r.category_group,
|
|
334
|
+
}))
|
|
335
|
+
: undefined;
|
|
336
|
+
return {
|
|
337
|
+
status: 'done',
|
|
338
|
+
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
292
344
|
// Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
|
|
293
345
|
async handleIngestShopeeProduct(command) {
|
|
294
346
|
const payload = command.payload || {};
|
|
@@ -301,19 +353,18 @@ class CommandPoller {
|
|
|
301
353
|
await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
|
|
302
354
|
return;
|
|
303
355
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
356
|
+
await this._runShopeeCommand(command, {
|
|
357
|
+
type: 'ingest_shopee_product',
|
|
358
|
+
profileName,
|
|
359
|
+
label: 'nạp xong sản phẩm Shopee',
|
|
360
|
+
failLog: '[shopee] ingest failed',
|
|
361
|
+
work: async (context) => {
|
|
362
|
+
const product = await scraper.ingestProduct(context, ids);
|
|
363
|
+
console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
|
|
364
|
+
await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
|
|
365
|
+
return { status: 'done', result: { name: product.name, images: product.images.length } };
|
|
366
|
+
},
|
|
367
|
+
});
|
|
317
368
|
}
|
|
318
369
|
|
|
319
370
|
// Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
|
|
@@ -337,139 +388,127 @@ class CommandPoller {
|
|
|
337
388
|
return;
|
|
338
389
|
}
|
|
339
390
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
} finally {
|
|
372
|
-
if (conn) conn.disconnect();
|
|
373
|
-
}
|
|
391
|
+
await this._runShopeeCommand(command, {
|
|
392
|
+
type: 'get_affiliate_link',
|
|
393
|
+
profileName,
|
|
394
|
+
label: 'lấy được link Shopee',
|
|
395
|
+
failLog: '[shopee] lấy link thất bại',
|
|
396
|
+
work: async (context) => {
|
|
397
|
+
const res = await scraper.generateOfferLink(context, {
|
|
398
|
+
items,
|
|
399
|
+
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
400
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
401
|
+
});
|
|
402
|
+
if (res.status === 'needs_verify') {
|
|
403
|
+
// Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
|
|
404
|
+
// Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
|
|
405
|
+
// phải biết mở đúng cái nào để kéo captcha.
|
|
406
|
+
const reason = `${res.reason} [${profileName}]`;
|
|
407
|
+
console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
|
|
408
|
+
return { status: 'done', result: { needs_verify: true, reason } };
|
|
409
|
+
}
|
|
410
|
+
if (res.status !== 'ok') {
|
|
411
|
+
return { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) };
|
|
412
|
+
}
|
|
413
|
+
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
414
|
+
await this.api.affiliateLinkResult(res.links, {
|
|
415
|
+
user_id: command.user_id,
|
|
416
|
+
sub_ids: res.sub_ids,
|
|
417
|
+
idea_id: payload.idea_id || null,
|
|
418
|
+
});
|
|
419
|
+
return { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
374
422
|
}
|
|
375
423
|
|
|
376
424
|
async handleFetchAffiliateReport(command) {
|
|
377
425
|
const payload = command.payload || {};
|
|
378
426
|
if (!(await this._ensureNst(command))) return;
|
|
379
|
-
const report = require('./shopee-report');
|
|
380
427
|
const profileName = payload.profile_name
|
|
381
428
|
|| (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
|
|
382
429
|
|| 'Shopee1';
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
await this.api.updateCommand(command._id, {
|
|
392
|
-
status: 'done',
|
|
393
|
-
result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
|
|
394
|
-
});
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (result.status !== 'ok') {
|
|
398
|
-
await this.api.updateCommand(command._id, {
|
|
399
|
-
status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
|
|
400
|
-
});
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
430
|
+
await this._runShopeeCommand(command, {
|
|
431
|
+
type: 'fetch_affiliate_report',
|
|
432
|
+
profileName,
|
|
433
|
+
label: 'đọc xong báo cáo Shopee',
|
|
434
|
+
failLog: '[shopee-report] đồng bộ thất bại',
|
|
435
|
+
work: (context) => this._readAffiliateReport(context, payload),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
403
438
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500),
|
|
418
|
-
});
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
439
|
+
// Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
|
|
440
|
+
async _readAffiliateReport(context, payload) {
|
|
441
|
+
const report = require('./shopee-report');
|
|
442
|
+
const result = await report.fetchConversionReport(context, {
|
|
443
|
+
days: payload.days || 90,
|
|
444
|
+
log: (message) => console.log(message),
|
|
445
|
+
});
|
|
446
|
+
if (result.status === 'needs_verify') {
|
|
447
|
+
return { status: 'done', result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 } };
|
|
448
|
+
}
|
|
449
|
+
if (result.status !== 'ok') {
|
|
450
|
+
return { status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500) };
|
|
451
|
+
}
|
|
421
452
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
try {
|
|
429
|
-
const media = await report.fetchSocialMediaAccounts(conn.context, {
|
|
430
|
-
log: (message) => console.log(message),
|
|
431
|
-
});
|
|
432
|
-
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
433
|
-
else mediaError = media.reason || media.status;
|
|
434
|
-
} catch (err) {
|
|
435
|
-
mediaError = String(err.message || err).slice(0, 200);
|
|
436
|
-
}
|
|
437
|
-
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
438
|
-
|
|
439
|
-
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
440
|
-
clicks: clickResult.rows,
|
|
441
|
-
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
442
|
-
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
443
|
-
// chỉ vì một lượt đọc hỏng.
|
|
444
|
-
media_accounts: mediaAccounts,
|
|
445
|
-
last_report_date: result.last_report_date,
|
|
446
|
-
last_update_time: result.last_update_time,
|
|
447
|
-
from: result.from,
|
|
448
|
-
to: result.to,
|
|
449
|
-
});
|
|
450
|
-
await this.api.updateCommand(command._id, {
|
|
453
|
+
const clickResult = await report.fetchClickReport(context, {
|
|
454
|
+
days: payload.days || 90,
|
|
455
|
+
log: (message) => console.log(message),
|
|
456
|
+
});
|
|
457
|
+
if (clickResult.status === 'needs_verify') {
|
|
458
|
+
return {
|
|
451
459
|
status: 'done',
|
|
452
|
-
result: {
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
460
|
+
result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (clickResult.status !== 'ok') {
|
|
464
|
+
return { status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500) };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
|
|
468
|
+
// nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
|
|
469
|
+
// profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
|
|
470
|
+
// kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
|
|
471
|
+
let mediaAccounts = null;
|
|
472
|
+
let mediaError = null;
|
|
473
|
+
try {
|
|
474
|
+
const media = await report.fetchSocialMediaAccounts(context, {
|
|
475
|
+
log: (message) => console.log(message),
|
|
465
476
|
});
|
|
466
|
-
|
|
477
|
+
if (media.status === 'ok') mediaAccounts = media.accounts;
|
|
478
|
+
else mediaError = media.reason || media.status;
|
|
467
479
|
} catch (err) {
|
|
468
|
-
|
|
469
|
-
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
470
|
-
} finally {
|
|
471
|
-
if (conn) conn.disconnect();
|
|
480
|
+
mediaError = String(err.message || err).slice(0, 200);
|
|
472
481
|
}
|
|
482
|
+
if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
|
|
483
|
+
|
|
484
|
+
const saved = await this.api.affiliateReportResult(result.rows, {
|
|
485
|
+
clicks: clickResult.rows,
|
|
486
|
+
// `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
|
|
487
|
+
// liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
|
|
488
|
+
// chỉ vì một lượt đọc hỏng.
|
|
489
|
+
media_accounts: mediaAccounts,
|
|
490
|
+
last_report_date: result.last_report_date,
|
|
491
|
+
last_update_time: result.last_update_time,
|
|
492
|
+
from: result.from,
|
|
493
|
+
to: result.to,
|
|
494
|
+
});
|
|
495
|
+
console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
|
|
496
|
+
return {
|
|
497
|
+
status: 'done',
|
|
498
|
+
result: {
|
|
499
|
+
count: result.rows.length,
|
|
500
|
+
saved: saved?.saved || 0,
|
|
501
|
+
matched: saved?.matched || 0,
|
|
502
|
+
unmatched: saved?.unmatched || 0,
|
|
503
|
+
click_count: clickResult.rows.length,
|
|
504
|
+
click_saved: saved?.click_saved || 0,
|
|
505
|
+
click_matched: saved?.click_matched || 0,
|
|
506
|
+
click_unmatched: saved?.click_unmatched || 0,
|
|
507
|
+
media_accounts: mediaAccounts ? mediaAccounts.length : null,
|
|
508
|
+
media_error: mediaError || undefined,
|
|
509
|
+
last_report_date: result.last_report_date,
|
|
510
|
+
},
|
|
511
|
+
};
|
|
473
512
|
}
|
|
474
513
|
|
|
475
514
|
async handlePlaywrightCommand(command) {
|
package/lib/shopee-report.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, evaluateWithin, EVAL_TIMEOUT_MS,
|
|
3
|
+
} = require('./shopee-scraper');
|
|
2
4
|
|
|
3
5
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
6
|
|
|
@@ -17,6 +19,7 @@ async function fetchConversionReport(ctx, {
|
|
|
17
19
|
maxPages = 200,
|
|
18
20
|
delayMs = 750,
|
|
19
21
|
timeoutMs = 60000,
|
|
22
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
20
23
|
log = null,
|
|
21
24
|
} = {}) {
|
|
22
25
|
let page = findAffiliatePage(ctx.pages());
|
|
@@ -43,7 +46,7 @@ async function fetchConversionReport(ctx, {
|
|
|
43
46
|
let lastReportDate = null;
|
|
44
47
|
let lastUpdateTime = null;
|
|
45
48
|
|
|
46
|
-
const updated = await page
|
|
49
|
+
const updated = await evaluateWithin(page, async () => {
|
|
47
50
|
try {
|
|
48
51
|
const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
|
|
49
52
|
method: 'POST', credentials: 'include',
|
|
@@ -56,12 +59,17 @@ async function fetchConversionReport(ctx, {
|
|
|
56
59
|
});
|
|
57
60
|
return await r.json().catch(() => null);
|
|
58
61
|
} catch { return null; }
|
|
59
|
-
});
|
|
62
|
+
}, undefined, evalTimeoutMs);
|
|
63
|
+
if (updated?.timedOut) {
|
|
64
|
+
return isBlockedState(page.url(), null)
|
|
65
|
+
? { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' }
|
|
66
|
+
: { status: 'failed', rows: [], reason: updated.error };
|
|
67
|
+
}
|
|
60
68
|
lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
|
|
61
69
|
lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
|
|
62
70
|
|
|
63
71
|
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
64
|
-
const result = await page
|
|
72
|
+
const result = await evaluateWithin(page, async (q) => {
|
|
65
73
|
const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
|
|
66
74
|
+ `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
|
|
67
75
|
try {
|
|
@@ -70,7 +78,7 @@ async function fetchConversionReport(ctx, {
|
|
|
70
78
|
} catch (e) {
|
|
71
79
|
return { status: 0, error: String(e?.message || e) };
|
|
72
80
|
}
|
|
73
|
-
}, { pageSize, pageNum, start: window.start, end: window.end });
|
|
81
|
+
}, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
|
|
74
82
|
|
|
75
83
|
if (isAntiBotBody(result.json)) {
|
|
76
84
|
return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
|
|
@@ -113,6 +121,7 @@ async function fetchClickReport(ctx, {
|
|
|
113
121
|
maxPages = 200,
|
|
114
122
|
delayMs = 250,
|
|
115
123
|
timeoutMs = 60000,
|
|
124
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
116
125
|
log = null,
|
|
117
126
|
} = {}) {
|
|
118
127
|
let page = findAffiliatePage(ctx.pages());
|
|
@@ -138,7 +147,7 @@ async function fetchClickReport(ctx, {
|
|
|
138
147
|
let total = null;
|
|
139
148
|
|
|
140
149
|
for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
|
|
141
|
-
const result = await page
|
|
150
|
+
const result = await evaluateWithin(page, async (q) => {
|
|
142
151
|
const url = `/api/v1/click_report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
|
|
143
152
|
+ `&click_time_s=${q.start}&click_time_e=${q.end}`;
|
|
144
153
|
try {
|
|
@@ -147,7 +156,7 @@ async function fetchClickReport(ctx, {
|
|
|
147
156
|
} catch (e) {
|
|
148
157
|
return { status: 0, error: String(e?.message || e) };
|
|
149
158
|
}
|
|
150
|
-
}, { pageSize, pageNum, start: window.start, end: window.end });
|
|
159
|
+
}, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
|
|
151
160
|
|
|
152
161
|
if (isAntiBotBody(result.json)) {
|
|
153
162
|
return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
|
|
@@ -206,7 +215,7 @@ async function fetchClickReport(ctx, {
|
|
|
206
215
|
// khác giữ nguyên số để sau này đọc lại, không đoán tên).
|
|
207
216
|
const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
|
|
208
217
|
|
|
209
|
-
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
|
|
218
|
+
async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
|
|
210
219
|
let page = findAffiliatePage(ctx.pages());
|
|
211
220
|
let opened = false;
|
|
212
221
|
if (!page) {
|
|
@@ -225,14 +234,14 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
|
|
|
225
234
|
return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
226
235
|
}
|
|
227
236
|
|
|
228
|
-
const result = await page
|
|
237
|
+
const result = await evaluateWithin(page, async () => {
|
|
229
238
|
try {
|
|
230
239
|
const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
|
|
231
240
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
232
241
|
} catch (e) {
|
|
233
242
|
return { status: 0, error: String(e?.message || e) };
|
|
234
243
|
}
|
|
235
|
-
});
|
|
244
|
+
}, undefined, evalTimeoutMs);
|
|
236
245
|
|
|
237
246
|
if (isAntiBotBody(result.json)) {
|
|
238
247
|
return { status: 'needs_verify', accounts: [], reason: ANTI_BOT_REASON };
|
|
@@ -266,4 +275,4 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
|
|
|
266
275
|
}
|
|
267
276
|
}
|
|
268
277
|
|
|
269
|
-
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
|
|
278
|
+
module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, evaluateWithin, SM_PLATFORM };
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -72,13 +72,16 @@ function parsePdpItem(item) {
|
|
|
72
72
|
async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {}) {
|
|
73
73
|
if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
|
|
74
74
|
const page = await ctx.newPage();
|
|
75
|
+
let timer;
|
|
75
76
|
try {
|
|
76
77
|
const captured = new Promise((resolve) => {
|
|
77
78
|
page.on('response', async (res) => {
|
|
78
79
|
if (!res.url().includes('/api/v4/pdp/get_pc')) return;
|
|
79
80
|
try { const j = await res.json(); if (j?.data?.item) resolve(j.data.item); } catch { /* keep waiting */ }
|
|
80
81
|
});
|
|
81
|
-
|
|
82
|
+
// Trang bị đóng (lệnh hết hạn chót → cắt kết nối) thì thôi chờ ngay.
|
|
83
|
+
page.once('close', () => resolve(null));
|
|
84
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
82
85
|
});
|
|
83
86
|
await page.goto(`https://shopee.vn/product/${shop_id}/${item_id}`, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
84
87
|
const item = await captured;
|
|
@@ -87,6 +90,7 @@ async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {})
|
|
|
87
90
|
parsed.product_url = `https://shopee.vn/product/${shop_id}/${item_id}`;
|
|
88
91
|
return parsed;
|
|
89
92
|
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
90
94
|
await page.close().catch(() => {});
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -169,6 +173,31 @@ const AFFILIATE_CATEGORIES = {
|
|
|
169
173
|
|
|
170
174
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
175
|
|
|
176
|
+
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
177
|
+
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
178
|
+
// (Shopee2, relabs03) một lượt đọc báo cáo nằm im 12 tiếng, chỉ nhả khi người
|
|
179
|
+
// gỡ captcha làm trang chuyển hướng. Mọi luồng Shopee (báo cáo, lấy link, quét
|
|
180
|
+
// món) dùng chung phiên và chung kiểu gọi nên dùng chung chốt này. Hết hạn thì
|
|
181
|
+
// trả `{ status: 0, timedOut }` để bên gọi xử như một lượt hỏng; trang lúc đó
|
|
182
|
+
// đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
183
|
+
const EVAL_TIMEOUT_MS = 60000;
|
|
184
|
+
|
|
185
|
+
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
186
|
+
let timer;
|
|
187
|
+
const expired = new Promise((resolve) => {
|
|
188
|
+
timer = setTimeout(() => resolve({
|
|
189
|
+
status: 0,
|
|
190
|
+
timedOut: true,
|
|
191
|
+
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
192
|
+
}), ms);
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// True when the session got bounced to the anti-bot captcha (scene=crawler_item)
|
|
173
202
|
// or logged out — in either case the offer API returns 404/empty and we must
|
|
174
203
|
// STOP (not keep hammering) and ask the operator to re-verify via VNC.
|
|
@@ -178,7 +207,12 @@ function isBlockedState(pageUrl, res) {
|
|
|
178
207
|
try { u = new URL(pageUrl || ''); } catch { return false; }
|
|
179
208
|
// CHỈ xét path + query của CHÍNH trang này. Regex quét cả chuỗi URL đọc nhầm
|
|
180
209
|
// tham số `next=` của trang khác — xem ghi chú ở findAffiliatePage.
|
|
181
|
-
|
|
210
|
+
//
|
|
211
|
+
// MỌI trang /verify/*, không riêng captcha: Shopee đẩy qua `verify/traffic`
|
|
212
|
+
// trước rồi mới tới `verify/captcha`. 28/09/2026 (Shopee2) lượt đọc báo cáo
|
|
213
|
+
// gặp đúng trang traffic, không nhận ra, chạy fetch trên trang chống bot và
|
|
214
|
+
// treo 12 tiếng tới khi người gỡ captcha.
|
|
215
|
+
if (/^\/(verify\/|buyer\/login)/.test(u.pathname)) return true;
|
|
182
216
|
if (u.searchParams.has('is_from_login')) return true;
|
|
183
217
|
return false;
|
|
184
218
|
}
|
|
@@ -337,6 +371,7 @@ async function scrapeOffers(ctx, {
|
|
|
337
371
|
delay_ms = 2500, // human-like pause between calls (anti-bot)
|
|
338
372
|
filters = {},
|
|
339
373
|
timeoutMs = 45000,
|
|
374
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
340
375
|
log = null,
|
|
341
376
|
// legacy single-sort param (back-compat with the first build)
|
|
342
377
|
sort_type = null,
|
|
@@ -384,7 +419,7 @@ async function scrapeOffers(ctx, {
|
|
|
384
419
|
for (const st of sort_types) {
|
|
385
420
|
for (let i = 0; i < pages; i++) {
|
|
386
421
|
const offset = i * page_limit;
|
|
387
|
-
const res = await page
|
|
422
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
388
423
|
// Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
|
|
389
424
|
// thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
|
|
390
425
|
// đang duyệt tab cũ.
|
|
@@ -398,7 +433,7 @@ async function scrapeOffers(ctx, {
|
|
|
398
433
|
const r = await fetch(url, { credentials: 'include' });
|
|
399
434
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
400
435
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
401
|
-
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types });
|
|
436
|
+
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types }, evalTimeoutMs);
|
|
402
437
|
|
|
403
438
|
if (isAntiBotBody(res.json)) {
|
|
404
439
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: ANTI_BOT_REASON };
|
|
@@ -406,6 +441,11 @@ async function scrapeOffers(ctx, {
|
|
|
406
441
|
if (isBlockedState(page.url(), res)) {
|
|
407
442
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
|
|
408
443
|
}
|
|
444
|
+
// Hết hạn KHÔNG phải hết dữ liệu: đi tiếp là thêm một lượt chờ nữa trên
|
|
445
|
+
// đúng phiên đang kẹt. Dừng hẳn, giữ những món đã quét được.
|
|
446
|
+
if (res.timedOut) {
|
|
447
|
+
return { status: 'failed', products: [...byId.values()], raw_count: rawCount, reason: res.error };
|
|
448
|
+
}
|
|
409
449
|
if (res.status !== 200 || res.json?.code !== 0) {
|
|
410
450
|
// soft stop — treat as end-of-data for this sort, move on
|
|
411
451
|
break;
|
|
@@ -478,7 +518,7 @@ function linkFailureReason(res, wanted = 0) {
|
|
|
478
518
|
}
|
|
479
519
|
|
|
480
520
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
481
|
-
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, log = null } = {}) {
|
|
521
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
|
|
482
522
|
const list = (items || [])
|
|
483
523
|
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
484
524
|
.filter((it) => it.itemId && it.shopId);
|
|
@@ -499,7 +539,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
499
539
|
if (isBlockedState(page.url(), null)) {
|
|
500
540
|
return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
501
541
|
}
|
|
502
|
-
const res = await page
|
|
542
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
503
543
|
const body = {
|
|
504
544
|
operationName: 'batchGetProductOfferLink',
|
|
505
545
|
query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
|
|
@@ -517,7 +557,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
517
557
|
});
|
|
518
558
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
519
559
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
520
|
-
}, { list, subs });
|
|
560
|
+
}, { list, subs }, evalTimeoutMs);
|
|
521
561
|
|
|
522
562
|
if (isAntiBotBody(res.json)) {
|
|
523
563
|
return { status: 'needs_verify', links: [], reason: ANTI_BOT_REASON };
|
|
@@ -576,4 +616,4 @@ function parseProductUrl(url = '') {
|
|
|
576
616
|
return null;
|
|
577
617
|
}
|
|
578
618
|
|
|
579
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab };
|
|
619
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab, evaluateWithin, EVAL_TIMEOUT_MS };
|