channel-worker 2.5.128 → 2.5.130

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.
@@ -388,8 +388,29 @@ class CommandPoller {
388
388
  return;
389
389
  }
390
390
 
391
+ // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
392
+ // nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
393
+ // profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
394
+ // kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
395
+ let mediaAccounts = null;
396
+ let mediaError = null;
397
+ try {
398
+ const media = await report.fetchSocialMediaAccounts(conn.context, {
399
+ log: (message) => console.log(message),
400
+ });
401
+ if (media.status === 'ok') mediaAccounts = media.accounts;
402
+ else mediaError = media.reason || media.status;
403
+ } catch (err) {
404
+ mediaError = String(err.message || err).slice(0, 200);
405
+ }
406
+ if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
407
+
391
408
  const saved = await this.api.affiliateReportResult(result.rows, {
392
409
  clicks: clickResult.rows,
410
+ // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
411
+ // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
412
+ // chỉ vì một lượt đọc hỏng.
413
+ media_accounts: mediaAccounts,
393
414
  last_report_date: result.last_report_date,
394
415
  last_update_time: result.last_update_time,
395
416
  from: result.from,
@@ -406,6 +427,8 @@ class CommandPoller {
406
427
  click_saved: saved?.click_saved || 0,
407
428
  click_matched: saved?.click_matched || 0,
408
429
  click_unmatched: saved?.click_unmatched || 0,
430
+ media_accounts: mediaAccounts ? mediaAccounts.length : null,
431
+ media_error: mediaError || undefined,
409
432
  last_report_date: result.last_report_date,
410
433
  },
411
434
  });
@@ -183,4 +183,78 @@ async function fetchClickReport(ctx, {
183
183
  }
184
184
  }
185
185
 
