channel-worker 2.5.101 → 2.5.103

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.
@@ -173,11 +173,39 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
173
173
  // or logged out — in either case the offer API returns 404/empty and we must
174
174
  // STOP (not keep hammering) and ask the operator to re-verify via VNC.
175
175
  function isBlockedState(pageUrl, res) {
176
- if (/\/verify\/captcha|\/buyer\/login|is_from_login/.test(pageUrl || '')) return true;
177
176
  if (res && (res.status === 403 || res.status === 404)) return true;
177
+ let u;
178
+ try { u = new URL(pageUrl || ''); } catch { return false; }
179
+ // CHỈ xét path + query của CHÍNH trang này. Regex quét cả chuỗi URL đọc nhầm
180
+ // tham số `next=` của trang khác — xem ghi chú ở findAffiliatePage.
181
+ if (/^\/(verify\/captcha|buyer\/login)/.test(u.pathname)) return true;
182
+ if (u.searchParams.has('is_from_login')) return true;
178
183
  return false;
179
184
  }
180
185
 
186
+ // Tab affiliate nào đang mở — so bằng HOSTNAME, không bằng chuỗi con.
187
+ //
188
+ // 11/09/2026, đo trên profile Shopee1 thật: tab captcha cũ
189
+ // `shopee.vn/verify/captcha?…&next=…%252Faffiliate.shopee.vn%252Foffer…`
190
+ // (mã hoá hai lần) chứa nguyên chuỗi "affiliate.shopee.vn", nên
191
+ // `pages().find(p => p.url().includes('affiliate.shopee.vn'))` vớ trúng ĐÚNG
192
+ // cái tab hỏng đó — nó nằm ở vị trí 0 trong 16 tab. isBlockedState đọc
193
+ // `/verify/captcha` rồi kết luận "session ở captcha", trong khi 15 tab còn lại
194
+ // đang đăng nhập bình thường (dashboard, conversion_report, offer). Chủ tịch mở
195
+ // profile thấy vẫn login mà máy một mực đòi xác minh — hai bên nhìn hai tab
196
+ // khác nhau.
197
+ //
198
+ // Bỏ QUA tab đang ở trạng thái chặn khi chọn: phiên chết thật thì không tab nào
199
+ // sống sót, hàm trả null và bên gọi mở tab mới — lượt goto sau đó vẫn bị đá về
200
+ // captcha nên vẫn báo needs_verify đúng như cũ.
201
+ function urlPath(u) { try { return new URL(u).pathname; } catch { return ''; } }
202
+ function isAffiliateUrl(u) { try { return new URL(u).hostname === 'affiliate.shopee.vn'; } catch { return false; } }
203
+
204
+ function findAffiliatePage(pages) {
205
+ const live = (pages || []).filter((p) => isAffiliateUrl(p.url()) && !isBlockedState(p.url(), null));
206
+ return live.find((p) => urlPath(p.url()).startsWith('/offer')) || live[0] || null;
207
+ }
208
+
181
209
  // Apply the Flow-1 hard filters (criteria) to parsed rows. Defaults are lenient;
182
210
  // the controller passes the agreed thresholds (commission/price/sold/rating/imgs).
