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.
package/lib/command-poller.js
CHANGED
|
@@ -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
|
});
|
package/lib/shopee-report.js
CHANGED
|
@@ -183,4 +183,78 @@ async function fetchClickReport(ctx, {
|
|
|
183
183
|
}
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
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
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|