channel-worker 2.5.44 → 2.5.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/api-client.js +1 -1
- package/package.json +1 -1
- package/scripts/lib/fb-guard.js +100 -0
- package/scripts/nurture_facebook.js +369 -0
- package/scripts/warmup_facebook.js +7 -0
package/lib/api-client.js
CHANGED
|
@@ -68,7 +68,7 @@ class ApiClient {
|
|
|
68
68
|
async getNextCommand(workerId) {
|
|
69
69
|
// Daemon-handled types. `_pw` variants route to the Playwright pipeline
|
|
70
70
|
// (lib/playwright-runner → scripts/<base>.js) instead of the extension.
|
|
71
|
-
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,scrape_affiliate_products,ingest_shopee_product';
|
|
71
|
+
const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,scrape_affiliate_products,ingest_shopee_product';
|
|
72
72
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
73
73
|
}
|
|
74
74
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Facebook session guard — shared by warmup_facebook.js and nurture_facebook.js.
|
|
2
|
+
//
|
|
3
|
+
// WHY: both scripts drive a long-lived NST profile that is SUPPOSED to be logged
|
|
4
|
+
// into Facebook. When that session dies (cookie expired, FB forced a re-login,
|
|
5
|
+
// checkpoint, or the account got disabled), the scripts used to fail with a
|
|
6
|
+
// generic "Reels entry not found" / "no posts found" — indistinguishable from a
|
|
7
|
+
// layout change. The nurture scheduler then kept driving a dead profile once a
|
|
8
|
+
// day, silently, for weeks.
|
|
9
|
+
//
|
|
10
|
+
// So: detect the account state explicitly and throw an error whose message
|
|
11
|
+
// STARTS with a machine-readable code. command-poller posts the thrown message
|
|
12
|
+
// verbatim (truncated to 500 chars) into the command's `error`, and the API's
|
|
13
|
+
// result hook parses the prefix → writes channel.nurture.account_status →
|
|
14
|
+
// disables the scheduler → the notification centre rings.
|
|
15
|
+
//
|
|
16
|
+
// Codes: FB_ACCOUNT_LOGGED_OUT | FB_ACCOUNT_CHECKPOINT | FB_ACCOUNT_BANNED
|
|
17
|
+
|
|
18
|
+
const CODES = {
|
|
19
|
+
logged_out: 'FB_ACCOUNT_LOGGED_OUT',
|
|
20
|
+
checkpoint: 'FB_ACCOUNT_CHECKPOINT',
|
|
21
|
+
banned: 'FB_ACCOUNT_BANNED',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Phrases FB shows on a disabled/suspended account (vi + en). Kept narrow on
|
|
25
|
+
// purpose — a false "banned" would switch off a healthy account's nurture loop.
|
|
26
|
+
const BANNED_RE = /(tài khoản của bạn đã bị (vô hiệu hoá|vô hiệu hóa|khoá|khóa|đình chỉ))|(your account has been disabled)|(we (have )?(suspended|disabled) your account)|(tài khoản này đã bị vô hiệu hoá)|(account (is )?(disabled|suspended|restricted) )/i;
|
|
27
|
+
// Identity/security interstitials — recoverable by a human, not by the script.
|
|
28
|
+
const CHECKPOINT_RE = /(xác nhận danh tính)|(xác minh danh tính)|(hãy xác nhận đây là bạn)|(confirm your identity)|(help us confirm)|(we need to confirm)|(bảo mật tài khoản của bạn)|(unusual (login|activity))|(hoạt động bất thường)/i;
|
|
29
|
+
|
|
30
|
+
// Read the account state of whatever page is currently open.
|
|
31
|
+
// Returns { status: 'ok'|'logged_out'|'checkpoint'|'banned', detail }.
|
|
32
|
+
async function readAccountState(page) {
|
|
33
|
+
const url = page.url() || '';
|
|
34
|
+
// URL is the strongest signal — FB redirects hard on all three states.
|
|
35
|
+
if (/\/checkpoint\//i.test(url)) return { status: 'checkpoint', detail: `redirected to ${url.slice(0, 120)}` };
|
|
36
|
+
if (/facebook\.com\/(login|recover)\b|login\.php|\/login\/device-based/i.test(url)) {
|
|
37
|
+
return { status: 'logged_out', detail: `redirected to ${url.slice(0, 120)}` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dom = await page.evaluate(() => {
|
|
41
|
+
const vis = (el) => {
|
|
42
|
+
if (!el) return false;
|
|
43
|
+
const r = el.getBoundingClientRect();
|
|
44
|
+
return r.width > 8 && r.height > 8 && el.offsetParent !== null;
|
|
45
|
+
};
|
|
46
|
+
// Login form present = the session is gone (FB renders email+pass on the
|
|
47
|
+
// logged-out home page too, so this catches a soft logout with no redirect).
|
|
48
|
+
const hasLoginForm = vis(document.querySelector("input[name='email'], input[name='pass'], input#email, input#pass"));
|
|
49
|
+
// A rendered FEED is proof of a live session: FB serves no posts on a
|
|
50
|
+
// login / checkpoint / disabled-account page. This matters because the
|
|
51
|
+
// text patterns below would otherwise fire on somebody's viral post about
|
|
52
|
+
// *their* account being disabled — a false "banned" would switch off a
|
|
53
|
+
// perfectly healthy account's nurture schedule.
|
|
54
|
+
const hasFeed = !!document.querySelector("[role='article']") ||
|
|
55
|
+
!!document.querySelector("[role='feed']");
|
|
56
|
+
return {
|
|
57
|
+
hasLoginForm,
|
|
58
|
+
hasFeed,
|
|
59
|
+
text: (document.body?.innerText || '').slice(0, 4000),
|
|
60
|
+
};
|
|
61
|
+
}).catch(() => null);
|
|
62
|
+
|
|
63
|
+
if (!dom) return { status: 'ok', detail: '' }; // page not readable — let the caller's own checks decide
|
|
64
|
+
if (dom.hasFeed) return { status: 'ok', detail: '' }; // posts render → session is alive
|
|
65
|
+
|
|
66
|
+
if (BANNED_RE.test(dom.text)) {
|
|
67
|
+
const m = dom.text.match(BANNED_RE);
|
|
68
|
+
return { status: 'banned', detail: (m && m[0] ? m[0] : 'disabled-account notice').slice(0, 160) };
|
|
69
|
+
}
|
|
70
|
+
if (CHECKPOINT_RE.test(dom.text)) {
|
|
71
|
+
const m = dom.text.match(CHECKPOINT_RE);
|
|
72
|
+
return { status: 'checkpoint', detail: (m && m[0] ? m[0] : 'checkpoint notice').slice(0, 160) };
|
|
73
|
+
}
|
|
74
|
+
if (dom.hasLoginForm) {
|
|
75
|
+
return { status: 'logged_out', detail: 'login form on page (session expired)' };
|
|
76
|
+
}
|
|
77
|
+
return { status: 'ok', detail: '' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Throw a coded error if the account is not usable. Call right after landing on
|
|
81
|
+
// facebook.com, and again whenever a step fails unexpectedly (a mid-session
|
|
82
|
+
// checkpoint is common — FB throws one the moment automated-looking activity
|
|
83
|
+
// trips a threshold).
|
|
84
|
+
async function assertAccountUsable(page, log) {
|
|
85
|
+
const st = await readAccountState(page);
|
|
86
|
+
if (st.status === 'ok') return st;
|
|
87
|
+
const code = CODES[st.status];
|
|
88
|
+
const human = st.status === 'banned'
|
|
89
|
+
? 'Account Facebook đã bị vô hiệu hoá/khoá — phải xử lý bằng tay.'
|
|
90
|
+
: st.status === 'checkpoint'
|
|
91
|
+
? 'Facebook chặn ở bước xác minh (checkpoint) — phải mở profile và xác minh bằng tay.'
|
|
92
|
+
: 'Profile đã ĐĂNG XUẤT khỏi Facebook — đăng nhập lại trong NST profile rồi bật lại.';
|
|
93
|
+
if (log) log('info', `[fb-guard] ${st.status}: ${st.detail}`);
|
|
94
|
+
// Code FIRST — command-poller truncates the message at 500 chars.
|
|
95
|
+
const err = new Error(`${code}: ${human} (${st.detail})`);
|
|
96
|
+
err.account_status = st.status;
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { readAccountState, assertAccountUsable, CODES };
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
// Facebook account nurture — ages an account so FB trusts it (the owner then
|
|
2
|
+
// creates the Page BY HAND; this script never touches Page creation).
|
|
3
|
+
//
|
|
4
|
+
// Runs on the channel's own NST profile + residential proxy, once or twice a
|
|
5
|
+
// day, driven by the API scheduler. One run = one "session": browse the real
|
|
6
|
+
// newsfeed the way a person does, and — only once the account is old enough —
|
|
7
|
+
// leave a few likes.
|
|
8
|
+
//
|
|
9
|
+
// PHASES (server decides, passed in payload.phase):
|
|
10
|
+
// 1 (day 1-3) read only — scroll, pause to read, watch a feed video. ZERO taps.
|
|
11
|
+
// 2 (day 4-7) + likes (3-5), + open a post to read comments.
|
|
12
|
+
// 3 (day 8+) + likes (5-10), + follow a page that shows up in the feed.
|
|
13
|
+
//
|
|
14
|
+
// NOT here: messaging (dropped — messaging strangers is the fastest way to a
|
|
15
|
+
// spam checkpoint) and joining groups (approval questions strand the script).
|
|
16
|
+
//
|
|
17
|
+
// Contract: run({ page, payload, log }) → { phase, posts_seen, likes,
|
|
18
|
+
// videos_watched, pages_followed, posts_opened, duration_sec }.
|
|
19
|
+
// A dead session (logged out / checkpoint / disabled) throws a CODED error via
|
|
20
|
+
// lib/fb-guard so the API can stop the schedule and raise a notification.
|
|
21
|
+
|
|
22
|
+
const { assertAccountUsable } = require('./lib/fb-guard');
|
|
23
|
+
|
|
24
|
+
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
25
|
+
function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
26
|
+
function chance(p) { return Math.random() < p; }
|
|
27
|
+
|
|
28
|
+
// Dismiss FB cookie / cross-sell / "not now" popups. Best-effort, never throws.
|
|
29
|
+
// Never touches the composer or a reel viewer.
|
|
30
|
+
async function dismissDialogs(page, log) {
|
|
31
|
+
const verbs = ['Cho phép tất cả cookie', 'Allow all cookies', 'Để sau', 'Not now', 'Lúc khác', 'Không phải bây giờ', 'Bỏ qua', 'Skip', 'Đóng', 'Close', 'OK', 'Đã hiểu', 'Got it'];
|
|
32
|
+
for (let round = 0; round < 3; round++) {
|
|
33
|
+
const hit = await page.evaluate((vs) => {
|
|
34
|
+
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
35
|
+
const r = dlg.getBoundingClientRect();
|
|
36
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
37
|
+
const aria = (dlg.getAttribute('aria-label') || '').toLowerCase();
|
|
38
|
+
if (/tạo bài viết|create post|thước phim|reel/i.test(aria)) continue;
|
|
39
|
+
for (const v of vs) {
|
|
40
|
+
for (const b of dlg.querySelectorAll("[role='button'], button")) {
|
|
41
|
+
const t = (b.innerText || '').trim();
|
|
42
|
+
if ((t === v || (b.getAttribute('aria-label') || '').trim() === v) && b.offsetParent !== null) {
|
|
43
|
+
b.setAttribute('__nur_dismiss__', '1');
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}, verbs).catch(() => null);
|
|
51
|
+
if (!hit) break;
|
|
52
|
+
try { await page.locator("[__nur_dismiss__='1']").click({ timeout: 2500 }); } catch {}
|
|
53
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_dismiss__]').forEach(e => e.removeAttribute('__nur_dismiss__'))).catch(() => {});
|
|
54
|
+
await page.waitForTimeout(800);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Count feed posts that entered the viewport since the last call, marking them
|
|
59
|
+
// so they're counted once. Sponsored posts are marked too (so they're never
|
|
60
|
+
// liked or watched) but do NOT count as "seen" — an ad isn't reading.
|
|
61
|
+
async function markPostsInView(page) {
|
|
62
|
+
return page.evaluate(() => {
|
|
63
|
+
let fresh = 0;
|
|
64
|
+
for (const art of document.querySelectorAll("[role='article']")) {
|
|
65
|
+
// Comments render as NESTED articles — only top-level posts count.
|
|
66
|
+
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
67
|
+
if (art.hasAttribute('data-nur-seen')) continue;
|
|
68
|
+
const r = art.getBoundingClientRect();
|
|
69
|
+
const inView = r.bottom > 0 && r.top < window.innerHeight && r.height > 80;
|
|
70
|
+
if (!inView) continue;
|
|
71
|
+
art.setAttribute('data-nur-seen', '1');
|
|
72
|
+
const head = (art.innerText || '').slice(0, 400);
|
|
73
|
+
if (/Được tài trợ|Sponsored|Tài trợ/i.test(head)) { art.setAttribute('data-nur-ad', '1'); continue; }
|
|
74
|
+
fresh++;
|
|
75
|
+
}
|
|
76
|
+
return fresh;
|
|
77
|
+
}).catch(() => 0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Like ONE organic post near the centre of the viewport. Returns true on a
|
|
81
|
+
// confirmed like. Deliberately picky: skips ads, skips already-liked posts, and
|
|
82
|
+
// never grabs a COMMENT's like button (nested article).
|
|
83
|
+
async function likeVisiblePost(page, log) {
|
|
84
|
+
const found = await page.evaluate(() => {
|
|
85
|
+
const mid = window.innerHeight / 2;
|
|
86
|
+
let best = null, bestDist = Infinity;
|
|
87
|
+
for (const art of document.querySelectorAll("[role='article']")) {
|
|
88
|
+
if (art.parentElement && art.parentElement.closest("[role='article']")) continue; // comment
|
|
89
|
+
if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-liked')) continue;
|
|
90
|
+
const r = art.getBoundingClientRect();
|
|
91
|
+
if (r.bottom < 60 || r.top > window.innerHeight - 60 || r.height < 120) continue;
|
|
92
|
+
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
93
|
+
if (dist < bestDist) { best = art; bestDist = dist; }
|
|
94
|
+
}
|
|
95
|
+
if (!best) return null;
|
|
96
|
+
if (/Được tài trợ|Sponsored/i.test((best.innerText || '').slice(0, 400))) { best.setAttribute('data-nur-ad', '1'); return null; }
|
|
97
|
+
for (const b of best.querySelectorAll("[role='button']")) {
|
|
98
|
+
const aria = (b.getAttribute('aria-label') || '').trim();
|
|
99
|
+
if (!/^(thích|like)$/i.test(aria)) continue;
|
|
100
|
+
if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
|
|
101
|
+
if (b.closest("[role='article']") !== best) continue; // belongs to a comment
|
|
102
|
+
const r = b.getBoundingClientRect();
|
|
103
|
+
if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
|
|
104
|
+
best.setAttribute('data-nur-liked', 'pending');
|
|
105
|
+
b.setAttribute('__nur_like__', '1');
|
|
106
|
+
return { aria };
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}).catch(() => null);
|
|
110
|
+
if (!found) return false;
|
|
111
|
+
|
|
112
|
+
const btn = page.locator("[__nur_like__='1']").first();
|
|
113
|
+
let ok = false;
|
|
114
|
+
try {
|
|
115
|
+
await btn.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
116
|
+
// Hover first, then a short beat — a real cursor travels before it clicks.
|
|
117
|
+
await btn.hover({ timeout: 3000 }).catch(() => {});
|
|
118
|
+
await page.waitForTimeout(randInt(500, 1400));
|
|
119
|
+
// MUST be a Playwright click: an el.click() from page.evaluate is synthetic
|
|
120
|
+
// and React's handler ignores it (same lesson as the YouTube ad-skip).
|
|
121
|
+
await btn.click({ timeout: 4000 });
|
|
122
|
+
await page.waitForTimeout(randInt(900, 1800));
|
|
123
|
+
ok = await btn.evaluate((el) => el.getAttribute('aria-pressed') === 'true').catch(() => true);
|
|
124
|
+
if (ok === null || ok === undefined) ok = true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
|
|
127
|
+
}
|
|
128
|
+
await page.evaluate((liked) => {
|
|
129
|
+
document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'));
|
|
130
|
+
document.querySelectorAll("[data-nur-liked='pending']").forEach(e => e.setAttribute('data-nur-liked', liked ? '1' : 'fail'));
|
|
131
|
+
}, !!ok).catch(() => {});
|
|
132
|
+
if (ok) log('info', '[nurture-fb] liked a post');
|
|
133
|
+
return !!ok;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Watch a video that's playing in the feed, counting only seconds where
|
|
137
|
+
// playback actually ADVANCES (a frozen/buffering player must not bank time).
|
|
138
|
+
async function watchFeedVideo(page, minSec, maxSec, log) {
|
|
139
|
+
const has = await page.evaluate(() => {
|
|
140
|
+
const vids = [...document.querySelectorAll('video')];
|
|
141
|
+
for (const v of vids) {
|
|
142
|
+
const r = v.getBoundingClientRect();
|
|
143
|
+
if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
144
|
+
const art = v.closest("[role='article']");
|
|
145
|
+
if (art && art.hasAttribute('data-nur-ad')) continue; // never watch ads
|
|
146
|
+
if (v.hasAttribute('data-nur-watched')) continue;
|
|
147
|
+
v.setAttribute('data-nur-watched', '1');
|
|
148
|
+
if (v.paused) { try { v.play(); } catch {} }
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}).catch(() => false);
|
|
153
|
+
if (!has) return false;
|
|
154
|
+
|
|
155
|
+
const budgetMs = randInt(minSec, maxSec) * 1000;
|
|
156
|
+
let watched = 0, lastT = -1, frozen = 0;
|
|
157
|
+
while (watched < budgetMs) {
|
|
158
|
+
const st = await page.evaluate(() => {
|
|
159
|
+
const v = document.querySelector("video[data-nur-watched='1']");
|
|
160
|
+
if (!v) return null;
|
|
161
|
+
if (v.paused && !v.ended) { try { v.play(); } catch {} }
|
|
162
|
+
return { t: Number(v.currentTime) || 0, ended: !!v.ended, dur: (isFinite(v.duration) && v.duration > 0) ? v.duration : 0, loop: !!v.loop };
|
|
163
|
+
}).catch(() => null);
|
|
164
|
+
if (!st) break;
|
|
165
|
+
if (!st.loop && (st.ended || (st.dur > 0 && st.t >= st.dur - 0.6))) break;
|
|
166
|
+
const advanced = st.t > lastT + 0.2 || st.t < lastT - 0.5; // forward, or loop wrap
|
|
167
|
+
if (advanced) { watched += 1000; frozen = 0; } else if (++frozen >= 6) break;
|
|
168
|
+
lastT = st.t;
|
|
169
|
+
await page.waitForTimeout(1000);
|
|
170
|
+
}
|
|
171
|
+
// Release the marker so a later video in the feed can be picked.
|
|
172
|
+
await page.evaluate(() => document.querySelectorAll("video[data-nur-watched='1']").forEach(v => v.setAttribute('data-nur-watched', 'done'))).catch(() => {});
|
|
173
|
+
if (watched > 0) log('info', `[nurture-fb] watched a feed video ~${Math.round(watched / 1000)}s`);
|
|
174
|
+
return watched > 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Open a post's permalink, read the comments for a bit, then go back.
|
|
178
|
+
// Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
|
|
179
|
+
async function openPostAndRead(page, log) {
|
|
180
|
+
const urlBefore = page.url();
|
|
181
|
+
const marked = await page.evaluate(() => {
|
|
182
|
+
const mid = window.innerHeight / 2;
|
|
183
|
+
let best = null, bestDist = Infinity;
|
|
184
|
+
for (const art of document.querySelectorAll("[role='article']")) {
|
|
185
|
+
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
186
|
+
if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-opened')) continue;
|
|
187
|
+
const r = art.getBoundingClientRect();
|
|
188
|
+
if (r.bottom < 60 || r.top > window.innerHeight - 60) continue;
|
|
189
|
+
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
190
|
+
if (dist < bestDist) { best = art; bestDist = dist; }
|
|
191
|
+
}
|
|
192
|
+
if (!best) return false;
|
|
193
|
+
for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
|
|
194
|
+
const r = a.getBoundingClientRect();
|
|
195
|
+
if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
|
|
196
|
+
best.setAttribute('data-nur-opened', '1');
|
|
197
|
+
a.setAttribute('__nur_open__', '1');
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
return false;
|
|
201
|
+
}).catch(() => false);
|
|
202
|
+
if (!marked) return false;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
await page.locator("[__nur_open__='1']").first().click({ timeout: 4000 });
|
|
206
|
+
} catch {
|
|
207
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
|
|
211
|
+
await page.waitForTimeout(randInt(2500, 4000));
|
|
212
|
+
|
|
213
|
+
// Read comments: a couple of small scrolls with human pauses.
|
|
214
|
+
for (let i = 0; i < randInt(2, 4); i++) {
|
|
215
|
+
await page.mouse.wheel(0, randInt(200, 600)).catch(() => {});
|
|
216
|
+
await page.waitForTimeout(randInt(1500, 4000));
|
|
217
|
+
}
|
|
218
|
+
log('info', '[nurture-fb] opened a post and read comments');
|
|
219
|
+
|
|
220
|
+
// Back to the feed. A photo/post overlay closes with Escape, a real
|
|
221
|
+
// navigation needs goBack — try both, feed check happens next loop.
|
|
222
|
+
if (page.url() !== urlBefore) {
|
|
223
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
|
|
224
|
+
} else {
|
|
225
|
+
await page.keyboard.press('Escape').catch(() => {});
|
|
226
|
+
}
|
|
227
|
+
await page.waitForTimeout(randInt(1500, 3000));
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
|
|
232
|
+
// Only clicks a Follow/Like-Page button that's already on screen — never hunts.
|
|
233
|
+
async function followVisiblePage(page, log) {
|
|
234
|
+
const marked = await page.evaluate(() => {
|
|
235
|
+
for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
|
|
236
|
+
const t = (b.innerText || '').trim();
|
|
237
|
+
const aria = (b.getAttribute('aria-label') || '').trim();
|
|
238
|
+
const label = t || aria;
|
|
239
|
+
if (!/^(theo dõi|follow|thích trang|like page)$/i.test(label)) continue;
|
|
240
|
+
const r = b.getBoundingClientRect();
|
|
241
|
+
if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
|
|
242
|
+
if (r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
243
|
+
const art = b.closest("[role='article']");
|
|
244
|
+
if (art && art.hasAttribute('data-nur-ad')) continue; // not an ad's CTA
|
|
245
|
+
b.setAttribute('__nur_follow__', '1');
|
|
246
|
+
return label;
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}).catch(() => null);
|
|
250
|
+
if (!marked) return false;
|
|
251
|
+
let ok = false;
|
|
252
|
+
try {
|
|
253
|
+
const btn = page.locator("[__nur_follow__='1']").first();
|
|
254
|
+
await btn.hover({ timeout: 2500 }).catch(() => {});
|
|
255
|
+
await page.waitForTimeout(randInt(400, 1100));
|
|
256
|
+
await btn.click({ timeout: 4000 });
|
|
257
|
+
await page.waitForTimeout(randInt(1200, 2200));
|
|
258
|
+
ok = true;
|
|
259
|
+
log('info', `[nurture-fb] followed a page ("${marked}")`);
|
|
260
|
+
} catch {}
|
|
261
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_follow__]').forEach(e => e.removeAttribute('__nur_follow__'))).catch(() => {});
|
|
262
|
+
return ok;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─── main ───────────────────────────────────────────────────────────────────
|
|
266
|
+
async function run({ page, payload, log }) {
|
|
267
|
+
const t0 = Date.now();
|
|
268
|
+
const cfg = payload.config || {};
|
|
269
|
+
const phase = Math.max(1, Math.min(3, parseInt(payload.phase, 10) || 1));
|
|
270
|
+
const dayIndex = parseInt(payload.day_index, 10) || 1;
|
|
271
|
+
|
|
272
|
+
const sessionSec = randInt(cfg.session_min_sec ?? 480, cfg.session_max_sec ?? 900);
|
|
273
|
+
const likesTarget = randInt(cfg.likes_min ?? 0, cfg.likes_max ?? 0);
|
|
274
|
+
const likeGap = Math.max(1, cfg.like_gap_posts ?? 5);
|
|
275
|
+
const videosMax = cfg.videos_max ?? 3;
|
|
276
|
+
const openPostMax = cfg.open_post_max ?? 0;
|
|
277
|
+
const followMax = cfg.follow_page_max ?? 0;
|
|
278
|
+
const watchMin = cfg.video_watch_min_sec ?? 10;
|
|
279
|
+
const watchMax = Math.max(watchMin, cfg.video_watch_max_sec ?? 30);
|
|
280
|
+
|
|
281
|
+
log('info', `[nurture-fb] session start — phase ${phase} (ngày ${dayIndex}), ~${Math.round(sessionSec / 60)} phút, like tối đa ${likesTarget}, video ≤${videosMax}`);
|
|
282
|
+
|
|
283
|
+
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
284
|
+
|
|
285
|
+
await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
286
|
+
await page.waitForTimeout(randInt(3000, 5000));
|
|
287
|
+
// Guard FIRST — everything below assumes a live logged-in session.
|
|
288
|
+
await assertAccountUsable(page, log);
|
|
289
|
+
await dismissDialogs(page, log);
|
|
290
|
+
|
|
291
|
+
let postsSeen = 0, likes = 0, videos = 0, pagesFollowed = 0, postsOpened = 0;
|
|
292
|
+
let postsSinceLike = likeGap; // allow the first like once enough posts scroll by
|
|
293
|
+
let ticks = 0, emptyTicks = 0;
|
|
294
|
+
const deadline = t0 + sessionSec * 1000;
|
|
295
|
+
|
|
296
|
+
while (Date.now() < deadline) {
|
|
297
|
+
ticks++;
|
|
298
|
+
const fresh = await markPostsInView(page);
|
|
299
|
+
postsSeen += fresh;
|
|
300
|
+
postsSinceLike += fresh;
|
|
301
|
+
// Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
|
|
302
|
+
// silent interstitial. Re-check the account, then nudge back to the top.
|
|
303
|
+
if (fresh === 0) {
|
|
304
|
+
if (++emptyTicks >= 15) {
|
|
305
|
+
emptyTicks = 0;
|
|
306
|
+
await assertAccountUsable(page, log);
|
|
307
|
+
log('info', '[nurture-fb] feed idle — quay lại đầu trang');
|
|
308
|
+
await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' })).catch(() => {});
|
|
309
|
+
await page.waitForTimeout(randInt(2000, 4000));
|
|
310
|
+
}
|
|
311
|
+
} else {
|
|
312
|
+
emptyTicks = 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Watch a feed video now and then (all phases — watching is passive).
|
|
316
|
+
if (videos < videosMax && chance(0.22)) {
|
|
317
|
+
if (await watchFeedVideo(page, watchMin, watchMax, log)) videos++;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Like — phase 2+ only, never two posts in a row (enforced by likeGap).
|
|
321
|
+
if (likes < likesTarget && postsSinceLike >= likeGap) {
|
|
322
|
+
if (await likeVisiblePost(page, log)) { likes++; postsSinceLike = 0; }
|
|
323
|
+
else postsSinceLike = Math.max(0, postsSinceLike - 1); // retry a bit later
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Open a post to read comments — phase 2+.
|
|
327
|
+
if (postsOpened < openPostMax && chance(0.05)) {
|
|
328
|
+
if (await openPostAndRead(page, log)) postsOpened++;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Follow a page the feed already showed — phase 3.
|
|
332
|
+
if (pagesFollowed < followMax && chance(0.06)) {
|
|
333
|
+
if (await followVisiblePage(page, log)) pagesFollowed++;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Human-shaped movement: mostly forward, sometimes a long read, sometimes
|
|
337
|
+
// a scroll back up (people re-read what they just passed).
|
|
338
|
+
const roll = Math.random();
|
|
339
|
+
if (roll < 0.15) {
|
|
340
|
+
await page.waitForTimeout(randInt(6000, 20000)); // stop and read
|
|
341
|
+
} else if (roll < 0.23) {
|
|
342
|
+
await page.mouse.wheel(0, -randInt(150, 400)).catch(() => {});
|
|
343
|
+
await page.waitForTimeout(randInt(1200, 3000));
|
|
344
|
+
} else {
|
|
345
|
+
await page.mouse.wheel(0, randInt(250, 900)).catch(() => {});
|
|
346
|
+
await page.waitForTimeout(randInt(800, 4000));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Mid-session health check (~ every 12 ticks). FB throws a checkpoint the
|
|
350
|
+
// moment activity trips a threshold — better to stop than to keep poking.
|
|
351
|
+
if (ticks % 12 === 0) await assertAccountUsable(page, log);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const durationSec = Math.round((Date.now() - t0) / 1000);
|
|
355
|
+
log('info', `[nurture-fb] session done — posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} pages=${pagesFollowed} opened=${postsOpened} duration=${durationSec}s`);
|
|
356
|
+
return {
|
|
357
|
+
phase,
|
|
358
|
+
day_index: dayIndex,
|
|
359
|
+
posts_seen: postsSeen,
|
|
360
|
+
likes,
|
|
361
|
+
videos_watched: videos,
|
|
362
|
+
pages_followed: pagesFollowed,
|
|
363
|
+
posts_opened: postsOpened,
|
|
364
|
+
duration_sec: durationSec,
|
|
365
|
+
account_status: 'ok',
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
module.exports = { run };
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
// entry are hard requirements — their absence (not logged in / consent wall /
|
|
17
17
|
// layout change) FAILS the session loudly (no silent bypass).
|
|
18
18
|
|
|
19
|
+
const { assertAccountUsable } = require('./lib/fb-guard');
|
|
20
|
+
|
|
19
21
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
20
22
|
function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
21
23
|
function shuffle(arr) {
|
|
@@ -353,12 +355,16 @@ async function run({ page, payload, log }) {
|
|
|
353
355
|
// 1) Land on the FB home feed (organic entry — not a deep link).
|
|
354
356
|
await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
|
|
355
357
|
await page.waitForTimeout(randInt(3000, 5000));
|
|
358
|
+
// A dead session (logged out / checkpoint / disabled account) used to surface
|
|
359
|
+
// as "Reels entry not found" — indistinguishable from a layout change. Name it.
|
|
360
|
+
await assertAccountUsable(page, log);
|
|
356
361
|
await dismissDialogs(page, log);
|
|
357
362
|
await organicScroll(page, randInt(1, 3));
|
|
358
363
|
|
|
359
364
|
// 2) Click the "Reels" entry (left nav / shortcut) — natural navigation.
|
|
360
365
|
const reelsOk = await clickReelsEntry(page, log);
|
|
361
366
|
if (!reelsOk) {
|
|
367
|
+
await assertAccountUsable(page, log); // may throw a coded account error instead
|
|
362
368
|
throw new Error('warmup-fb: "Reels" entry not found on FB home — check the profile is logged in to Facebook and the left-nav Reels shortcut is present');
|
|
363
369
|
}
|
|
364
370
|
await dismissDialogs(page, log);
|
|
@@ -372,6 +378,7 @@ async function run({ page, payload, log }) {
|
|
|
372
378
|
// 3) Search the keyword via the top bar (from the Reels surface).
|
|
373
379
|
const focused = await focusFbSearch(page, log);
|
|
374
380
|
if (!focused) {
|
|
381
|
+
await assertAccountUsable(page, log); // logged out mid-session → coded error
|
|
375
382
|
throw new Error('warmup-fb: search box not found — FB layout/login issue (cannot warm up without searching)');
|
|
376
383
|
}
|
|
377
384
|
log('info', `[warmup-fb] searching: "${kw}"`);
|