channel-worker 2.5.55 → 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/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,nurture_facebook_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,fetch_facebook_reel_stats_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
 
@@ -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
- if (profile_id && this.nst && typeof this.nst.closeProfile === 'function') {
1247
- await this.nst.closeProfile(profile_id).catch((e) => console.warn(`[commands] nst.closeProfile failed: ${e.message}`));
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
@@ -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: 'Custom',
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
- // Update profile language to en-US (Custom) before launch
205
- try {
206
- for (const method of ['PATCH', 'PUT']) {
207
- const res = await fetch(`${this.baseUrl}/profiles/${profileId}`, {
208
- method,
209
- headers: { 'Content-Type': 'application/json', 'x-api-key': this.apiKey },
210
- body: JSON.stringify({
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
- connectConfig.args['--proxy-bypass-list'] = 'api.channel.tunasm.art,*.amazonaws.com,localhost,127.0.0.1';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.55",
3
+ "version": "2.5.57",
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": {
@@ -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 };