channel-worker 2.5.143 → 2.5.145

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/api-client.js CHANGED
@@ -75,7 +75,7 @@ class ApiClient {
75
75
  // nào — xảy ra 14/09 với verify_facebook_reels_pw (cổng thứ nhất là enum
76
76
  // của app/models/WorkerCommand.js, cũng đã quên một lần cùng ngày).
77
77
  // api/tests/publish-verify-wiring.test.js canh cả hai cổng.
78
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link,fetch_affiliate_report';
78
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,upload_facebook_post_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,verify_facebook_reels_pw,comment_facebook_pw,facebook_page_check_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link,fetch_affiliate_report';
79
79
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
80
  }
81
81
 
@@ -14,12 +14,19 @@ 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Ả lượt đọc báo cáo Shopee (nối profile + đơn + click + liên
18
- // kết). Lượt thật 90 ngày mất ~1 phút (26/09: 808 đơn + 9.268 click trong 54s).
19
- // Không có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lệnh của
20
- // Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả daemon
21
- // (drain chờ `_handling` rỗng). API có bộ dọn riêng ở ngưỡng dài hơn.
22
- const AFF_REPORT_DEADLINE_MS = 10 * 60 * 1000;
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
+ };
23
30
 
24
31
  class CommandPoller {
25
32
  constructor(api, config) {
@@ -39,7 +46,7 @@ class CommandPoller {
39
46
  // pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
40
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ó.
41
48
  this._pollsInFlight = 0;
42
- this.affReportDeadlineMs = AFF_REPORT_DEADLINE_MS;
49
+ this.shopeeDeadlineMs = { ...SHOPEE_DEADLINE_MS };
43
50
 
44
51
  // Init Nstbrowser if API key available
45
52
  if (config.nst_api_key) {
@@ -238,65 +245,102 @@ class CommandPoller {
238
245
  }
239
246
 
240
247
  // Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
241
- async handleScrapeAffiliateProducts(command) {
242
- const payload = command.payload || {};
243
- if (!(await this._ensureNst(command))) return;
244
- const scraper = require('./shopee-scraper');
245
- const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
246
- let conn;
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
+ });
247
269
  try {
248
- conn = await this._connectNstProfileByName(profileName);
249
- // Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
250
- // `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
251
- // cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
252
- const keyword = String(payload.keyword || '').trim();
253
- const res = await scraper.scrapeOffers(conn.context, {
254
- keyword: keyword || null,
255
- category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
256
- match_id: keyword ? null : (payload.match_id || null),
257
- // Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
258
- // (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
259
- sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
260
- pages: payload.pages ?? 2,
261
- page_limit: payload.page_limit ?? 20,
262
- delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
263
- filters: payload.filters || {},
264
- log: (m) => console.log(m), // để thấy số tab đã dọn
265
- });
266
- if (res.status === 'needs_verify') {
267
- // Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
268
- // so the dashboard can prompt the operator to re-verify via VNC.
269
- console.warn(`[shopee] needs_verify: ${res.reason}`);
270
- if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
271
- await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
272
- return;
273
- }
274
- console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
275
- await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
276
- // Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
277
- // 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
278
- // thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
279
- // Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
280
- const slim = keyword
281
- ? res.products.slice(0, 20).map((r) => ({
282
- shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
283
- commission_rate: r.commission_rate, commission_value: r.commission_value,
284
- sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
285
- product_url: r.product_url, category_group: r.category_group,
286
- }))
287
- : undefined;
288
- await this.api.updateCommand(command._id, {
289
- status: 'done',
290
- result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
291
- });
270
+ const patch = await Promise.race([job, deadline]);
271
+ await this.api.updateCommand(command._id, patch);
292
272
  } catch (err) {
293
- console.error(`[shopee] scrape failed: ${err.message}`);
273
+ console.error(`${failLog}: ${err.message}`);
294
274
  await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
295
275
  } finally {
276
+ clearTimeout(timer);
277
+ abandoned = true;
296
278
  if (conn) conn.disconnect();
297
279
  }
298
280
  }
299
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
+
300
344
  // Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
301
345
  async handleIngestShopeeProduct(command) {
302
346
  const payload = command.payload || {};
@@ -309,19 +353,18 @@ class CommandPoller {
309
353
  await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
310
354
  return;
311
355
  }
312
- let conn;
313
- try {
314
- conn = await this._connectNstProfileByName(profileName);
315
- const product = await scraper.ingestProduct(conn.context, ids);
316
- console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
317
- await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
318
- await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
319
- } catch (err) {
320
- console.error(`[shopee] ingest failed: ${err.message}`);
321
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
322
- } finally {
323
- if (conn) conn.disconnect();
324
- }
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
+ });
325
368
  }
