channel-worker 2.5.56 → 2.5.57
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 +38 -2
- package/lib/nst-manager.js +25 -29
- package/package.json +1 -1
- package/scripts/lib/dom-pick.js +53 -0
- package/scripts/lib/human.js +147 -0
- package/scripts/nurture_facebook.js +222 -110
- package/scripts/upload_facebook.js +93 -66
- package/scripts/upload_facebook_photo.js +19 -17
- package/scripts/warmup_facebook.js +58 -65
- package/scripts/warmup_youtube.js +60 -19
package/lib/command-poller.js
CHANGED
|
@@ -321,6 +321,27 @@ class CommandPoller {
|
|
|
321
321
|
// Always release the per-profile mutex — even on throw — or sibling
|
|
322
322
|
// pw cmds for the same profile would hang forever.
|
|
323
323
|
if (this._pwInFlight) this._pwInFlight.delete(profileId);
|
|
324
|
+
|
|
325
|
+
// CLOSE THE BROWSER HERE, not via a round-trip close_profile command.
|
|
326
|
+
// connectOverCDP only DETACHES Playwright, so the NST browser survives the
|
|
327
|
+
// script; the idle-timeout sweep skips it (it only closes profiles listed
|
|
328
|
+
// in _profileLastActivity, which the pw path never writes), and the API's
|
|
329
|
+
// close_profile only fires when the result reaches a terminal status with
|
|
330
|
+
// a live channel — a crash, a worker restart or a deleted channel leaks
|
|
331
|
+
// the profile forever. Sessions touch a DIFFERENT profile every time, so
|
|
332
|
+
// the leak is cumulative.
|
|
333
|
+
//
|
|
334
|
+
// Scoped to the browse-only scripts. Publish keeps its profile open on
|
|
335
|
+
// purpose: one profile serves several platform uploads back-to-back.
|
|
336
|
+
const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
|
|
337
|
+
if (CLOSE_AFTER.has(scriptName) && this.nst) {
|
|
338
|
+
try {
|
|
339
|
+
await this.nst.stopProfile(profileId);
|
|
340
|
+
console.log(`[commands/pw] closed profile ${profileId} after ${scriptName}`);
|
|
341
|
+
} catch (e) {
|
|
342
|
+
console.warn(`[commands/pw] close after ${scriptName} failed: ${e.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
324
345
|
}
|
|
325
346
|
}
|
|
326
347
|
|
|
@@ -1243,8 +1264,23 @@ class CommandPoller {
|
|
|
1243
1264
|
const { profile_id } = command.payload || {};
|
|
1244
1265
|
console.log(`[commands] Closing profile: ${profile_id}`);
|
|
1245
1266
|
try {
|
|
1246
|
-
|
|
1247
|
-
|
|
1267
|
+
// NstManager exposes stopProfile — there has never been a closeProfile.
|
|
1268
|
+
// The old `typeof this.nst.closeProfile === 'function'` guard made every
|
|
1269
|
+
// close_profile command a silent no-op that still reported 'done', so
|
|
1270
|
+
// browsers accumulated until the machine crawled (measured 2026-08-22 on
|
|
1271
|
+
// win-worker: 22 profiles / 203 nstchrome procs / 18.4 GB).
|
|
1272
|
+
if (profile_id) {
|
|
1273
|
+
if (!this.nst) {
|
|
1274
|
+
try {
|
|
1275
|
+
const apiKey = await this.api.getSetting('nst_api_key');
|
|
1276
|
+
if (apiKey) { const NstManager = require('./nst-manager'); this.nst = new NstManager(apiKey); }
|
|
1277
|
+
} catch {}
|
|
1278
|
+
}
|
|
1279
|
+
if (!this.nst) {
|
|
1280
|
+
console.warn('[commands] close_profile skipped — NST API key not configured');
|
|
1281
|
+
} else {
|
|
1282
|
+
await this.nst.stopProfile(profile_id).catch((e) => console.warn(`[commands] nst.stopProfile failed: ${e.message}`));
|
|
1283
|
+
}
|
|
1248
1284
|
}
|
|
1249
1285
|
} finally {
|
|
1250
1286
|
// Release the lease so another machine can pick up the profile if
|
package/lib/nst-manager.js
CHANGED
|
@@ -110,12 +110,21 @@ class NstManager {
|
|
|
110
110
|
|
|
111
111
|
const platform = (options.os || 'windows').toLowerCase() === 'mac' ? 'MacOS' : 'Windows';
|
|
112
112
|
console.log(`[nst] WARNING: Profile "${name}" NOT FOUND after 3 retries — creating new profile (${platform})...`);
|
|
113
|
+
// Only pin the noise flags. Everything else (CPU count, RAM, kernel
|
|
114
|
+
// version, UA, locale/timezone) is left to Nstbrowser's own randomiser.
|
|
115
|
+
// Previously this hard-coded hardwareConcurrency=8 / deviceMemory=8 /
|
|
116
|
+
// kernel 132 / en-US for EVERY profile, and NST honoured it — measured
|
|
117
|
+
// 2026-08-22 on win-worker: 22 running browsers, 22/22 identical on
|
|
118
|
+
// (chrome, cpu, ram, language, timezone, screen). Same machine fingerprint
|
|
119
|
+
// across a fleet of accounts is exactly what lets a platform cluster them.
|
|
120
|
+
// localization 'BasedOnProxy' lets NST derive locale + timezone from the
|
|
121
|
+
// proxy's geo, so a VN account on a VN proxy stops claiming it only speaks
|
|
122
|
+
// English. All FB/YT selectors are already bilingual (vi + en).
|
|
113
123
|
const res = await this.api('/profiles', {
|
|
114
124
|
method: 'POST',
|
|
115
125
|
body: JSON.stringify({
|
|
116
126
|
name,
|
|
117
127
|
platform,
|
|
118
|
-
kernelMilestone: '132',
|
|
119
128
|
fingerprint: {
|
|
120
129
|
flags: {
|
|
121
130
|
audio: 'Noise',
|
|
@@ -123,15 +132,7 @@ class NstManager {
|
|
|
123
132
|
fonts: 'Masked',
|
|
124
133
|
gpu: 'Allow',
|
|
125
134
|
webgl: 'Noise',
|
|
126
|
-
localization: '
|
|
127
|
-
},
|
|
128
|
-
hardwareConcurrency: 8,
|
|
129
|
-
deviceMemory: 8,
|
|
130
|
-
localization: {
|
|
131
|
-
basedOnProxy: false,
|
|
132
|
-
languages: ['en-US', 'en'],
|
|
133
|
-
locale: 'en-US',
|
|
134
|
-
timezone: 'America/New_York',
|
|
135
|
+
localization: 'BasedOnProxy',
|
|
135
136
|
},
|
|
136
137
|
},
|
|
137
138
|
}),
|
|
@@ -201,28 +202,17 @@ class NstManager {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
fingerprint: {
|
|
212
|
-
flags: { localization: 'Custom' },
|
|
213
|
-
localization: { basedOnProxy: false, languages: ['en-US', 'en'], locale: 'en-US' },
|
|
214
|
-
},
|
|
215
|
-
}),
|
|
216
|
-
});
|
|
217
|
-
if (res.ok) { console.log(`[nst] Profile language set to en-US Custom (${method})`); break; }
|
|
218
|
-
}
|
|
219
|
-
} catch {}
|
|
220
|
-
|
|
205
|
+
// NOTE: this used to PATCH every profile back to en-US "Custom" right
|
|
206
|
+
// before launch (and pass --lang=en-US), which overrode whatever the
|
|
207
|
+
// profile had been given by hand and made 22/22 running browsers claim
|
|
208
|
+
// they only speak English while sitting on VN proxies with VN accounts.
|
|
209
|
+
// Removed 2026-08-22 — locale now follows the profile/proxy. Facebook and
|
|
210
|
+
// YouTube Studio render in the ACCOUNT's language regardless of the
|
|
211
|
+
// browser, and every selector in scripts/ is bilingual anyway.
|
|
221
212
|
const connectConfig = {
|
|
222
213
|
headless: false,
|
|
223
214
|
autoClose: false,
|
|
224
215
|
args: {
|
|
225
|
-
'--lang': 'en-US',
|
|
226
216
|
'--disable-features': 'Translate',
|
|
227
217
|
},
|
|
228
218
|
};
|
|
@@ -231,7 +221,13 @@ class NstManager {
|
|
|
231
221
|
if (options.proxy) {
|
|
232
222
|
await this.setProxy(profileId, options.proxy);
|
|
233
223
|
connectConfig.proxy = options.proxy;
|
|
234
|
-
|
|
224
|
+
// Only the worker's own API + loopback bypass the proxy. `*.amazonaws.com`
|
|
225
|
+
// used to be here too, which sent every in-browser request to any AWS
|
|
226
|
+
// host (Facebook/YouTube serve plenty of media off AWS CDNs) out the REAL
|
|
227
|
+
// IP alongside the proxied ones — a same-session IP leak. Video/thumbnail
|
|
228
|
+
// downloads don't go through the browser (lib/download.js uses node http),
|
|
229
|
+
// so nothing here depends on the AWS bypass.
|
|
230
|
+
connectConfig.args['--proxy-bypass-list'] = 'api.channel.tunasm.art,localhost,127.0.0.1';
|
|
235
231
|
console.log(`[nst] Proxy: ${options.proxy} (saved to profile + bypass: api.channel.tunasm.art)`);
|
|
236
232
|
}
|
|
237
233
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Pick an element inside the page WITHOUT leaving a trace in the DOM.
|
|
2
|
+
//
|
|
3
|
+
// The old pattern was: page.evaluate(() => el.setAttribute('__nur_like__','1'))
|
|
4
|
+
// then page.locator("[__nur_like__='1']").click(), then remove the attribute.
|
|
5
|
+
// That writes a custom attribute with a self-describing name onto Facebook's
|
|
6
|
+
// own DOM for a few seconds — right on the button that is about to be clicked.
|
|
7
|
+
// Facebook instruments its DOM (MutationObserver integrity checks); a foreign
|
|
8
|
+
// attribute with a stable prefix is a fingerprint, and `data-nur-watched=done`
|
|
9
|
+
// was never removed at all.
|
|
10
|
+
//
|
|
11
|
+
// This helper runs the same picking logic but returns the element as a
|
|
12
|
+
// Playwright ElementHandle via evaluateHandle. Nothing is written to the page;
|
|
13
|
+
// handle.click() / hover() are the same trusted input events a locator sends.
|
|
14
|
+
//
|
|
15
|
+
// pick(page, fn, arg) → { el: ElementHandle|null, data: {...} }
|
|
16
|
+
// fn runs in the page and must return { el: Element|null, ...anything }.
|
|
17
|
+
// Everything except `el` comes back JSON-serialised in `data`.
|
|
18
|
+
const { humanClick } = require('./human');
|
|
19
|
+
|
|
20
|
+
async function pick(page, fn, arg) {
|
|
21
|
+
let h;
|
|
22
|
+
try {
|
|
23
|
+
h = await page.evaluateHandle(fn, arg);
|
|
24
|
+
} catch {
|
|
25
|
+
return { el: null, data: {} };
|
|
26
|
+
}
|
|
27
|
+
let el = null, data = {};
|
|
28
|
+
try {
|
|
29
|
+
const prop = await h.getProperty('el');
|
|
30
|
+
el = prop.asElement();
|
|
31
|
+
if (!el) await prop.dispose().catch(() => {});
|
|
32
|
+
data = await h.evaluate((o) => { const { el: _e, ...rest } = o || {}; return rest; }).catch(() => ({}));
|
|
33
|
+
} finally {
|
|
34
|
+
await h.dispose().catch(() => {});
|
|
35
|
+
}
|
|
36
|
+
return { el, data };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Click an ElementHandle: scroll into view, cursor travels there (lib/human),
|
|
40
|
+
// hover a beat, click off-centre. Always disposes the handle.
|
|
41
|
+
async function clickHandle(page, el, { hoverMs = [400, 1200], timeout = 4000, scroll = true } = {}) {
|
|
42
|
+
if (!el) return false;
|
|
43
|
+
try {
|
|
44
|
+
await humanClick(page, el, { hoverMs, timeout, scroll });
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
} finally {
|
|
49
|
+
await el.dispose().catch(() => {});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
module.exports = { pick, clickHandle };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Human-shaped input for the Playwright scripts. One place, used by every
|
|
2
|
+
// browse/publish script so the fleet shares one behaviour instead of five
|
|
3
|
+
// slightly different robots.
|
|
4
|
+
//
|
|
5
|
+
// What the old code looked like to a platform, measured 2026-08-22:
|
|
6
|
+
// - mouse.move(x, y) with no steps → the cursor TELEPORTS. A session had
|
|
7
|
+
// 1-2 moves total, each landing dead-centre on the button it then clicked.
|
|
8
|
+
// - locator.click() → always the exact centre of the bounding box.
|
|
9
|
+
// - mouse.wheel(0, 250..900) → ONE wheel event per scroll. A real wheel
|
|
10
|
+
// sends a burst of ~100-120px notches; a trackpad sends dozens of tiny ones.
|
|
11
|
+
// - keyboard.type(ch) + uniform 60-160ms → no pauses at word boundaries,
|
|
12
|
+
// never a typo, a flat distribution no human produces.
|
|
13
|
+
// - waitForTimeout(4000) → the same 4000 every run.
|
|
14
|
+
//
|
|
15
|
+
// Everything here is plain Playwright input (trusted events). Nothing touches
|
|
16
|
+
// the page's DOM.
|
|
17
|
+
|
|
18
|
+
function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
19
|
+
function rand(min, max) { return min + Math.random() * (max - min); }
|
|
20
|
+
function chance(p) { return Math.random() < p; }
|
|
21
|
+
|
|
22
|
+
// Log-normal-ish: most values near `mid`, a long tail to the right (the
|
|
23
|
+
// "thinking pause"), hard floor at `min`.
|
|
24
|
+
function skewed(min, mid, max) {
|
|
25
|
+
const u = Math.random(), v = Math.random();
|
|
26
|
+
const z = Math.sqrt(-2 * Math.log(u || 1e-9)) * Math.cos(2 * Math.PI * v); // N(0,1)
|
|
27
|
+
const x = mid * Math.exp(0.45 * z);
|
|
28
|
+
return Math.max(min, Math.min(max, x));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A fixed wait turned into a jittered one: 4000 → 2800..5600.
|
|
32
|
+
function jit(ms, lo = 0.7, hi = 1.4) { return Math.round(ms * rand(lo, hi)); }
|
|
33
|
+
async function pause(page, ms, lo, hi) { await page.waitForTimeout(jit(ms, lo, hi)); }
|
|
34
|
+
|
|
35
|
+
// Playwright doesn't expose where the cursor is, so remember it per page.
|
|
36
|
+
const POS = new WeakMap();
|
|
37
|
+
function cursorOf(page) {
|
|
38
|
+
if (!POS.has(page)) POS.set(page, { x: randInt(200, 600), y: randInt(200, 500) });
|
|
39
|
+
return POS.get(page);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Move along a quadratic Bézier with a random control point, variable step
|
|
43
|
+
// timing, a small overshoot on long hops, and a settle wobble at the end.
|
|
44
|
+
async function humanMove(page, x, y) {
|
|
45
|
+
const from = cursorOf(page);
|
|
46
|
+
const dist = Math.hypot(x - from.x, y - from.y);
|
|
47
|
+
if (dist < 2) return;
|
|
48
|
+
const steps = Math.max(8, Math.min(45, Math.round(dist / 12) + randInt(-3, 6)));
|
|
49
|
+
// Control point off the straight line, proportional to distance.
|
|
50
|
+
const bend = dist * rand(0.08, 0.25) * (chance(0.5) ? 1 : -1);
|
|
51
|
+
const mx = (from.x + x) / 2, my = (from.y + y) / 2;
|
|
52
|
+
const nx = -(y - from.y) / dist, ny = (x - from.x) / dist; // unit normal
|
|
53
|
+
const cx = mx + nx * bend, cy = my + ny * bend;
|
|
54
|
+
// Long hops overshoot a little and come back.
|
|
55
|
+
const over = dist > 250 && chance(0.6) ? rand(0.02, 0.06) : 0;
|
|
56
|
+
const tx = x + (x - from.x) * over, ty = y + (y - from.y) * over;
|
|
57
|
+
for (let i = 1; i <= steps; i++) {
|
|
58
|
+
// Ease-in-out so the cursor accelerates then brakes.
|
|
59
|
+
let t = i / steps; t = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
|
|
60
|
+
const px = (1 - t) * (1 - t) * from.x + 2 * (1 - t) * t * cx + t * t * tx;
|
|
61
|
+
const py = (1 - t) * (1 - t) * from.y + 2 * (1 - t) * t * cy + t * t * ty;
|
|
62
|
+
await page.mouse.move(px + rand(-0.8, 0.8), py + rand(-0.8, 0.8)).catch(() => {});
|
|
63
|
+
if (i < steps) await page.waitForTimeout(randInt(4, 18));
|
|
64
|
+
}
|
|
65
|
+
if (over) {
|
|
66
|
+
await page.waitForTimeout(randInt(30, 90));
|
|
67
|
+
for (let i = 1; i <= 4; i++) {
|
|
68
|
+
const t = i / 4;
|
|
69
|
+
await page.mouse.move(tx + (x - tx) * t, ty + (y - ty) * t).catch(() => {});
|
|
70
|
+
await page.waitForTimeout(randInt(8, 20));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
POS.set(page, { x, y });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Where inside a box a person clicks: near the middle, but not AT it.
|
|
77
|
+
// Horizontal spread wider than vertical (text buttons are wide).
|
|
78
|
+
function pointIn(box) {
|
|
79
|
+
const gx = Math.max(-0.38, Math.min(0.38, skewed(0, 0.12, 0.38) * (chance(0.5) ? 1 : -1)));
|
|
80
|
+
const gy = Math.max(-0.3, Math.min(0.3, skewed(0, 0.1, 0.3) * (chance(0.5) ? 1 : -1)));
|
|
81
|
+
return { x: box.x + box.width * (0.5 + gx), y: box.y + box.height * (0.5 + gy) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Click an ElementHandle or Locator: scroll into view, travel there, hover a
|
|
85
|
+
// beat, press/release with a human hold time. Falls back to target.click()
|
|
86
|
+
// when the box can't be read (detached, zero-size) so callers keep working.
|
|
87
|
+
async function humanClick(page, target, { hoverMs = [250, 900], timeout = 4000, scroll = true } = {}) {
|
|
88
|
+
if (scroll) await target.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
|
|
89
|
+
const box = await target.boundingBox().catch(() => null);
|
|
90
|
+
if (!box || box.width < 2 || box.height < 2) {
|
|
91
|
+
await target.click({ timeout });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const p = pointIn(box);
|
|
95
|
+
await humanMove(page, p.x, p.y);
|
|
96
|
+
await page.waitForTimeout(randInt(hoverMs[0], hoverMs[1]));
|
|
97
|
+
// Tiny drift while "deciding" — people rarely hold a pixel.
|
|
98
|
+
if (chance(0.5)) await page.mouse.move(p.x + rand(-1.5, 1.5), p.y + rand(-1.5, 1.5)).catch(() => {});
|
|
99
|
+
await page.mouse.down();
|
|
100
|
+
await page.waitForTimeout(randInt(55, 140));
|
|
101
|
+
await page.mouse.up();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Scroll by ~`dy` the way a wheel does it: a burst of notches that starts
|
|
105
|
+
// quick and decelerates, with an occasional mid-burst hesitation. Direction
|
|
106
|
+
// follows the sign of dy. Returns the total delta actually sent.
|
|
107
|
+
async function humanWheel(page, dy) {
|
|
108
|
+
const dir = dy < 0 ? -1 : 1;
|
|
109
|
+
let left = Math.abs(dy), sent = 0;
|
|
110
|
+
const notch = randInt(80, 140);
|
|
111
|
+
let n = 0;
|
|
112
|
+
while (left > 0) {
|
|
113
|
+
// First notches full size, tail ones shrink (flick + coast).
|
|
114
|
+
const k = n < 3 ? 1 : Math.max(0.25, 1 - (n - 2) * rand(0.12, 0.22));
|
|
115
|
+
const d = Math.min(left, Math.round(notch * k * rand(0.85, 1.15)));
|
|
116
|
+
if (d <= 0) break;
|
|
117
|
+
await page.mouse.wheel(0, dir * d).catch(() => {});
|
|
118
|
+
sent += d; left -= d; n++;
|
|
119
|
+
await page.waitForTimeout(n < 3 ? randInt(15, 45) : randInt(30, 110));
|
|
120
|
+
if (chance(0.08)) await page.waitForTimeout(randInt(150, 450)); // hand hesitates
|
|
121
|
+
if (n > 14) break; // never a 40-notch burst
|
|
122
|
+
}
|
|
123
|
+
return sent * dir;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Type with a human cadence: skewed per-key delay, longer pauses after a
|
|
127
|
+
// space / punctuation, a rare typo fixed with Backspace. Uses keyboard.type
|
|
128
|
+
// for the character itself so IME/Unicode (Vietnamese) goes through unchanged.
|
|
129
|
+
async function humanType(page, text, { typo = 0.025, base = 95 } = {}) {
|
|
130
|
+
for (let i = 0; i < text.length; i++) {
|
|
131
|
+
const ch = text[i];
|
|
132
|
+
if (typo > 0 && chance(typo) && /[a-z]/i.test(ch)) {
|
|
133
|
+
const wrong = String.fromCharCode(ch.charCodeAt(0) + (chance(0.5) ? 1 : -1));
|
|
134
|
+
await page.keyboard.type(wrong);
|
|
135
|
+
await page.waitForTimeout(skewed(120, 260, 700));
|
|
136
|
+
await page.keyboard.press('Backspace');
|
|
137
|
+
await page.waitForTimeout(skewed(80, 160, 400));
|
|
138
|
+
}
|
|
139
|
+
await page.keyboard.type(ch);
|
|
140
|
+
let d = skewed(35, base, 420);
|
|
141
|
+
if (ch === ' ') d += skewed(0, 60, 350);
|
|
142
|
+
else if (/[.,!?;:\n]/.test(ch)) d += skewed(80, 220, 900);
|
|
143
|
+
await page.waitForTimeout(Math.round(d));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = { randInt, rand, chance, skewed, jit, pause, cursorOf, humanMove, humanClick, humanWheel, humanType };
|