186
- module.exports = { fetchConversionReport, fetchClickReport, reportWindow };
186
+ // Danh sách tài khoản mạng xã hội ĐÃ LIÊN KẾT với tài khoản affiliate — cùng
187
+ // thứ trang `account_setting` hiện ở tab "Media Information".
188
+ //
189
+ // KHÔNG mở account_setting và KHÔNG bấm tab: đo thật 22/09/2026 trên phiên
190
+ // Shopee1, endpoint này trả đủ dữ liệu khi gọi từ MỘT tab affiliate bất kỳ
191
+ // (thử từ /dashboard). Đi đường DOM thì phải mở thêm trang, bấm đúng nhãn
192
+ // tiếng Anh "Media Information", và nhãn đó đổi theo ngôn ngữ giao diện.
193
+ //
194
+ // Mỗi dòng có `user_id` là ID Facebook THẬT của page (vd 1298751909986110) —
195
+ // đúng con số nằm trong link `facebook.com/profile.php?id=…`. Nhờ vậy việc so
196
+ // khớp với kênh của mình không phải parse URL, vốn có nhiều biến thể
197
+ // (?referral_source=…, /<handle>/, m.facebook.com…).
198
+ //
199
+ // `platform`: 6 = Facebook, 3 = TikTok (đo được trên tài khoản thật; các mã
200
+ // khác giữ nguyên số để sau này đọc lại, không đoán tên).
201
+ const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
202
+
203
+ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
204
+ let page = findAffiliatePage(ctx.pages());
205
+ let opened = false;
206
+ if (!page) {
207
+ page = await ctx.newPage();
208
+ opened = true;
209
+ }
210
+
211
+ try {
212
+ if (!page.url().includes('affiliate.shopee.vn/')) {
213
+ await page.goto('https://affiliate.shopee.vn/offer/product_offer', {
214
+ waitUntil: 'domcontentloaded', timeout: timeoutMs,
215
+ }).catch(() => {});
216
+ await sleep(1500);
217
+ }
218
+ if (isBlockedState(page.url(), null)) {
219
+ return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
220
+ }
221
+
222
+ const result = await page.evaluate(async () => {
223
+ try {
224
+ const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
225
+ return { status: r.status, json: await r.json().catch(() => null) };
226
+ } catch (e) {
227
+ return { status: 0, error: String(e?.message || e) };
228
+ }
229
+ });
230
+
231
+ if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
232
+ return { status: 'needs_verify', accounts: [], reason: `blocked while reading media info (http ${result.status}, code ${result.json?.code})` };
233
+ }
234
+ if (result.status !== 200 || result.json?.code !== 0) {
235
+ return { status: 'failed', accounts: [], reason: result.json?.msg || result.error || `media info HTTP ${result.status}` };
236
+ }
237
+
238
+ const list = Array.isArray(result.json?.data?.accounts) ? result.json.data.accounts : [];
239
+ const accounts = list.map((row) => ({
240
+ media_user_id: String(row.user_id || row.unique_id || '').trim(),
241
+ media_name: String(row.user_name || '').trim(),
242
+ website_url: String(row.website_url || '').trim(),
243
+ platform_code: Number(row.platform) || 0,
244
+ platform: SM_PLATFORM[Number(row.platform)] || 'other',
245
+ link_status: Number(row.link_status) || 0,
246
+ facebook_page_type: Number(row.facebook_page_type) || 0,
247
+ partner_link_status: Number(row.partner_link_status) || 0,
248
+ follower_number: Number(row.follower_number) || 0,
249
+ // Shopee trả giây, không phải mili-giây.
250
+ verification_time: Number(row.verification_time) || 0,
251
+ })).filter((a) => a.media_user_id || a.website_url);
252
+
253
+ if (log) log(`[shopee-report] media: ${accounts.length} tài khoản liên kết (${accounts.filter((a) => a.platform === 'facebook').length} Facebook)`);
254
+ return { status: 'ok', accounts };
255
+ } finally {
256
+ if (opened) await page.close().catch(() => {});
257
+ }
258
+ }
259
+
260
+ module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.128",
3
+ "version": "2.5.130",
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, discoverReelsTabUrls, openReelsTabWithTiles } = require('./upload_facebook').__testables;
41
+ const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef } = 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. Ô
@@ -583,8 +583,17 @@ async function run({ page, payload, log }) {
583
583
  // Gửi về API bằng token của worker (command-poller gắn log.apiRequest).
584
584
  if (typeof log.apiRequest !== 'function') throw new Error('log.apiRequest không có — cần channel-worker ≥ 2.5.55');
585
585
  // api-client.request trả thẳng `data` (ném lỗi khi success=false).
586
+ // Danh tính page, gỡ từ URL tab Reels vừa CHỨNG MINH có tile (opened.url) —
587
+ // không phải từ `page_url` trong payload hay một anchor dò được. Gửi kèm để
588
+ // API biết kênh này ứng với page Facebook nào; đó là chỗ duy nhất đối chiếu
589
+ // được với danh sách page đã liên kết bên Shopee. Không có thì bỏ qua, lượt
590
+ // ghi số liệu không phụ thuộc vào nó.
591
+ const pageRef = opened.verified ? parsePageRef(opened.url) : null;
592
+ if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
593
+
586
594
  const d = await log.apiRequest('POST', '/analytics/post-stats', {
587
595
  channel_id, platform: 'facebook', mode, at: scannedAt, followers,
596
+ ...(pageRef ? { page_ref: pageRef } : {}),
588
597
  items: items.map(({ post_id, views, views_text }) => ({ post_id, views, views_text })),
589
598
  });
590
599
  log('info', `[fb-stats] đã ghi ${d?.written} điểm đo, khớp idea ${d?.matched}, đang lên ${d?.rising || 0}, chuông ${d?.alerts || 0}`);
@@ -0,0 +1,223 @@
1
+ // A few minutes on the ordinary web BEFORE the platform script opens its
2
+ // platform. Same NST profile, same proxy, same tab — so the history, the cookie
3
+ // jar and the network activity land where the profile can be seen to have them.
4
+ //
5
+ // What this does NOT do: fool Facebook with other sites' cookies. Facebook
6
+ // cannot read them (cross-origin). What it buys is narrower and real:
7
+ // - the profile's history isn't facebook.com and nothing else, which is what
8
+ // a browser looks like when a human has never used it for anything;
9
+ // - the session can arrive at the platform with a `Referer` from a page it
10
+ // actually just read, instead of every single visit being a bare typed URL.
11
+ //
12
+ // Nothing here logs in, submits a form, downloads a file, or touches the
13
+ // platform being nurtured. Every step is best-effort: a dead site, a consent
14
+ // wall we can't dismiss, a nav timeout — all of it is logged and skipped. The
15
+ // caller's session must never fail because a news site was slow.
16
+ //
17
+ // Contract: warmWeb({ page, cfg, log }) → { ran, sites, seconds, last_origin }
18
+ // cfg.warm_prob 0..1 share of sessions that browse at all (default 0.6)
19
+ // cfg.warm_sites string[] URLs to draw from (default DEFAULT_SITES)
20
+ // cfg.warm_sites_min/max how many sites in one detour (default 1..3)
21
+ // cfg.warm_sec_min/max total budget for the whole detour (default 60..150)
22
+ // cfg.warm_click_prob 0..1 chance of opening one article per site (default 0.4)
23
+
24
+ const { pick, clickHandle } = require('./dom-pick');
25
+ const { randInt, chance, humanMove, humanWheel } = require('./human');
26
+
27
+ // Vietnamese sites that need no login, carry real article links, and have
28
+ // nothing to do with the fleet's own work (deliberately NO youtube/tiktok —
29
+ // those overlap the warmup scripts — and NO shopee/tiki, which sit in the
30
+ // affiliate flow and throw captchas at a fresh residential IP).
31
+ const DEFAULT_SITES = [
32
+ 'https://vnexpress.net/',
33
+ 'https://dantri.com.vn/',
34
+ 'https://thanhnien.vn/',
35
+ 'https://kenh14.vn/',
36
+ 'https://vi.wikipedia.org/',
37
+ 'https://vnexpress.net/thoi-tiet',
38
+ ];
39
+
40
+ // Consent / app-install / notification banners. Same shape as the Facebook
41
+ // dismisser, but these sites put the button in plain markup rather than a
42
+ // [role=dialog], so the sweep looks at buttons and links anywhere on the page.
43
+ const CONSENT_VERBS = [
44
+ 'Đồng ý', 'Đồng ý và tiếp tục', 'Chấp nhận', 'Chấp nhận tất cả', 'Tôi đồng ý',
45
+ 'Accept', 'Accept all', 'I agree', 'Got it', 'Đã hiểu', 'Bỏ qua', 'Để sau',
46
+ 'Không, cảm ơn', 'Không cho phép', 'Đóng',
47
+ ];
48
+
49
+ function originOf(url) {
50
+ try { return new URL(url).origin; } catch { return ''; }
51
+ }
52
+
53
+ // Park the cursor mid-viewport before any wheel event: mouse.wheel scrolls
54
+ // whatever sits under the pointer, and the pointer starts at (0,0) — usually a
55
+ // fixed header that doesn't scroll. Same lesson as the Facebook feed.
56
+ async function centerMouse(page) {
57
+ const vp = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })).catch(() => null);
58
+ if (!vp) return;
59
+ const jitter = (n) => n + randInt(-120, 120);
60
+ await humanMove(page, jitter(Math.floor(vp.w / 2)), jitter(Math.floor(vp.h / 2)));
61
+ }
62
+
63
+ // Scroll and verify the page actually moved; fall back to a programmatic
64
+ // scroll. Reading is not a trust-gated action, so a synthetic scroll on a site
65
+ // that fights wheel events is still better than sitting still.
66
+ async function scrollSome(page, dy) {
67
+ const readY = () => page.evaluate(() => window.scrollY || document.documentElement.scrollTop || 0).catch(() => 0);
68
+ const before = await readY();
69
+ await humanWheel(page, dy);
70
+ await page.waitForTimeout(randInt(300, 700));
71
+ let after = await readY();
72
+ if (Math.abs(after - before) < 5) {
73
+ await page.evaluate((d) => window.scrollBy(0, d), dy).catch(() => {});
74
+ await page.waitForTimeout(randInt(250, 600));
75
+ after = await readY();
76
+ }
77
+ return Math.abs(after - before);
78
+ }
79
+
80
+ async function dismissConsent(page) {
81
+ for (let round = 0; round < 2; round++) {
82
+ const { el } = await pick(page, (vs) => {
83
+ for (const b of document.querySelectorAll("button, [role='button'], a")) {
84
+ const t = (b.innerText || '').trim();
85
+ if (!t || t.length > 40) continue;
86
+ const aria = (b.getAttribute('aria-label') || '').trim();
87
+ if (!vs.some((v) => t === v || aria === v)) continue;
88
+ const r = b.getBoundingClientRect();
89
+ if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
90
+ return { el: b };
91
+ }
92
+ return { el: null };
93
+ }, CONSENT_VERBS);
94
+ if (!el) return;
95
+ await clickHandle(page, el, { hoverMs: [250, 800], timeout: 2500, scroll: false });
96
+ await page.waitForTimeout(randInt(500, 1200));
97
+ }
98
+ }
99
+
100
+ // Open one article on the SAME host. Cross-host links are skipped rather than
101
+ // followed: the point is a plausible little visit, not a crawl, and an outbound
102
+ // ad link is the one link on a news page most likely to be a redirect chain.
103
+ // target=_blank is filtered out too — the caller's single-tab guard closes new
104
+ // tabs on arrival, so clicking one would just look like nothing happened.
105
+ async function readOneArticle(page, log) {
106
+ const host = (() => { try { return new URL(page.url()).hostname; } catch { return ''; } })();
107
+ if (!host) return false;
108
+ const urlBefore = page.url();
109
+ const { el } = await pick(page, (h) => {
110
+ const out = [];
111
+ for (const a of document.querySelectorAll('a[href]')) {
112
+ if ((a.getAttribute('target') || '') === '_blank') continue;
113
+ const href = a.getAttribute('href') || '';
114
+ if (/^(#|javascript:|mailto:|tel:)/i.test(href)) continue;
115
+ let u;
116
+ try { u = new URL(a.href, location.href); } catch { continue; }
117
+ if (u.hostname !== h || u.href === location.href) continue;
118
+ // An article link, not nav furniture: a real headline is long, and the
119
+ // path is more than one short segment.
120
+ const text = (a.innerText || '').trim();
121
+ if (text.length < 25) continue;
122
+ if (u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).length < 1) continue;
123
+ const r = a.getBoundingClientRect();
124
+ if (r.width < 40 || r.height < 8) continue;
125
+ if (r.top < 0 || r.top > window.innerHeight) continue; // must be on screen
126
+ out.push(a);
127
+ }
128
+ if (!out.length) return { el: null };
129
+ return { el: out[Math.floor(Math.random() * out.length)] };
130
+ }, host);
131
+ if (!el) return false;
132
+ if (!(await clickHandle(page, el, { hoverMs: [300, 1000] }))) return false;
133
+ await page.waitForTimeout(randInt(2500, 5500));
134
+ if (page.url() === urlBefore) return false;
135
+ // Landed somewhere else entirely (interstitial, ad redirect) → back out.
136
+ try {
137
+ if (new URL(page.url()).hostname !== host) {
138
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
139
+ return false;
140
+ }
141
+ } catch { return false; }
142
+ await centerMouse(page);
143
+ for (let i = 0; i < randInt(2, 5); i++) {
144
+ await scrollSome(page, randInt(250, 800));
145
+ await page.waitForTimeout(randInt(1500, 5000));
146
+ }
147
+ if (log) log('info', `[warm-web] đọc 1 bài: ${page.url().slice(0, 90)}`);
148
+ return true;
149
+ }
150
+
151
+ async function visitSite(page, url, log) {
152
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 });
153
+ await page.waitForTimeout(randInt(2500, 6000));
154
+ await dismissConsent(page);
155
+ await centerMouse(page);
156
+ for (let i = 0; i < randInt(2, 5); i++) {
157
+ await scrollSome(page, randInt(250, 900));
158
+ // Mostly a glance, sometimes a real stop to read.
159
+ await page.waitForTimeout(chance(0.25) ? randInt(5000, 14000) : randInt(1200, 4000));
160
+ }
161
+ if (chance(0.5)) {
162
+ await scrollSome(page, -randInt(200, 500));
163
+ await page.waitForTimeout(randInt(1200, 3500));
164
+ }
165
+ log('info', `[warm-web] ghé ${originOf(url) || url}`);
166
+ }
167
+
168
+ async function warmWeb({ page, cfg = {}, log }) {
169
+ const out = { ran: false, sites: 0, seconds: 0, last_origin: '' };
170
+ // EVERY exit logs, including the ones that do nothing. A probability branch
171
+ // that stays silent when it skips is indistinguishable from a branch that is
172
+ // never reached: measured 2026-09-22, four sessions in a row rolled a skip
173
+ // (0.4^4 = 2.6%) and the only way to tell bad luck from a dead code path was
174
+ // to re-run the module by hand on each machine. One line is cheaper.
175
+ const say = (m) => { if (typeof log === 'function') log('info', `[warm-web] ${m}`); };
176
+ const prob = cfg.warm_prob ?? 0.6;
177
+ if (!(prob > 0)) { say(`tắt (warm_prob=${prob}) — không lướt web ngoài`); return out; }
178
+ if (!chance(prob)) { say(`bỏ qua lượt này (quay xác suất trượt, warm_prob=${prob})`); return out; }
179
+
180
+ // A MISSING list means "the API is older than this script" → use the built-in
181
+ // one. An EMPTY list means the caller said no sites, so browse none: a config
182
+ // that clears the list must not quietly fall back to six hardcoded domains.
183
+ const pool = (Array.isArray(cfg.warm_sites) ? cfg.warm_sites : DEFAULT_SITES)
184
+ .filter((u) => typeof u === 'string' && /^https?:\/\//i.test(u));
185
+ if (!pool.length) { say('danh sách site rỗng — không lướt web ngoài'); return out; }
186
+
187
+ const want = randInt(Math.max(1, cfg.warm_sites_min ?? 1), Math.max(1, cfg.warm_sites_max ?? 3));
188
+ const budgetSec = randInt(cfg.warm_sec_min ?? 60, Math.max(cfg.warm_sec_min ?? 60, cfg.warm_sec_max ?? 150));
189
+ const clickProb = cfg.warm_click_prob ?? 0.4;
190
+
191
+ // Draw without replacement so a two-site detour is two different sites.
192
+ const picks = [];
193
+ const bag = [...pool];
194
+ while (picks.length < want && bag.length) picks.push(...bag.splice(randInt(0, bag.length - 1), 1));
195
+
196
+ const t0 = Date.now();
197
+ const deadline = t0 + budgetSec * 1000;
198
+ out.ran = true;
199
+ log('info', `[warm-web] lướt web ngoài trước khi vào platform — ${picks.length} site, ngân sách ~${budgetSec}s`);
200
+
201
+ for (const url of picks) {
202
+ // Budget is checked BETWEEN sites, never before the first one: a badly
203
+ // configured budget should shorten the detour, not turn it into a no-op
204
+ // that still reports `ran: true`.
205
+ if (out.sites > 0 && Date.now() >= deadline) break;
206
+ try {
207
+ await visitSite(page, url, log);
208
+ out.sites++;
209
+ out.last_origin = originOf(page.url()) || originOf(url) || out.last_origin;
210
+ if (Date.now() < deadline && chance(clickProb)) await readOneArticle(page, log);
211
+ out.last_origin = originOf(page.url()) || out.last_origin;
212
+ } catch (e) {
213
+ // A site that won't load is not a reason to lose the session behind this.
214
+ log('info', `[warm-web] bỏ qua ${originOf(url) || url}: ${String(e.message || e).slice(0, 80)}`);
215
+ }
216
+ }
217
+
218
+ out.seconds = Math.round((Date.now() - t0) / 1000);
219
+ log('info', `[warm-web] xong — ${out.sites} site trong ${out.seconds}s${out.last_origin ? `, rời từ ${out.last_origin}` : ''}`);
220
+ return out;
221
+ }
222
+
223
+ module.exports = { warmWeb, DEFAULT_SITES };
@@ -14,14 +14,21 @@
14
14
  // NOT here: messaging (dropped — messaging strangers is the fastest way to a
15
15
  // spam checkpoint) and joining groups (approval questions strand the script).
16
16
  //
17
+ // Most sessions open a couple of ordinary websites FIRST (lib/warm-web), on the
18
+ // same profile and proxy, so the profile's history isn't facebook.com and
19
+ // nothing else and the visit can carry a real Referer. That detour is added ON
20
+ // TOP of the feed budget — the feed time below is unchanged by it.
21
+ //
17
22
  // Contract: run({ page, payload, log }) → { phase, posts_seen, likes,
18
- // videos_watched, pages_followed, posts_opened, duration_sec }.
23
+ // videos_watched, pages_followed, posts_opened, warm_sites_visited, warm_sec,
24
+ // duration_sec }.
19
25
  // A dead session (logged out / checkpoint / disabled) throws a CODED error via
20
26
  // lib/fb-guard so the API can stop the schedule and raise a notification.
21
27
 
22
28
  const { assertAccountUsable, readProfileMode } = require('./lib/fb-guard');
23
29
  const { pick, clickHandle } = require('./lib/dom-pick');
24
30
  const { humanMove, humanWheel, humanClick } = require('./lib/human');
31
+ const { warmWeb } = require('./lib/warm-web');
25
32
 
26
33
  // Every markup shape Facebook has used for "one post in the feed". Used by the
27
34
  // secondary helpers (ad check, open-post) via closest()/querySelectorAll;
@@ -535,7 +542,18 @@ async function run({ page, context, payload, log }) {
535
542
  guardSingleTab(context, page, tabGate, log);
536
543
  }
537
544
 
538
- await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
545
+ // A few minutes on the ordinary web first — same tab, same profile, same
546
+ // proxy. Runs BEFORE any Facebook navigation so nothing here can interfere
547
+ // with the account guard below, and it never throws (lib/warm-web swallows
548
+ // its own failures): a news site being down must not cost the session.
549
+ const warm = await warmWeb({ page, cfg, log });
550
+
551
+ // Arriving from a page we actually just read, about half the time. The other
552
+ // half is a bare navigation, which is what typing an address does — both are
553
+ // real, and always doing either one is the pattern.
554
+ const navOpts = { waitUntil: 'domcontentloaded', timeout: 60000 };
555
+ if (warm.last_origin && chance(0.5)) navOpts.referer = warm.last_origin;
556
+ await page.goto('https://www.facebook.com/', navOpts);
539
557
  await page.waitForTimeout(randInt(3000, 5000));
540
558
  // Guard FIRST — everything below assumes a live logged-in session.
541
559
  await assertAccountUsable(page, log);
@@ -550,6 +568,7 @@ async function run({ page, context, payload, log }) {
550
568
  profile_mode: 'page', skipped: true,
551
569
  phase, day_index: dayIndex,
552
570
  posts_seen: 0, likes: 0, videos_watched: 0, pages_followed: 0, posts_opened: 0,
571
+ warm_sites_visited: warm.sites, warm_sec: warm.seconds,
553
572
  duration_sec: Math.round((Date.now() - t0) / 1000),
554
573
  account_status: 'ok',
555
574
  };
@@ -565,7 +584,11 @@ async function run({ page, context, payload, log }) {
565
584
  let sideTrips = 0;
566
585
  const sideTripsMax = randInt(0, 2);
567
586
  let ticks = 0, emptyTicks = 0, stuckScrolls = 0;
568
- const deadline = t0 + sessionSec * 1000;
587
+ // From NOW, not from t0: the outside-web detour is added on top of the feed
588
+ // budget (owner's call, 2026-09-22). Anchoring on t0 would silently spend
589
+ // the detour's 1-3 minutes out of the newsfeed time, which is the part that
590
+ // actually ages the account.
591
+ const deadline = Date.now() + sessionSec * 1000;
569
592
 
570
593
  const seenKeys = new Set(); // post ids already counted (survives DOM recycling)
571
594
  const likedKeys = new Set(); // post ids already attempted — never twice
@@ -670,7 +693,7 @@ async function run({ page, context, payload, log }) {
670
693
  }
671
694
 
672
695
  const durationSec = Math.round((Date.now() - t0) / 1000);
673
- log('info', `[nurture-fb] session done — posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} pages=${pagesFollowed} opened=${postsOpened} duration=${durationSec}s`);
696
+ log('info', `[nurture-fb] session done — posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} pages=${pagesFollowed} opened=${postsOpened} web-ngoài=${warm.sites}/${warm.seconds}s duration=${durationSec}s`);
674
697
  return {
675
698
  phase,
676
699
  profile_mode: 'personal',
@@ -680,6 +703,11 @@ async function run({ page, context, payload, log }) {
680
703
  videos_watched: videos,
681
704
  pages_followed: pagesFollowed,
682
705
  posts_opened: postsOpened,
706
+ // Reported for the history only. Deliberately NOT part of any threshold:
707
+ // the "did this session read anything" gate keys off posts_seen, so a
708
+ // detour can never graduate a profile that browsed zero Facebook posts.
709
+ warm_sites_visited: warm.sites,
710
+ warm_sec: warm.seconds,
683
711
  duration_sec: durationSec,
684
712
  account_status: 'ok',
685
713
  };
@@ -1123,6 +1123,38 @@ function pickReelsTabUrl(anchors) {
1123
1123
  return pickReelsTabUrls(anchors)[0] || null;
1124
1124
  }
1125
1125
 
1126
+ // DANH TÍNH của page, gỡ ra từ URL tab Reels đã mở được.
1127
+ //
1128
+ // Vì sao cần: không chỗ nào trong hệ thống lưu ID Facebook của kênh. Worker dò
1129
+ // lại tab Reels ở MỖI lượt đăng và mỗi lượt đo view rồi vứt đi, nên API không
1130
+ // có gì để đối chiếu với danh sách page đã liên kết bên Shopee. Gỡ ở đây là
1131
+ // miễn phí — URL này vừa được chứng minh render ra tile, tức đúng page của
1132
+ // kênh, không phải một anchor "trông giống".
1133
+ //
1134
+ // `pickReelsTabUrls` chỉ đẻ ra ĐÚNG hai dạng, nên parser không cần đoán:
1135
+ // profile.php?id=<số>&sk=reels_tab → { id }
1136
+ // /<handle>/?sk=reels_tab → { handle }
1137
+ // Dạng handle KHÔNG suy ra được id; để API so bằng handle với link bên Shopee.
1138
+ function parsePageRef(reelsTabUrl) {
1139
+ const raw = String(reelsTabUrl || '').trim();
1140
+ if (!raw) return null;
1141
+ let u;
1142
+ try { u = new URL(raw.startsWith('http') ? raw : `https://www.facebook.com${raw}`); } catch { return null; }
1143
+ if (!/(^|\.)facebook\.com$/i.test(u.hostname)) return null;
1144
+
1145
+ const id = u.searchParams.get('id');
1146
+ if (/^\d{5,25}$/.test(String(id || ''))) return { id: String(id), handle: '', url: raw };
1147
+
1148
+ // Đoạn đầu của path; bỏ các từ khoá của chính Facebook để không nhận nhầm
1149
+ // `/reel/…`, `/profile.php` thành tên page.
1150
+ const seg = decodeURIComponent(u.pathname.split('/').filter(Boolean)[0] || '');
1151
+ if (!seg || !/^[A-Za-z0-9._-]{3,}$/.test(seg)) return null;
1152
+ if (['profile.php', 'reel', 'reels', 'video', 'videos', 'watch', 'groups', 'pages', 'people'].includes(seg.toLowerCase())) return null;
1153
+ // Path toàn số cũng là ID page (shortcut `/<id số>/`), không phải tên riêng.
1154
+ if (/^\d{5,25}$/.test(seg)) return { id: seg, handle: '', url: raw };
1155
+ return { id: '', handle: seg, url: raw };
1156
+ }
1157
+
1126
1158
  async function hasVisibleReelComposer(page) {
1127
1159
  return page.evaluate(() => {
1128
1160
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
@@ -3658,6 +3690,7 @@ module.exports.__testables = {
3658
3690
  pickPostPublishReelsTabUrl,
3659
3691
  pickReelsTabUrl,
3660
3692
  pickReelsTabUrls,
3693
+ parsePageRef,
3661
3694
  discoverReelsTabUrls,
3662
3695
  openReelsTabWithTiles,
3663
3696
  networkIdVerdict,