326
369
 
327
370
  // Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
@@ -345,40 +388,37 @@ class CommandPoller {
345
388
  return;
346
389
  }
347
390
 
348
- let conn;
349
- try {
350
- conn = await this._connectNstProfileByName(profileName);
351
- const res = await scraper.generateOfferLink(conn.context, {
352
- items,
353
- subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
354
- log: (m) => console.log(m), // để thấy số tab đã dọn
355
- });
356
- if (res.status === 'needs_verify') {
357
- // Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
358
- // Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
359
- // phải biết mở đúng cái nào để kéo captcha.
360
- const reason = `${res.reason} [${profileName}]`;
361
- console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
362
- await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason } });
363
- return;
364
- }
365
- if (res.status !== 'ok') {
366
- await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
367
- return;
368
- }
369
- console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
370
- await this.api.affiliateLinkResult(res.links, {
371
- user_id: command.user_id,
372
- sub_ids: res.sub_ids,
373
- idea_id: payload.idea_id || null,
374
- });
375
- await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
376
- } catch (err) {
377
- console.error(`[shopee] lấy link thất bại: ${err.message}`);
378
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
379
- } finally {
380
- if (conn) conn.disconnect();
381
- }
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
+ });
382
422
  }
383
423
 
384
424
  async handleFetchAffiliateReport(command) {
@@ -387,35 +427,13 @@ class CommandPoller {
387
427
  const profileName = payload.profile_name
388
428
  || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
389
429
  || 'Shopee1';
390
-
391
- // Việc thật chạy trong `work`, chạy đua với hạn chót. Hết hạn thì chốt
392
- // `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ văng ra); kết nối
393
- // nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
394
- let conn = null;
395
- let abandoned = false;
396
- const work = (async () => {
397
- const c = await this._connectNstProfileByName(profileName);
398
- if (abandoned) { c.disconnect(); return null; }
399
- conn = c;
400
- return this._readAffiliateReport(c.context, payload);
401
- })();
402
- let timer;
403
- const deadline = new Promise((_, reject) => {
404
- timer = setTimeout(() => reject(new Error(
405
- `quá ${Math.round(this.affReportDeadlineMs / 60000)} phút chưa đọc xong báo cáo Shopee [${profileName}] — huỷ lượt này`,
406
- )), this.affReportDeadlineMs);
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),
407
436
  });
408
- try {
409
- const patch = await Promise.race([work, deadline]);
410
- await this.api.updateCommand(command._id, patch);
411
- } catch (err) {
412
- console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
413
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
414
- } finally {
415
- clearTimeout(timer);
416
- abandoned = true;
417
- if (conn) conn.disconnect();
418
- }
419
437
  }
420
438
 
421
439
  // Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
@@ -512,6 +530,9 @@ class CommandPoller {
512
530
  // only serializes within a profile. Wait up to 30 min with 5s polling
513
531
  // (upload typically 1-3 min); stale-cleanup picks up zombies separately.
514
532
  // Lượt mới của cùng profile → huỷ hẹn đóng, giữ browser cho nó dùng lại.
533
+ // Nhớ lại là CÓ hẹn: browser đó do automation mở, lượt này xong phải hẹn
534
+ // đóng lại (xem nhánh runInfo.attached ở cuối hàm).
535
+ const hadCloseTimer = !!(this._pwCloseTimers && this._pwCloseTimers.has(profileId));
515
536
  if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
516
537
  clearTimeout(this._pwCloseTimers.get(profileId));
517
538
  this._pwCloseTimers.delete(profileId);
@@ -581,6 +602,7 @@ class CommandPoller {
581
602
  });
