channel-worker 2.5.106 → 2.5.108

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.
@@ -200,10 +200,17 @@ class CommandPoller {
200
200
  let conn;
201
201
  try {
202
202
  conn = await this._connectNstProfileByName(profileName);
203
+ // Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
204
+ // `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
205
+ // cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
206
+ const keyword = String(payload.keyword || '').trim();
203
207
  const res = await scraper.scrapeOffers(conn.context, {
204
- category: payload.category || 'women_clothes', // Tier-1 default: quần áo nữ
205
- match_id: payload.match_id || null,
206
- sort_types: payload.sort_types || [2, 3], // commission + sales, merged
208
+ keyword: keyword || null,
209
+ category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
210
+ match_id: keyword ? null : (payload.match_id || null),
211
+ // Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
212
+ // (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
213
+ sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
207
214
  pages: payload.pages ?? 2,
208
215
  page_limit: payload.page_limit ?? 20,
209
216
  delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
@@ -217,9 +224,24 @@ class CommandPoller {
217
224
  await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
218
225
  return;
219
226
  }
220
- console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}`);
227
+ console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
221
228
  await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
222
- await this.api.updateCommand(command._id, { status: 'done', result: { count: res.products.length, raw: res.raw_count } });
229
+ // Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
230
+ // 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
231
+ // thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
232
+ // Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
233
+ const slim = keyword
234
+ ? res.products.slice(0, 20).map((r) => ({
235
+ shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
236
+ commission_rate: r.commission_rate, commission_value: r.commission_value,
237
+ sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
238
+ product_url: r.product_url, category_group: r.category_group,
239
+ }))
240
+ : undefined;
241
+ await this.api.updateCommand(command._id, {
242
+ status: 'done',
243
+ result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
244
+ });
223
245
  } catch (err) {
224
246
  console.error(`[shopee] scrape failed: ${err.message}`);
225
247
  await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
@@ -246,6 +246,14 @@ function applyFilters(rows, f = {}) {
246
246
  async function scrapeOffers(ctx, {
247
247
  category = null, // key in AFFILIATE_CATEGORIES (e.g. 'women_clothes')
248
248
  match_id = null, // explicit category id override
249
+ // Tìm theo TỪ KHOÁ thay vì duyệt tab ngành hàng. Cùng endpoint, khác tham số:
250
+ // `list_type=0&keyword=<kw>&filter_types=2` (đo 09/09/2026 — ghi chú cũ bảo
251
+ // "cần signed header" là sai/lạc hậu).
252
+ //
253
+ // Đây là thứ gỡ bế tắc kho bão hoà: 6 tab ngành hàng luôn trả về top N giống
254
+ // nhau nên kho đứng yên ở 34 sản phẩm. Từ khoá thì mở ra đúng món hợp với
255
+ // từng video.
256
+ keyword = null,
249
257
  sort_types = [2], // [2]=commission; pass [2,3] to merge commission+sales
250
258
  pages = 2,
251
259
  page_limit = 20,
@@ -256,6 +264,18 @@ async function scrapeOffers(ctx, {
256
264
  sort_type = null,
257
265
  } = {}) {
258
266
  if (sort_type != null) sort_types = [sort_type];
267
+ const kw = String(keyword || '').trim();
268
+ // ⚠️ Ý NGHĨA CỦA sort_type ĐỔI KHI TÌM THEO TỪ KHOÁ (đo 09/09/2026):
269
+ // 2 = bán chạy ← thứ mình muốn
270
+ // 3 = GIÁ GIẢM DẦN, không phải doanh số (khác hẳn chế độ duyệt tab)
271
+ // 1 = liên quan, trả về toàn hàng bán 0 / 0 sao
272
+ // Mặc định của hàm là [2] nên chế độ từ khoá đã đúng sẵn; nhưng bên gọi quen
273
+ // tay truyền [2,3] như lúc quét tab thì một nửa kết quả là hàng đắt nhất chứ
274
+ // không phải hàng bán chạy. Cắt 3 đi và nói ra, đừng lặng lẽ quét rác.
275
+ if (kw && sort_types.includes(3)) {
276
+ sort_types = sort_types.filter((x) => x !== 3);
277
+ if (!sort_types.length) sort_types = [2];
278
+ }
259
279
  const cat = category && AFFILIATE_CATEGORIES[category] ? AFFILIATE_CATEGORIES[category] : null;
260
280
  const mid = match_id || cat?.match_id || null;
261
281
  // Default the category_groups filter to the category's own group (so a
@@ -285,15 +305,20 @@ async function scrapeOffers(ctx, {
285
305
  for (let i = 0; i < pages; i++) {
286
306
  const offset = i * page_limit;
287
307
  const res = await page.evaluate(async (q) => {
288
- const cat = q.listType
289
- ? `&list_type=${q.listType}`
290
- : (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0');
308
+ // Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
309
+ // thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
310
+ // đang duyệt tab cũ.
311
+ const cat = q.kw
312
+ ? `&list_type=0&keyword=${encodeURIComponent(q.kw)}&filter_types=2`
313
+ : (q.listType
314
+ ? `&list_type=${q.listType}`
315
+ : (q.mid ? `&list_type=3&match_type=2&match_id=${q.mid}` : '&list_type=0'));
291
316
  const url = `/api/v3/offer/product/list?sort_type=${q.st}&page_offset=${q.offset}&page_limit=${q.page_limit}&client_type=1${cat}`;
292
317
  try {
293
318
  const r = await fetch(url, { credentials: 'include' });
294
319
  return { status: r.status, json: await r.json().catch(() => null) };
295
320
  } catch (e) { return { status: 0, error: String(e.message) }; }
296
- }, { st, offset, page_limit, mid, listType });
321
+ }, { st, offset, page_limit, mid, listType, kw });
297
322
 
298
323
  if (isBlockedState(page.url(), res) || res.json?.code === 90309999) {
299
324
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
@@ -314,7 +339,10 @@ async function scrapeOffers(ctx, {
314
339
  }
315
340
 
316
341
  const products = applyFilters([...byId.values()], filters);
317
- return { status: 'ok', products, raw_count: rawCount };
342
+ // `keyword` đi kèm kết quả để bên gọi ghi lại sản phẩm tìm được theo từ
343
+ // khoá nào — không có nó thì kho chỉ biết "có món này", không biết vì sao
344
+ // nó được nhặt về, và lượt chấm điểm hợp-video sau này mất căn cứ.
345
+ return { status: 'ok', products, raw_count: rawCount, keyword: kw || null };
318
346
  } finally {
319
347
  if (opened) await page.close().catch(() => {});
320
348
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.106",
3
+ "version": "2.5.108",
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": {
@@ -38,7 +38,7 @@ function parseViews(txt) {
38
38
  // 09/09 tìm được tab để so trước/sau. Bản riêng còn thiếu cả bộ lọc "anchor
39
39
  // của bài trong bảng tin" (gốc 2 link page lạ sáng 10/09) nên có thể quét
40
40
  // nhầm tab Reels của page KHÁC mà không hay.
41
- const { discoverReelsTabUrl } = require('./upload_facebook').__testables;
41
+ const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles } = require('./upload_facebook').__testables;
42
42
 
43
43
  // Số người theo dõi của page, đọc ngay trên chính trang đang đứng (header hiện
44
44
  // ở mọi tab của page/profile) — không tốn thêm lượt mở profile nào. Ô
@@ -507,17 +507,21 @@ async function run({ page, payload, log }) {
507
507
  const scrolls = Number.isFinite(Number(payload?.scroll)) && Number(payload.scroll) > 0
508
508
  ? Number(payload.scroll)
509
509
  : (mode === 'fresh' ? 2 : 6);
510
- let tabUrl = null;
511
- if (page_url && /sk=reels_tab/.test(page_url)) tabUrl = page_url;
512
- if (!tabUrl) {
510
+ let candidates = [];
511
+ if (page_url && /sk=reels_tab/.test(page_url)) candidates = [page_url];
512
+ if (!candidates.length) {
513
513
  await page.goto(page_url || 'https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 45_000 });
514
514
  await page.waitForTimeout(4000);
515
- tabUrl = await discoverReelsTabUrl(page, log); // có log → in mẫu href khi không khớp
515
+ candidates = await discoverReelsTabUrls(page, log); // có log → in mẫu href khi không khớp
516
516
  }
517
- if (!tabUrl) throw new Error('không tìm được link tab Reels của page');
518
- log('info', `[fb-stats] ${mode} · reels_tab: ${tabUrl.slice(0, 100)}`);
519
- await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 });
520
- await page.waitForTimeout(5000);
517
+ if (!candidates.length) throw new Error('không tìm được link tab Reels của page');
518
+ // Thử lần lượt tới khi thấy tile: cùng một page có hai dạng URL mà chỉ một
519
+ // dạng render tab Reels. Ca "Chuyện Nghề Nhà Nông" 14/09 — dạng
520
+ // profile.php?id=… ra 0 tile suốt 4 lượt quét (kênh mất trắng số liệu view,
521
+ // và bộ đối chiếu xuôi cũng mù theo), dạng /chuyennghenhanong/ ra 17 tile.
522
+ const opened = await openReelsTabWithTiles(page, candidates, { log, settleMs: 5000, tag: '[fb-stats]' });
523
+ log('info', `[fb-stats] ${mode} · reels_tab: ${String(opened.url || '').slice(0, 100)}`
524
+ + (candidates.length > 1 ? ` (ứng viên ${opened.tried}/${candidates.length})` : ''));
521
525
 
522
526
  // Follower chỉ đọc ở lượt nền: lượt bám bài chạy 2-3 lần/ngày mà con số này
523
527
  // gần như không đổi trong ngày. Đọc hỏng thì bỏ qua — KHÔNG được làm hỏng
@@ -560,4 +564,4 @@ async function run({ page, payload, log }) {
560
564
  return { ok: true, mode, tiles: tiles.length, written: d?.written || 0, matched: d?.matched || 0, rising: d?.rising || 0, alerts: d?.alerts || 0, followers };
561
565
  }
562
566
 
563
- module.exports = { run, __testables: { parseViews, scrapeFollowers, parseExact, parseVnPostTime, runInsights, dumpInsightsFailure, discoverReelsTabUrl } };
567
+ module.exports = { run, __testables: { parseViews, scrapeFollowers, parseExact, parseVnPostTime, runInsights, dumpInsightsFailure, discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles } };
@@ -803,10 +803,10 @@ async function confirmPublishDialog({ firstHit, tagConfirmButton, clickTagged, c
803
803
  return { confirmed: !pending };
804
804
  }
805
805
 
806
- // Link tab Reels của Page dùng CẢ HAI đầu lượt đăng (chụp ảnh nền trước
807
- // khi đăng, quét lại sau khi đăng). Ba chiến lược, giữ nguyên từ bản cũ.
808
- async function discoverReelsTabUrl(page, log = null) {
809
- const anchors = await page.evaluate(() => {
806
+ // Mọi anchor trên trang hiện tại (href + aria + nằm trong bảng tin không)
807
+ // nguyên liệu cho pickReelsTabUrls. Tách riêng để bộ chạy một lượt evaluate.
808
+ async function scrapePageAnchors(page) {
809
+ return page.evaluate(() => {
810
810
  const out = [];
811
811
  for (const a of document.querySelectorAll('a[href]')) {
812
812
  const href = a.getAttribute('href') || '';
@@ -819,13 +819,47 @@ async function discoverReelsTabUrl(page, log = null) {
819
819
  }
820
820
  return out;
821
821
  }).catch(() => []);
822
- const url = pickReelsTabUrl(anchors);
823
- if (!url && log) {
822
+ }
823
+
824
+ // MỌI ứng viên URL tab Reels của page, theo thứ tự ưu tiên. Trả danh sách chứ
825
+ // không phải một cái, vì cùng một page có thể có HAI dạng URL mà chỉ MỘT dạng
826
+ // render tile — ca "Chuyện Nghề Nhà Nông" 14/09: `/profile.php?id=615922598169
827
+ // 21&sk=reels_tab` ra 0 tile ở cả 4 lượt quét liên tiếp (kênh mù hẳn, mất luôn
828
+ // số liệu view), trong khi `/chuyennghenhanong/?sk=reels_tab` ra 17 tile. Trước
829
+ // đó nó vẫn chạy đúng bằng dạng handle; anchor trên trang đổi là dạng
830
+ // profile.php thắng chiến lược 2 và cả bộ đo lẫn bộ lấy link cùng mù.
831
+ async function discoverReelsTabUrls(page, log = null) {
832
+ const anchors = await scrapePageAnchors(page);
833
+ const urls = pickReelsTabUrls(anchors);
834
+ if (!urls.length && log) {
824
835
  // Chẩn đoán: in vài href "giống của Page" để lần sau thêm đúng chiến lược.
825
836
  const sample = anchors.map((a) => a.href).filter((h) => /^\/(?:profile\.php|\d{8,}|[A-Za-z0-9._-]{3,}\/?(?:\?|$))/.test(h)).slice(0, 8);
826
837
  log('info', `[fb-pw] tìm tab Reels: ${anchors.length} anchor, không khớp chiến lược nào; mẫu: ${JSON.stringify(sample)}`);
827
838
  }
828
- return url;
839
+ return urls;
840
+ }
841
+ async function discoverReelsTabUrl(page, log = null) {
842
+ return (await discoverReelsTabUrls(page, log))[0] || null;
843
+ }
844
+
845
+ // Mở tab Reels: thử lần lượt từng ứng viên tới khi thấy tile. `verified` = URL
846
+ // này CHẮC CHẮN render được tab Reels của page — bằng chứng mạnh hơn "dò được
847
+ // một link trông giống", nên lượt lấy link sau khi đăng ưu tiên nó.
848
+ // Page thật sự chưa có reel nào cũng ra verified=false; khi đó ids rỗng là
849
+ // đúng, không phải hỏng.
850
+ async function openReelsTabWithTiles(page, urls, { log = null, settleMs = 4000, wait = null, tag = '[fb-pw]' } = {}) {
851
+ const list = [...new Set((urls || []).filter(Boolean))];
852
+ let lastUrl = null;
853
+ for (let i = 0; i < list.length; i++) {
854
+ const url = list[i];
855
+ lastUrl = url;
856
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
857
+ if (wait) await wait(page, settleMs); else await page.waitForTimeout(settleMs);
858
+ const ids = await scrapeReelTileIds(page);
859
+ if (ids.length) return { url, ids, verified: true, tried: i + 1 };
860
+ if (log && i + 1 < list.length) log('warn', `${tag} tab Reels ${url.slice(0, 80)} không có tile nào — thử ứng viên kế (${i + 2}/${list.length})`);
861
+ }
862
+ return { url: lastUrl, ids: [], verified: false, tried: list.length };
829
863
  }
830
864
 
831
865
  // Mọi reel ID đang hiện trên tab Reels, theo thứ tự DOM (mới nhất trước).
@@ -861,7 +895,11 @@ function diffNewReelIds(beforeIds, afterIds) {
861
895
  // "no reels_tab link found") — trang đó không có link tab Reels, dò lại là
862
896
  // trượt và cả so trước/sau lẫn lượt chờ 75s đều bị bỏ → bài lên thật mà thành
863
897
  // "mập mờ" (Makeup tại nhà 2 13/09 18:07, thich-lo-mo 12/09). Thuần, test được.
864
- function pickPostPublishReelsTabUrl({ discovered, remembered }) {
898
+ function pickPostPublishReelsTabUrl({ discovered, remembered, rememberedVerified = false }) {
899
+ // URL đã CHỨNG MINH có tile lúc chụp ảnh nền thắng cả link dò được trên trang
900
+ // hiện tại: cùng một page có hai dạng URL mà chỉ một dạng render tab Reels
901
+ // (xem discoverReelsTabUrls), và dò lại sau khi đăng dễ bốc trúng dạng hỏng.
902
+ if (rememberedVerified && remembered) return { url: remembered, via: 'snapshot-verified' };
865
903
  if (discovered) return { url: discovered, via: 'page' };
866
904
  if (remembered) return { url: remembered, via: 'snapshot' };
867
905
  return { url: null, via: null };
@@ -892,7 +930,9 @@ function isComposerDialogSignature(text) {
892
930
  // chụp ảnh nền trước khi đăng) không có anchor profile.php/Dòng thời gian —
893
931
  // 2/2 lượt "không tìm được link tab Reels trước khi đăng". Trang chủ của Page
894
932
  // có shortcut bên trái trỏ /<id số>/ hoặc /<handle>/, và đôi khi /<id>/reels/.
895
- function pickReelsTabUrl(anchors) {
933
+ function pickReelsTabUrls(anchors) {
934
+ const out = [];
935
+ const add = (u) => { if (u && !out.includes(u)) out.push(u); };
896
936
  const list = (anchors || [])
897
937
  .map((a) => ({ href: String(a.href || ''), aria: String(a.aria || ''), inFeed: !!a.inFeed }))
898
938
  // ⛔ LOẠI anchor CỦA BÀI TRONG BẢNG TIN — đây là gốc của 2 link sai sáng
@@ -905,27 +945,31 @@ function pickReelsTabUrl(anchors) {
905
945
  // (tracking của một bài cụ thể) hoặc anchor nằm trong vùng feed/article.
906
946
  .filter((a) => !a.inFeed && !/[?&]__cft__|[?&]__tn__/.test(a.href));
907
947
  const abs = (h) => (h.startsWith('http') ? h : `https://www.facebook.com${h}`);
908
- for (const a of list) if (/sk=reels_tab/.test(a.href)) return abs(a.href);
948
+ for (const a of list) if (/sk=reels_tab/.test(a.href)) add(abs(a.href));
909
949
  for (const a of list) {
910
950
  const m = a.href.match(/\/profile\.php\?id=(\d+)/);
911
- if (m) return `https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`;
951
+ if (m) add(`https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`);
912
952
  }
913
953
  for (const a of list) {
914
954
  if (!/Dòng thời gian|Timeline/i.test(a.aria)) continue;
915
955
  const m = a.href.match(/^(?:https?:\/\/(?:www\.)?facebook\.com)?\/([A-Za-z0-9._-]{3,})\/?(?:\?|$)/);
916
- if (m && !['profile.php', 'reel', 'video', 'videos', 'reels'].includes(m[1])) return `https://www.facebook.com/${m[1]}/?sk=reels_tab`;
956
+ if (m && !['profile.php', 'reel', 'video', 'videos', 'reels'].includes(m[1])) add(`https://www.facebook.com/${m[1]}/?sk=reels_tab`);
917
957
  }
918
958
  // 4. /<id số>/reels/ hoặc /<id số>/ — shortcut của Page trên trang chủ.
919
959
  for (const a of list) {
920
960
  const m = a.href.match(/^(?:https?:\/\/(?:www\.)?facebook\.com)?\/(\d{10,20})(?:\/reels)?\/?(?:\?|$)/);
921
- if (m) return `https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`;
961
+ if (m) add(`https://www.facebook.com/profile.php?id=${m[1]}&sk=reels_tab`);
922
962
  }
923
963
  // 5. /<handle>/reels/ — Page có tên riêng.
924
964
  for (const a of list) {
925
965
  const m = a.href.match(/^(?:https?:\/\/(?:www\.)?facebook\.com)?\/([A-Za-z0-9._-]{3,})\/reels\/?(?:\?|$)/);
926
- if (m && !['profile.php', 'reel', 'video', 'videos'].includes(m[1])) return `https://www.facebook.com/${m[1]}/?sk=reels_tab`;
966
+ if (m && !['profile.php', 'reel', 'video', 'videos'].includes(m[1])) add(`https://www.facebook.com/${m[1]}/?sk=reels_tab`);
927
967
  }
928
- return null;
968
+ return out;
969
+ }
970
+ // Ứng viên đầu bảng (giữ cho chỗ gọi cũ / test cũ).
971
+ function pickReelsTabUrl(anchors) {
972
+ return pickReelsTabUrls(anchors)[0] || null;
929
973
  }
930
974
 
931
975
  async function hasVisibleReelComposer(page) {
@@ -960,7 +1004,7 @@ async function runOnce({ page, payload, log }) {
960
1004
  } = payload || {};
961
1005
  if (!video_url) throw new Error('No video_url provided');
962
1006
 
963
- log('info', '[fb-pw] selectors version=2026.09.13-reels-tab-fallback');
1007
+ log('info', '[fb-pw] selectors version=2026.09.14-reels-tab-candidates');
964
1008
 
965
1009
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
966
1010
 
@@ -1070,14 +1114,16 @@ async function runOnce({ page, payload, log }) {
1070
1114
  let reelIdsBefore = null;
1071
1115
  // GIỮ LẠI URL tab Reels cho sau khi đăng — xem pickPostPublishReelsTabUrl.
1072
1116
  let reelsTabUrlBefore = null;
1117
+ let reelsTabVerifiedBefore = false;
1073
1118
  try {
1074
- const tabUrl = await discoverReelsTabUrl(page, log);
1075
- if (tabUrl) {
1076
- reelsTabUrlBefore = tabUrl;
1077
- await page.goto(tabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 });
1078
- await pause(page, 3500);
1079
- reelIdsBefore = await scrapeReelTileIds(page);
1080
- log('info', `[fb-pw] ảnh nền tab Reels trước khi đăng: ${reelIdsBefore.length} reel`);
1119
+ const candidates = await discoverReelsTabUrls(page, log);
1120
+ if (candidates.length) {
1121
+ const opened = await openReelsTabWithTiles(page, candidates, { log, settleMs: 3500, wait: pause });
1122
+ reelsTabUrlBefore = opened.url;
1123
+ reelsTabVerifiedBefore = opened.verified;
1124
+ reelIdsBefore = opened.ids;
1125
+ log('info', `[fb-pw] ảnh nền tab Reels trước khi đăng: ${reelIdsBefore.length} reel`
1126
+ + (candidates.length > 1 ? ` (ứng viên ${opened.tried}/${candidates.length})` : ''));
1081
1127
  await navigateFacebookHome(page, log);
1082
1128
  await pause(page, 2500);
1083
1129
  } else {
@@ -3050,10 +3096,13 @@ async function runOnce({ page, payload, log }) {
3050
3096
  const picked = pickPostPublishReelsTabUrl({
3051
3097
  discovered: await discoverReelsTabUrl(page),
3052
3098
  remembered: reelsTabUrlBefore,
3099
+ rememberedVerified: reelsTabVerifiedBefore,
3053
3100
  });
3054
3101
  const reelsTabHref = picked.url;
3055
3102
  if (picked.via === 'snapshot') {
3056
3103
  log('info', `[fb-pw] trang hiện tại không có link tab Reels (${String(page.url() || '').slice(0, 70)}) — dùng URL đã biết từ lúc chụp ảnh nền`);
3104
+ } else if (picked.via === 'snapshot-verified') {
3105
+ log('info', '[fb-pw] dùng URL tab Reels ĐÃ chứng minh có tile lúc chụp ảnh nền (dạng URL kia của cùng page có thể ra 0 tile)');
3057
3106
  }
3058
3107
  if (reelsTabHref) {
3059
3108
  reelsTabUrl = reelsTabHref;
@@ -3381,6 +3430,9 @@ module.exports.__testables = {
3381
3430
  diffNewReelIds,
3382
3431
  pickPostPublishReelsTabUrl,
3383
3432
  pickReelsTabUrl,
3433
+ pickReelsTabUrls,
3434
+ discoverReelsTabUrls,
3435
+ openReelsTabWithTiles,
3384
3436
  networkIdVerdict,
3385
3437
  classifyComposerText,
3386
3438
  readPageAlerts,