183
211
  function applyFilters(rows, f = {}) {
@@ -237,11 +265,11 @@ async function scrapeOffers(ctx, {
237
265
  const listType = cat?.list_type || null;
238
266
 
239
267
  const pagesOpen = ctx.pages();
240
- let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
268
+ let page = findAffiliatePage(pagesOpen);
241
269
  let opened = false;
242
270
  if (!page) { page = await ctx.newPage(); opened = true; }
243
271
  try {
244
- if (!page.url().includes('affiliate.shopee.vn/offer')) {
272
+ if (!urlPath(page.url()).startsWith('/offer')) {
245
273
  // 'networkidle' (playwright), KHÔNG 'networkidle2' (tên của puppeteer —
246
274
  // playwright coi là giá trị lạ và ném lỗi).
247
275
  await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
@@ -326,11 +354,11 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
326
354
  for (let i = 0; i < 5; i++) subs[`subId${i + 1}`] = cleanSubId(subIds[i]);
327
355
 
328
356
  const pagesOpen = ctx.pages();
329
- let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
357
+ let page = findAffiliatePage(pagesOpen);
330
358
  let opened = false;
331
359
  if (!page) { page = await ctx.newPage(); opened = true; }
332
360
  try {
333
- if (!page.url().includes('affiliate.shopee.vn')) {
361
+ if (!isAffiliateUrl(page.url())) {
334
362
  await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
335
363
  await sleep(1500);
336
364
  }
@@ -376,11 +404,13 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
376
404
  // Parse a Shopee product URL → { shop_id, item_id }. Accepts
377
405
  // /product/<shop>/<item> and i.<shop>.<item> forms.
378
406
  function parseProductUrl(url = '') {
379
- let m = url.match(/\/product\/(\d+)\/(\d+)/);
407
+ // `/opaanlp/` đích của link affiliate (đo 12/09: link rút gọn s.shopee.vn
408
+ // đá về đúng dạng này) — vẫn là một sản phẩm, vẫn lấy được shop/item.
409
+ let m = url.match(/\/(?:product|opaanlp)\/(\d+)\/(\d+)/);
380
410
  if (m) return { shop_id: m[1], item_id: m[2] };
381
411
  m = url.match(/i\.(\d+)\.(\d+)/);
382
412
  if (m) return { shop_id: m[1], item_id: m[2] };
383
413
  return null;
384
414
  }
385
415
 
386
- module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
416
+ module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, findAffiliatePage };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.101",
3
+ "version": "2.5.103",
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": {
@@ -497,7 +497,7 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
497
497
  absentGraceMs = PUBLISH_CTA_ABSENT_GRACE_MS,
498
498
  } = {}) {
499
499
  const deadline = Date.now() + timeoutMs;
500
- let announced = false, sawPresent = false, absentSince = 0;
500
+ let announced = false, sawPresent = false, absentSince = 0, stuckSince = 0;
501
501
  while (Date.now() < deadline) {
502
502
  // The final metadata form mounts lazily on some FB cohorts. The first
503
503
  // fillMetadata() immediately after Tiếp can run before its textbox exists,
@@ -510,7 +510,7 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
510
510
  const dlgs = document.querySelectorAll("[role='dialog']");
511
511
  const roots = dlgs.length ? Array.from(dlgs) : [document];
512
512
  const vh = window.innerHeight;
513
- let present = false, enabled = false, rejected = '';
513
+ let present = false, enabled = false, rejected = '', text = '';
514
514
  for (const root of roots) {
515
515
  for (const el of root.querySelectorAll("button, [role='button']")) {
516
516
  const t = (el.innerText || el.textContent || '').trim();
@@ -532,9 +532,22 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
532
532
  const txt = (root.innerText || '').slice(0, 4000);
533
533
  const m = /Không thể tải (?:file|tệp)[^\n]{0,120}|couldn['’]t upload your (?:file|video)[^\n]{0,80}|Unable to upload[^\n]{0,80}/i.exec(txt);
534
534
  if (m) rejected = m[0].trim();
535
+ text += txt + '\n';
535
536
  }
536
- return { present, enabled, rejected };
537
- }, verbs).catch(() => ({ present: false, enabled: false, rejected: '' }));
537
+ return { present, enabled, rejected, text: text.slice(0, 6000) };
538
+ }, verbs).catch(() => ({ present: false, enabled: false, rejected: '', text: '' }));
539
+ // Bảng cài đặt không tải (tooltip "Vui lòng đợi tải xong quyền riêng tư…"):
540
+ // Đăng sẽ xám mãi, đứng đủ 600s là đốt profile vô ích. Thấy liên tục quá
541
+ // SETTINGS_STUCK_MS thì trả về để call site mở lại composer một lượt.
542
+ if (classifyComposerText(st.text).settingsStuck) {
543
+ if (!stuckSince) stuckSince = Date.now();
544
+ if (Date.now() - stuckSince >= SETTINGS_STUCK_MS) {
545
+ log('warn', `[${tag}] bảng cài đặt thước phim không tải quá ${Math.round(SETTINGS_STUCK_MS / 1000)}s (tooltip "đợi tải xong quyền riêng tư") — không chờ hết ${Math.round(timeoutMs / 1000)}s`);
546
+ return { enabled: false, sawPresent, settingsStuck: true };
547
+ }
548
+ } else {
549
+ stuckSince = 0;
550
+ }
538
551
  if (st.rejected) {
539
552
  log('warn', `[${tag}] Facebook TỪ CHỐI file: "${st.rejected.slice(0, 120)}" — nút "Đăng" sẽ không bật, dừng chờ ngay`);
540
553
  return { enabled: false, sawPresent: true, rejected: st.rejected };
@@ -624,6 +637,51 @@ async function isCustomThumbApplied(page) {
624
637
  }).catch(() => false);
625
638
  }
626
639
 
640
+ // Composer đang ở trạng thái gì? Đọc từ CHỮ trong hộp thoại — ba chữ ký nhìn
641
+ // được từ ảnh dump 10-11/09 (5 ca "KHÔNG commit" + 2 ca no-advance-3):
642
+ // rightsCheck : dòng đỏ "Không hoàn tất được quy trình kiểm tra bản quyền."
643
+ // trên nút Đăng → FB không commit, bấm mù 3 lần vô ích; ~20'
644
+ // sau đăng lại là được (Chuyện Nhà Nông 07:04, Làm Giàu Từ
645
+ // Chăn Nuôi 07:16 ngày 11/09).
646
+ // publishing : lớp phủ "Đang đăng" trên video → FB ĐANG đăng; bản cũ chỉ
647
+ // chờ 3×8s rồi phán "CHƯA được đăng" → lịch đăng lại → nguy
648
+ // cơ đúp (Bí Quyết Nhà Nông 20:41, Nông Nghiệp Thực Tế 17:54
649
+ // ngày 10/09 — may là lượt đó không hoàn tất).
650
+ // settingsStuck : bảng cài đặt bên trái trống + tooltip "Vui lòng đợi tải
651
+ // xong quyền riêng tư của Thước phim" → Đăng xám mãi; đứng
652
+ // 600s rồi "description field not found" (phụ kiện điện tử
653
+ // 02 10/09, kol du lịch 11/09).
654
+ // Thuần, test được; regex neo đầu dòng cho "Đang đăng" để không dính mô tả
655
+ // bài có chữ "đang đăng" giữa câu.
656
+ const COMPOSER_TEXT_RE = {
657
+ rightsCheck: /Không hoàn tất được quy trình kiểm tra bản quyền|couldn['’]t complete (?:the )?(?:rights|copyright) check/i,
658
+ publishing: /(?:^|\n)\s*(?:Đang đăng|Đang xuất bản|Publishing|Posting)\s*(?:\.{3}|…)?\s*(?:\n|$)/i,
659
+ settingsStuck: /Vui lòng đợi tải xong quyền riêng tư của Thước phim|wait (?:for|until) (?:the )?reel(?:'s)? privacy/i,
660
+ };
661
+ function classifyComposerText(txt) {
662
+ const t = String(txt || '');
663
+ return {
664
+ rightsCheck: COMPOSER_TEXT_RE.rightsCheck.test(t),
665
+ publishing: COMPOSER_TEXT_RE.publishing.test(t),
666
+ settingsStuck: COMPOSER_TEXT_RE.settingsStuck.test(t),
667
+ };
668
+ }
669
+ async function readComposerText(page) {
670
+ return page.evaluate(() => {
671
+ const out = [];
672
+ for (const dlg of document.querySelectorAll("[role='dialog']")) {
673
+ const r = dlg.getBoundingClientRect();
674
+ if (r.width < 8 || r.height < 8) continue;
675
+ const cs = getComputedStyle(dlg);
676
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
677
+ out.push(dlg.innerText || '');
678
+ }
679
+ return out.join('\n').slice(0, 8000);
680
+ }).catch(() => '');
681
+ }
682
+ const PUBLISHING_WAIT_MS = 5 * 60 * 1000; // "Đang đăng": chờ tới 5 phút, không bấm lại
683
+ const SETTINGS_STUCK_MS = 90 * 1000; // bảng cài đặt trống quá 90s: mở lại composer
684
+
627
685
  const SAFE_RETRY_COMPOSER_CLOSED = 'FB_SAFE_RETRY_COMPOSER_CLOSED';
628
686
 
629
687
  function safeComposerRetryError(message) {
@@ -2653,6 +2711,14 @@ async function runOnce({ page, payload, log }) {
2653
2711
  // đăng nhập), và PublishRetry phân loại đúng "file bị từ chối".
2654
2712
  throw new Error(`FB_PUBLISH_REJECTED: Facebook từ chối file video ("${waitResult.rejected.slice(0, 100)}") — thường gặp với file >50 MB đi đường CDP; nén/render lại nhỏ hơn rồi đăng`);
2655
2713
  }
2714
+ // Bảng cài đặt không tải → đóng composer, chạy lại cả lượt upload
2715
+ // TRONG PHIÊN (đường safe-retry sẵn có: bắt đầu lại từ trang chủ nên
2716
+ // hộp thoại cũ tự rụng). Chưa bấm Đăng nên không thể đúp. Lượt hai
2717
+ // vẫn kẹt thì ném ra, PublishRetry xếp phiên khác (luật settings_panel_stuck).
2718
+ if (waitResult.settingsStuck) {
2719
+ await page.keyboard.press('Escape').catch(() => {});
2720
+ throw safeComposerRetryError(`FB_SETTINGS_PANEL_STUCK: bảng cài đặt thước phim không tải sau ${Math.round(SETTINGS_STUCK_MS / 1000)}s (tooltip "đợi tải xong quyền riêng tư") — mở lại composer`);
2721
+ }
2656
2722
  // Composer biến mất (URL nhảy sang /reel/… vì toast cướp click, hoặc
2657
2723
  // FB tự đóng) → CHẠY LẠI cả lượt upload thay vì báo lỗi khó hiểu.
2658
2724
  // An toàn vì chưa hề bấm Đăng ở nhánh này.
@@ -2803,6 +2869,29 @@ async function runOnce({ page, payload, log }) {
2803
2869
  }, publishVerbs).catch(() => false);
2804
2870
 
2805
2871
  for (let commitTry = 0; commitTry < 3 && (await composerStillOpen()); commitTry++) {
2872
+ // Trước khi bấm lại, ĐỌC composer đang nói gì (classifyComposerText):
2873
+ const state = classifyComposerText(await readComposerText(page));
2874
+ if (state.rightsCheck) {
2875
+ // Dòng đỏ "Không hoàn tất được quy trình kiểm tra bản quyền" — FB không
2876
+ // commit, bấm nữa cũng vậy. Báo đúng tên để PublishRetry xếp lại sau.
2877
+ await dumpFailure(page, 'rights-check-incomplete', log);
2878
+ throw new Error('FB_RIGHTS_CHECK_INCOMPLETE: Facebook không hoàn tất được quy trình kiểm tra bản quyền — video CHƯA được đăng, ~20 phút sau đăng lại thường được');
2879
+ }
2880
+ if (state.publishing) {
2881
+ // "Đang đăng": Facebook ĐANG đăng — cấm bấm lại, cấm kết luận "chưa
2882
+ // đăng". Chờ composer tự đóng, tối đa PUBLISHING_WAIT_MS.
2883
+ log('info', `[fb-pw] composer báo "Đang đăng" — Facebook đang đăng, chờ tới ${Math.round(PUBLISHING_WAIT_MS / 1000)}s, KHÔNG bấm lại`);
2884
+ const until = Date.now() + PUBLISHING_WAIT_MS;
2885
+ let still = true;
2886
+ while (Date.now() < until) {
2887
+ await pause(page, 5000);
2888
+ if (!(await composerStillOpen())) { still = false; break; }
2889
+ if (!classifyComposerText(await readComposerText(page)).publishing) { still = false; break; }
2890
+ }
2891
+ if (!still) continue; // vòng for kiểm lại composerStillOpen: đóng rồi thì coi như đã commit
2892
+ await dumpFailure(page, 'publishing-timeout', log);
2893
+ throw new Error(`FB_PUBLISH_IN_PROGRESS: Facebook vẫn "Đang đăng" sau ${Math.round(PUBLISHING_WAIT_MS / 1000)}s — có thể bài ĐÃ lên, kiểm trên Facebook trước khi đăng lại`);
2894
+ }
2806
2895
  log('warn', `[fb-pw] composer STILL open after publish click — re-clicking "Đăng" (retry ${commitTry + 1}/3)`);
2807
2896
  const reClicked = await page.evaluate((verbs) => {
2808
2897
  const dlgs = document.querySelectorAll("[role='dialog']");
@@ -3208,6 +3297,7 @@ module.exports.__testables = {
3208
3297
  diffNewReelIds,
3209
3298
  pickReelsTabUrl,
3210
3299
  networkIdVerdict,
3300
+ classifyComposerText,
3211
3301
  isComposerDialogSignature,
3212
3302
  discoverReelsTabUrl,
3213
3303
  scrapeReelTileIds,