582
603
 
583
604
  const keepOpen = await this._keepOpenCfg();
605
+ const runInfo = {};
584
606
 
585
607
  // Nhịp còn-sống SUỐT lúc kịch bản chạy (không chỉ lúc xếp hàng). Bộ dọn của
586
608
  // API khai tử lệnh đăng 'running' quá 20 phút không cập nhật; một lượt
@@ -600,6 +622,7 @@ class CommandPoller {
600
622
  payload,
601
623
  log,
602
624
  keepOpen: keepOpen.enabled,
625
+ info: runInfo,
603
626
  });
604
627
  // postCommandResult (not updateCommand) so the existing extension-side
605
628
  // logic mirrors status into ContentJob + ContentIdea.publish_results +
@@ -637,6 +660,11 @@ class CommandPoller {
637
660
  this._keptOpenProfiles.set(profileId, Date.now());
638
661
  console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
639
662
  await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
663
+ } else if (runInfo.attached && !hadCloseTimer) {
664
+ // Script nối vào browser ĐANG MỞ SẴN mà không có hẹn đóng nào của
665
+ // automation (vd "Kiểm tra Page ngay" lúc người vừa tạo Page còn ngồi
666
+ // trong cửa sổ) → không phải browser của mình, để nguyên cho người đó.
667
+ console.log(`[commands/pw] ${scriptName}: nối vào profile ${profileId} đang mở sẵn — để nguyên, không hẹn đóng`);
640
668
  } else if (CLOSE_AFTER.has(scriptName) && this.nst) {
641
669
  try {
642
670
  await this.nst.stopProfile(profileId);
@@ -52,33 +52,35 @@ async function enableFocusEmulation(page, log) {
52
52
  }
53
53
  }
54
54
 
55
- async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false }) {
56
- let playwright;
57
- try { playwright = require('playwright-core'); }
58
- catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
59
-
60
- const script = loadScript(scriptName);
61
-
62
- log('info', `[pw] launching NST profile ${profileId}…`);
55
+ // Lấy cổng CDP của profile. Mở mới nếu chưa chạy; đang chạy thì mặc định
56
+ // tắt-mở lại để có endpoint tươi.
57
+ //
58
+ // Hai trường hợp NỐI THẲNG vào browser đang chạy thay vì tắt-mở lại:
59
+ // - keepOpen: máy bị chặn số lượt mở/ngày — relaunch là đốt 1 lượt cho không.
60
+ // - attachIfRunning (script tự khai, vd facebook_page_check): người vừa tạo
61
+ // Page rất có thể còn ngồi trong đúng cửa sổ đó — relaunch là đóng ngang
62
+ // trước mặt họ. Script loại này tự mở tab riêng và chỉ đóng tab của nó.
63
+ // Trả { endpoint, attached } — attached = đã nối vào browser đang chạy sẵn
64
+ // (daemon dựa vào đó để không hẹn đóng một cửa sổ nó không mở).
65
+ async function resolveEndpoint({ nst, profileId, log = () => {}, keepOpen = false, attachIfRunning = false }) {
63
66
  // Don't load any Chromium extension — Playwright is the automation, no need
64
67
  // for the channel-manager-ext on this path. (The chagpt cgpt path still
65
68
  // launches with content-creator-ext via a different code path.)
66
69
  const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
67
70
  let endpoint = launchRes?.wsEndpoint;
71
+ let attached = false;
68
72
 
69
73
  // If the profile was already running, launchProfile returns { alreadyRunning:
70
74
  // true } without a wsEndpoint.
71
75
  if (!endpoint && launchRes?.alreadyRunning) {
72
- // keepOpen: máy bị chặn số lượt mở/ngày. Tắt-mở lại một profile đang chạy
73
- // sẵn chỉ để lấy endpoint mới là đốt 1 lượt cho không — nối thẳng vào cổng
74
- // debug của nó thay vì relaunch.
75
- if (keepOpen) {
76
+ if (keepOpen || attachIfRunning) {
76
77
  const live = await nst.resolveRunningEndpoint(profileId).catch(() => null);
77
78
  if (live?.httpEndpoint) {
78
79
  endpoint = live.httpEndpoint;
79
- log('info', `[pw] profile đang chạy — nối vào cổng debug ${live.port} (không tốn lượt mở)`);
80
+ attached = true;
81
+ log('info', `[pw] profile đang chạy — nối vào cổng debug ${live.port} (không mở lại${keepOpen ? ', không tốn lượt mở' : ''})`);
80
82
  } else {
81
- log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại (tốn 1 lượt)`);
83
+ log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại${keepOpen ? ' (tốn 1 lượt)' : ''}`);
82
84
  }
83
85
  }
84
86
  if (!endpoint) {
@@ -88,6 +90,22 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
88
90
  }
89
91
  }
90
92
  if (!endpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
93
+ return { endpoint, attached };
94
+ }
95
+
96
+ // `info` (tuỳ chọn) được điền { attached } cho daemon đọc sau lượt chạy.
97
+ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false, info = null }) {
98
+ let playwright;
99
+ try { playwright = require('playwright-core'); }
100
+ catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
101
+
102
+ const script = loadScript(scriptName);
103
+
104
+ log('info', `[pw] launching NST profile ${profileId}…`);
105
+ const { endpoint, attached } = await resolveEndpoint({
106
+ nst, profileId, log, keepOpen, attachIfRunning: !!script.attachIfRunning,
107
+ });
108
+ if (info) info.attached = attached;
91
109
 
92
110
  log('info', `[pw] connectOverCDP…`);
93
111
  const browser = await playwright.chromium.connectOverCDP(endpoint);
@@ -107,4 +125,4 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
107
125
  }
108
126
  }
109
127
 
110
- module.exports = { runPlaywrightScript, enableFocusEmulation };
128
+ module.exports = { runPlaywrightScript, enableFocusEmulation, resolveEndpoint };
@@ -1,31 +1,9 @@
1
- const { findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON } = require('./shopee-scraper');
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
 
5
- // Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
6
- // fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
7
- // (Shopee2, relabs03) một lượt nằm im 12 tiếng, chỉ nhả khi người gỡ captcha
8
- // làm trang chuyển hướng — suốt lúc đó lệnh `running` khoá nút Đồng bộ và lịch
9
- // của máy. Hết hạn thì trả `{ status: 0, timedOut }` để bên gọi xử như một
10
- // lượt hỏng; trang lúc đó đã sang captcha thì isBlockedState đọc ra needs_verify.
11
- const EVAL_TIMEOUT_MS = 60000;
12
-
13
- async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
14
- let timer;
15
- const expired = new Promise((resolve) => {
16
- timer = setTimeout(() => resolve({
17
- status: 0,
18
- timedOut: true,
19
- error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
20
- }), ms);
21
- });
22
- try {
23
- return await Promise.race([page.evaluate(fn, arg), expired]);
24
- } finally {
25
- clearTimeout(timer);
26
- }
27
- }
28
-
29
7
  function reportWindow(days = 90, nowMs = Date.now()) {
30
8
  const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
31
9
  const vnNow = new Date(nowMs + 7 * 3600e3);
@@ -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
- setTimeout(() => resolve(null), timeoutMs);
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.
@@ -342,6 +371,7 @@ async function scrapeOffers(ctx, {
342
371
  delay_ms = 2500, // human-like pause between calls (anti-bot)
343
372
  filters = {},
344
373
  timeoutMs = 45000,
374
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
345
375
  log = null,
346
376
  // legacy single-sort param (back-compat with the first build)
347
377
  sort_type = null,
@@ -389,7 +419,7 @@ async function scrapeOffers(ctx, {
389
419
  for (const st of sort_types) {
390
420
  for (let i = 0; i < pages; i++) {
391
421
  const offset = i * page_limit;
392
- const res = await page.evaluate(async (q) => {
422
+ const res = await evaluateWithin(page, async (q) => {
393
423
  // Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
394
424
  // thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
395
425
  // đang duyệt tab cũ.
@@ -403,7 +433,7 @@ async function scrapeOffers(ctx, {
403
433
  const r = await fetch(url, { credentials: 'include' });
404
434
  return { status: r.status, json: await r.json().catch(() => null) };
405
435
  } catch (e) { return { status: 0, error: String(e.message) }; }
406
- }, { st, offset, page_limit, mid, listType, kw, ft: filter_types });
436
+ }, { st, offset, page_limit, mid, listType, kw, ft: filter_types }, evalTimeoutMs);
407
437
 
408
438
  if (isAntiBotBody(res.json)) {
409
439
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: ANTI_BOT_REASON };
@@ -411,6 +441,11 @@ async function scrapeOffers(ctx, {
411
441
  if (isBlockedState(page.url(), res)) {
412
442
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
413
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
+ }
414
449
  if (res.status !== 200 || res.json?.code !== 0) {
415
450
  // soft stop — treat as end-of-data for this sort, move on
416
451
  break;
@@ -483,7 +518,7 @@ function linkFailureReason(res, wanted = 0) {
483
518
  }
484
519
 
485
520
  // subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
486
- 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 } = {}) {
487
522
  const list = (items || [])
488
523
  .map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
489
524
  .filter((it) => it.itemId && it.shopId);
@@ -504,7 +539,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
504
539
  if (isBlockedState(page.url(), null)) {
505
540
  return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
506
541
  }
507
- const res = await page.evaluate(async (q) => {
542
+ const res = await evaluateWithin(page, async (q) => {
508
543
  const body = {
509
544
  operationName: 'batchGetProductOfferLink',
510
545
  query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
@@ -522,7 +557,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
522
557
  });
523
558
  return { status: r.status, json: await r.json().catch(() => null) };
524
559
  } catch (e) { return { status: 0, error: String(e.message) }; }
525
- }, { list, subs });
560
+ }, { list, subs }, evalTimeoutMs);
526
561
 
527
562
  if (isAntiBotBody(res.json)) {
528
563
  return { status: 'needs_verify', links: [], reason: ANTI_BOT_REASON };
@@ -581,4 +616,4 @@ function parseProductUrl(url = '') {
581
616
  return null;
582
617
  }
583
618
 
584
- 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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.143",
3
+ "version": "2.5.145",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -0,0 +1,74 @@
1
+ // "Kiểm tra Page ngay" — profile NST của kênh đang dùng Facebook dưới danh
2
+ // nghĩa PAGE hay vẫn là trang CÁ NHÂN?
3
+ //
4
+ // Sinh ra 29/09/2026: người vừa tạo Page vẫn bị API chặn đăng vì cờ
5
+ // `nurture.profile_mode='personal'` do phiên nuôi hôm trước ghi, và cờ đó chỉ
6
+ // phiên nuôi làm mới được (hàng nuôi chạy lần lượt cả đội). Script này là lượt
7
+ // đọc riêng, ~30 giây: mở facebook.com, kiểm tài khoản còn sống, đọc danh tính
8
+ // bằng ĐÚNG bộ đọc mà lượt đăng dùng (lib/fb-guard readProfileMode), rồi thôi.
9
+ // Không lướt, không bấm, không mở web ngoài như phiên nuôi.
10
+ //
11
+ // Người bấm nút này thường VỪA tạo Page trong chính profile đó và cửa sổ có
12
+ // thể còn mở. Vì vậy:
13
+ // - attachIfRunning: runner nối vào browser đang chạy thay vì tắt-mở lại;
14
+ // - làm trong TAB RIÊNG và chỉ đóng tab của mình — không đóng tab nào khác
15
+ // (phiên nuôi thì có dọn tab, ở đây thì không).
16
+ //
17
+ // Contract: run({ page, context, payload, log }) → { profile_mode:
18
+ // 'page'|'personal'|'unknown', why, timeline_label, nav_sample, duration_sec }.
19
+ // Tài khoản chết (đăng xuất / checkpoint / khoá) ném lỗi có mã FB_ACCOUNT_*
20
+ // như mọi script Facebook khác — API ghi trạng thái tài khoản từ đó.
21
+
22
+ const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
23
+ const { enableFocusEmulation } = require('../lib/playwright-runner');
24
+
25
+ // Facebook vẽ thanh điều hướng + ô "Tạo bài viết" sau khi tải xong khung
26
+ // trang; đọc quá sớm thì mọi tín hiệu đều rỗng → 'unknown'. Đọc lại vài lượt
27
+ // cách nhau vài giây trước khi chịu là không đọc ra.
28
+ const READ_ATTEMPTS = 3;
29
+ const READ_GAP_MS = 3000;
30
+
31
+ async function openOwnTab(page, context, log) {
32
+ if (!context || typeof context.newPage !== 'function') return { tab: page, own: false };
33
+ try {
34
+ return { tab: await context.newPage(), own: true };
35
+ } catch (e) {
36
+ log('warn', `[fb-page-check] không mở được tab riêng, dùng tab sẵn có (${String(e?.message || e).slice(0, 80)})`);
37
+ return { tab: page, own: false };
38
+ }
39
+ }
40
+
41
+ async function run({ page, context, payload = {}, log = () => {} }) {
42
+ const t0 = Date.now();
43
+ const { tab, own } = await openOwnTab(page, context, log);
44
+ // Tab mới mở trong một browser người đang xem là tab NỀN — Chrome bóp nhịp
45
+ // vẽ của nó, Facebook có thể chưa dựng xong nav khi ta đọc.
46
+ const focus = own ? await enableFocusEmulation(tab, log) : null;
47
+ try {
48
+ await tab.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
49
+ await tab.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
50
+ await tab.waitForTimeout(3000);
51
+ await assertAccountUsable(tab, log);
52
+
53
+ let mode = await readProfileMode(tab);
54
+ for (let i = 1; i < READ_ATTEMPTS && mode.mode === 'unknown'; i++) {
55
+ await tab.waitForTimeout(READ_GAP_MS);
56
+ mode = await readProfileMode(tab);
57
+ }
58
+ const durationSec = Math.round((Date.now() - t0) / 1000);
59
+ log('info', `[fb-page-check] ${mode.why}${mode.timelineLabel ? ` timeline="${mode.timelineLabel}"` : ''}`
60
+ + ` nav="${mode.navSample}" (${durationSec}s, kênh ${payload.channel_id || '?'})`);
61
+ return {
62
+ profile_mode: mode.mode,
63
+ why: mode.why,
64
+ timeline_label: mode.timelineLabel || '',
65
+ nav_sample: mode.navSample || '',
66
+ duration_sec: durationSec,
67
+ };
68
+ } finally {
69
+ if (focus) await focus.detach().catch(() => {});
70
+ if (own) await tab.close().catch(() => {});
71
+ }
72
+ }
73
+
74
+ module.exports = { run, attachIfRunning: true };
@@ -4691,6 +4691,10 @@ async function runOnce({ page, payload, log }) {
4691
4691
  reels_before: reelIdsBefore ? reelIdsBefore.slice(0, 300) : undefined,
4692
4692
  reels_tab_url: reelsTabUrlBefore || reelsTabUrl || undefined,
4693
4693
  video_path_local: videoPath,
4694
+ // Danh tính đọc lúc mở trang (bước identity check ở trên). 'page' → API
4695
+ // ghi cờ Page cho kênh, cờ cũ `personal`/`unknown` tự lành mà không phải
4696
+ // chờ phiên nuôi (xem api services/FacebookPublishGuard).
4697
+ profile_mode: profileMode.mode,
4694
4698
  };
4695
4699
  } finally {
4696
4700
  safeUnlink(videoPath);