channel-worker 2.5.150 → 2.5.152

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
@@ -1,7 +1,21 @@
1
+ // Final-result reports (done / failed / pending) ride out an API restart:
2
+ // one lost PUT left the command 'running' until the stale cleanup marked it
3
+ // "Timed out — có thể bài ĐÃ lên" and the scheduler stopped retrying the
4
+ // idea. ~2.5 minutes covers a pm2 restart with room to spare.
5
+ const REPORT_RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000, 30000, 60000];
6
+
7
+ // The API never answered: connection refused/reset, a gateway 502/503/504, or
8
+ // a non-JSON page while it restarts. An answer from the API itself — success:
9
+ // false, even a 500 from a handler — is final: repeating it changes nothing.
10
+ class ApiUnreachableError extends Error {}
11
+ const GATEWAY_STATUSES = new Set([502, 503, 504]);
12
+
1
13
  class ApiClient {
2
14
  constructor(baseUrl, workerToken) {
3
15
  this.baseUrl = baseUrl.replace(/\/$/, '');
4
16
  this.workerToken = workerToken || '';
17
+ this.reportRetryDelaysMs = REPORT_RETRY_DELAYS_MS;
18
+ this.sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
5
19
  }
6
20
 
7
21
  async request(method, path, body = null) {
@@ -15,8 +29,21 @@ class ApiClient {
15
29
  };
16
30
  if (body) options.body = JSON.stringify(body);
17
31
 
18
- const res = await fetch(url, options);
19
- const json = await res.json();
32
+ let res;
33
+ try {
34
+ res = await fetch(url, options);
35
+ } catch (err) {
36
+ throw new ApiUnreachableError(`API unreachable: ${err.message}`);
37
+ }
38
+ let json;
39
+ try {
40
+ json = await res.json();
41
+ } catch {
42
+ throw new ApiUnreachableError(`API error ${res.status} (no JSON body)`);
43
+ }
44
+ if (GATEWAY_STATUSES.has(res.status) && !json?.success) {
45
+ throw new ApiUnreachableError(json?.message || `API error ${res.status}`);
46
+ }
20
47
 
21
48
  if (!json.success) {
22
49
  throw new Error(json.message || `API error ${res.status}`);
@@ -24,6 +51,21 @@ class ApiClient {
24
51
  return json.data;
25
52
  }
26
53
 
54
+ // Same request, retried while the API is unreachable. Only for writes that
55
+ // are safe to repeat — every report here sets a final state.
56
+ async reportRequest(method, path, body) {
57
+ for (let attempt = 0; ; attempt++) {
58
+ try {
59
+ return await this.request(method, path, body);
60
+ } catch (err) {
61
+ const delay = this.reportRetryDelaysMs[attempt];
62
+ if (!(err instanceof ApiUnreachableError) || delay === undefined) throw err;
63
+ console.warn(`[api] ${method} ${path} failed (${err.message}) — retry ${attempt + 1}/${this.reportRetryDelaysMs.length} in ${Math.round(delay / 1000)}s`);
64
+ await this.sleep(delay);
65
+ }
66
+ }
67
+ }
68
+
27
69
  // Worker
28
70
  async register(workerData) {
29
71
  return this.request('POST', '/workers/register', workerData);
@@ -79,8 +121,12 @@ class ApiClient {
79
121
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
80
122
  }
81
123
 
124
+ // A 'running' touch is a liveness beat: NOT retried — a beat landing late
125
+ // would overwrite the final 'done' that followed it.
82
126
  async updateCommand(commandId, data) {
83
- return this.request('PUT', `/workers/commands/${commandId}`, data);
127
+ const path = `/workers/commands/${commandId}`;
128
+ if (data?.status === 'running') return this.request('PUT', path, data);
129
+ return this.reportRequest('PUT', path, data);
84
130
  }
85
131
 
86
132
  // Report a command result via the extension-style endpoint, which also
@@ -88,7 +134,7 @@ class ApiClient {
88
134
  // idea UI reflects pw-pipeline outcomes the same way it does for extension
89
135
  // uploads). Use this for upload_*_pw results.
90
136
  async postCommandResult(commandId, { status, result, error } = {}) {
91
- return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
137
+ return this.reportRequest('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
92
138
  }
93
139
 
94
140
  // Shopee scraper callbacks — worker ships parsed products, API upserts into
@@ -222,4 +268,4 @@ class ApiClient {
222
268
  }
223
269
  }
224
270
 
225
- module.exports = { ApiClient };
271
+ module.exports = { ApiClient, ApiUnreachableError, REPORT_RETRY_DELAYS_MS };
@@ -2,12 +2,21 @@ const { NstManager } = require('./nst-manager');
2
2
  const { refreshPinnedAgentAddress } = require('./nst-agent-locator');
3
3
  const { checkAndUpdateExtension } = require('./extension-updater');
4
4
  const { StatsSyncer } = require('./stats-syncer');
5
+ const { ApiUnreachableError } = require('./api-client');
5
6
 
6
7
  // Quãng RẢNH trước khi đóng profile của luồng đăng bài. Phải đủ dài để lượt
7
8
  // upload nền tảng kế tiếp (facebook → youtube → tiktok của cùng một idea) kịp
8
9
  // tới và dùng lại browser, nhưng không được để mở qua đêm.
9
10
  const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
10
11
 
12
+ // Quãng rảnh trước khi đóng profile sau một lượt THẢ BÌNH LUẬN. Trước
13
+ // 07/10/2026 lượt thả đóng profile ngay, nên lấy link + thả món 2, món 3 của
14
+ // cùng bài là mỗi món mở lại profile từ đầu — ~10-12s mỗi lần trên win-worker
15
+ // (đo prod 06/10: launch → connectOverCDP mất 9-12s trong lượt 50-80s). Một
16
+ // phút đủ cho người chọn món tiếp; ngắn hơn hẳn 5 phút của lượt đăng bài vì
17
+ // bình luận rải rác trên nhiều kênh, giữ lâu là chất đống browser.
18
+ const COMMENT_IDLE_CLOSE_MS = Number(process.env.COMMENT_IDLE_CLOSE_MS || 60 * 1000);
19
+
11
20
  // Người bấm "Mở trình duyệt" trên web → profile đó là của NGƯỜI trong quãng
12
21
  // này, automation không được đóng ngang. Trước 16/09/2026 không có hàng rào
13
22
  // nào: hẹn đóng 300s của lượt đăng bài trước đó vẫn chạy và đóng profile ngay
@@ -284,11 +293,23 @@ class CommandPoller {
284
293
  )), ms);
285
294
  });
286
295
  try {
287
- const patch = await Promise.race([job, deadline]);
288
- await this.api.updateCommand(command._id, patch);
289
- } catch (err) {
290
- console.error(`${failLog}: ${err.message}`);
291
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
296
+ let patch;
297
+ try {
298
+ patch = await Promise.race([job, deadline]);
299
+ } catch (err) {
300
+ console.error(`${failLog}: ${err.message}`);
301
+ patch = { status: 'failed', error: String(err.message || err).slice(0, 500) };
302
+ }
303
+ // Outside the work's try: a report that cannot reach the API must not
304
+ // turn a finished job into 'failed'.
305
+ try {
306
+ await this.api.updateCommand(command._id, patch);
307
+ } catch (err) {
308
+ console.error(`${failLog}: ${patch?.status} NOT reported: ${err.message}`);
309
+ if (!(err instanceof ApiUnreachableError) && patch?.status !== 'failed') {
310
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) }).catch(() => {});
311
+ }
312
+ }
292
313
  } finally {
293
314
  clearTimeout(timer);
294
315
  abandoned = true;
@@ -664,7 +685,7 @@ class CommandPoller {
664
685
  // Lượt mới của cùng profile → huỷ hẹn đóng, giữ browser cho nó dùng lại.
665
686
  // Nhớ lại là CÓ hẹn: browser đó do automation mở, lượt này xong phải hẹn
666
687
  // đóng lại (xem nhánh runInfo.attached ở cuối hàm).
667
- const hadCloseTimer = !!(this._pwCloseTimers && this._pwCloseTimers.has(profileId));
688
+ let hadCloseTimer = !!(this._pwCloseTimers && this._pwCloseTimers.has(profileId));
668
689
  if (this._pwCloseTimers && this._pwCloseTimers.has(profileId)) {
669
690
  clearTimeout(this._pwCloseTimers.get(profileId));
670
691
  this._pwCloseTimers.delete(profileId);
@@ -696,6 +717,11 @@ class CommandPoller {
696
717
  }
697
718
  await new Promise(r => setTimeout(r, 5000));
698
719
  }
720
+ // Lượt trước của CÙNG profile vừa xong trong lúc mình xếp hàng thì nó hẹn
721
+ // đóng SAU lúc mình hỏi ở đầu hàm. Huỷ hẹn đó và nhớ là có: để nguyên thì
722
+ // hẹn ấy nổ giữa lượt này (thấy profile bận, tự bỏ), còn cuối lượt mình
723
+ // tưởng browser là của người (nhánh runInfo.attached) → profile mở mãi.
724
+ if (this._cancelPwClose(profileId)) hadCloseTimer = true;
699
725
  this._pwInFlight.set(profileId, command.type);
700
726
  console.log(`[commands/pw] ${command.type} → scripts/${scriptName}.js (profile=${profileId})`);
701
727
 
@@ -747,23 +773,43 @@ class CommandPoller {
747
773
  }, RUN_BEAT_MS);
748
774
 
749
775
  try {
750
- const result = await runPlaywrightScript({
751
- nst: this.nst,
752
- profileId,
753
- scriptName,
754
- payload,
755
- log,
756
- keepOpen: keepOpen.enabled,
757
- info: runInfo,
758
- });
759
- // postCommandResult (not updateCommand) so the existing extension-side
760
- // logic mirrors status into ContentJob + ContentIdea.publish_results +
761
- // broadcasts the WS update — pw and extension paths share idea-state UX.
762
- await this.api.postCommandResult(command._id, { status: 'done', result: result || {} });
763
- console.log(`[commands/pw] ${command.type} done`);
764
- } catch (err) {
765
- console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
766
- await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
776
+ let outcome;
777
+ try {
778
+ const result = await runPlaywrightScript({
779
+ nst: this.nst,
780
+ profileId,
781
+ scriptName,
782
+ payload,
783
+ log,
784
+ keepOpen: keepOpen.enabled,
785
+ info: runInfo,
786
+ });
787
+ outcome = { status: 'done', result: result || {} };
788
+ console.log(`[commands/pw] ${command.type} done`);
789
+ } catch (err) {
790
+ console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
791
+ outcome = { status: 'failed', error: String(err.message || err).slice(0, 500) };
792
+ }
793
+ // Stop the 'running' beat first: a beat landing after the result would
794
+ // put the command back to 'running'.
795
+ clearInterval(beat);
796
+ // Reported once, outside the script's try: a report that cannot reach
797
+ // the API used to fall into the catch above and report the posted video
798
+ // as 'failed'. postCommandResult (not updateCommand) so the existing
799
+ // extension-side logic mirrors status into ContentJob +
800
+ // ContentIdea.publish_results + broadcasts the WS update — pw and
801
+ // extension paths share idea-state UX.
802
+ try {
803
+ await this.api.postCommandResult(command._id, outcome);
804
+ } catch (err) {
805
+ console.error(`[commands/pw] ${command.type} ${outcome.status} NOT reported: ${err.message}`);
806
+ // The API answered and refused it: settle the command as failed
807
+ // rather than leave it 'running'. Unreachable after every retry:
808
+ // nothing more can be sent.
809
+ if (!(err instanceof ApiUnreachableError) && outcome.status !== 'failed') {
810
+ await this.api.postCommandResult(command._id, { status: 'failed', error: `API từ chối kết quả: ${String(err.message).slice(0, 300)}` }).catch(() => {});
811
+ }
812
+ }
767
813
  } finally {
768
814
  clearInterval(beat);
769
815
  // Always release the per-profile mutex — even on throw — or sibling
@@ -781,7 +827,7 @@ class CommandPoller {
781
827
  //
782
828
  // Scoped to the browse-only scripts. Publish keeps its profile open on
783
829
  // purpose: one profile serves several platform uploads back-to-back.
784
- const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats', 'comment_facebook']);
830
+ const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
785
831
  // Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
786
832
  // đời đóng-mở bình thường ngay từ lượt này.
787
833
  if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
@@ -797,6 +843,10 @@ class CommandPoller {
797
843
  // automation (vd "Kiểm tra Page ngay" lúc người vừa tạo Page còn ngồi
798
844
  // trong cửa sổ) → không phải browser của mình, để nguyên cho người đó.
799
845
  console.log(`[commands/pw] ${scriptName}: nối vào profile ${profileId} đang mở sẵn — để nguyên, không hẹn đóng`);
846
+ } else if (scriptName === 'comment_facebook' && this.nst) {
847
+ // Hẹn đóng NGẮN thay vì đóng ngay — món kế của cùng bài dùng lại
848
+ // browser này (xem COMMENT_IDLE_CLOSE_MS).
849
+ this._schedulePwClose(profileId, scriptName, COMMENT_IDLE_CLOSE_MS);
800
850
  } else if (CLOSE_AFTER.has(scriptName) && this.nst) {
801
851
  try {
802
852
  await this.nst.stopProfile(profileId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.150",
3
+ "version": "2.5.152",
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": {
@@ -29,7 +29,7 @@
29
29
  // script khai thành công theo thao tác của chính nó, không theo thứ trang web
30
30
  // hiện ra. API hạ mọi kết quả thiếu `verified` xuống 'failed'.
31
31
 
32
- const { assertAccountUsable, readPublishBlock } = require('./lib/fb-guard');
32
+ const { assertAccountUsable, assertSessionOnContentPage, readPublishBlock } = require('./lib/fb-guard');
33
33
  const { humanClick, humanType, pause, randInt } = require('./lib/human');
34
34
 
35
35
  // Chụp màn hình lúc trượt — mượn của upload_facebook, nhưng require MUỘN và có
@@ -255,20 +255,18 @@ async function run({ page, payload = {}, log }) {
255
255
  if (!link) throw new Error('thiếu link — không có gì để đọc lại xác minh');
256
256
  if (!text.includes(link)) throw new Error('text không chứa link — API dựng sai câu bình luận');
257
257
 
258
- // Ghé trang chủ TRƯỚC rồi mới sang bài, dù biết thừa link bài.
258
+ // Vào THẲNG bài, kiểm phiên ngay tại đó.
259
259
  //
260
- // Lý do không kiểm tra phiên ngay trên trang bài: lib/fb-guard đọc chữ trong
261
- // body để đoán checkpoint/khoá tài khoản, mà trang bài thì đầy BÌNH LUẬN của
262
- // người lạ — một người bình luận "tài khoản của bạn đã bị vô hiệu hoá" là đủ
263
- // để script tự kết luận kênh mình chết. Trang chủ có feed thật, fb-guard
264
- // thấy feed là trả 'ok' ngay, không cần đọc chữ.
265
- await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS }).catch(() => {});
266
- await pause(page, 2200);
267
- await assertAccountUsable(page, log);
268
-
260
+ // Bản trước ghé trang chủ trước để fb-guard đọc phiên trên một trang có feed
261
+ // thật: kiểm đủ (đọc chữ trong body) trên trang bài là dính bẫy BÌNH LUẬN của
262
+ // người lạ — ai đó viết "tài khoản của bạn đã bị vô hiệu hoá" là script tự
263
+ // kết luận kênh mình chết. Chuyến ghé đó tốn 5-8s mỗi lượt thả (đo prod
264
+ // 06/10/2026, lượt 50s trên win-worker). assertSessionOnContentPage chỉ xét
265
+ // URL bị đá đi và form đăng nhập — hai thứ người lạ không bình luận ra được.
269
266
  log('info', `[fb-cmt] mở bài ${postUrl}`);
270
267
  await page.goto(postUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT_MS });
271
268
  await pause(page, 2600);
269
+ await assertSessionOnContentPage(page, log);
272
270
 
273
271
  // Trang bài mở ra là trang LỖI / bài đã xoá thì dừng ngay, đừng đi tìm ô soạn
274
272
  // rồi báo "không thấy ô bình luận" — hai chuyện khác hẳn nhau khi đọc log.
@@ -42,13 +42,19 @@ const CHECKPOINT_RE = /(xác nhận danh tính)|(xác minh danh tính)|(hãy xá
42
42
 
43
43
  // Read the account state of whatever page is currently open.
44
44
  // Returns { status: 'ok'|'logged_out'|'checkpoint'|'banned', detail }.
45
- async function readAccountState(page) {
46
- const url = page.url() || '';
47
- // URL is the strongest signal — FB redirects hard on all three states.
45
+ // URL is the strongest signal — FB redirects hard on all three states.
46
+ function readRedirect(url) {
48
47
  if (/\/checkpoint\//i.test(url)) return { status: 'checkpoint', detail: `redirected to ${url.slice(0, 120)}` };
49
48
  if (/facebook\.com\/(login|recover)\b|login\.php|\/login\/device-based/i.test(url)) {
50
49
  return { status: 'logged_out', detail: `redirected to ${url.slice(0, 120)}` };
51
50
  }
51
+ return null;
52
+ }
53
+
54
+ async function readAccountState(page) {
55
+ const url = page.url() || '';
56
+ const redirected = readRedirect(url);
57
+ if (redirected) return redirected;
52
58
 
53
59
  const dom = await page.evaluate(() => {
54
60
  const vis = (el) => {
@@ -95,7 +101,37 @@ async function readAccountState(page) {
95
101
  // checkpoint is common — FB throws one the moment automated-looking activity
96
102
  // trips a threshold).
97
103
  async function assertAccountUsable(page, log) {
98
- const st = await readAccountState(page);
104
+ return throwIfUnusable(await readAccountState(page), log);
105
+ }
106
+
107
+ // Kiểm phiên NGAY TRÊN trang bài/reel, khỏi ghé trang chủ trước — lượt thả
108
+ // bình luận mất 5-8s cho riêng chuyến ghé đó (đo prod 06/10/2026).
109
+ //
110
+ // Chỉ dùng tín hiệu KHÔNG thể đến từ bình luận của người lạ: URL bị đá sang
111
+ // checkpoint/đăng nhập, và form đăng nhập hiện trên trang (trang của người đã
112
+ // đăng nhập không có ô email/mật khẩu). KHÔNG đọc chữ trong body như
113
+ // readAccountState: trang bài đầy chữ người lạ, một bình luận "tài khoản của
114
+ // bạn đã bị vô hiệu hoá" là đủ để kết luận oan kênh mình chết. Form đăng nhập
115
+ // xét mà KHÔNG cần vắng bài: reel công khai vẫn vẽ bài cho người chưa đăng
116
+ // nhập, kèm hộp mời đăng nhập — "có bài" ở đây không chứng minh phiên sống.
117
+ async function readSessionOnContentPage(page) {
118
+ const redirected = readRedirect(page.url() || '');
119
+ if (redirected) return redirected;
120
+ const hasLoginForm = await page.evaluate(() => {
121
+ const el = document.querySelector("input[name='email'], input[name='pass'], input#email, input#pass");
122
+ if (!el) return false;
123
+ const r = el.getBoundingClientRect();
124
+ return r.width > 8 && r.height > 8 && el.offsetParent !== null;
125
+ }).catch(() => false);
126
+ if (hasLoginForm) return { status: 'logged_out', detail: 'login form on post page (session expired)' };
127
+ return { status: 'ok', detail: '' };
128
+ }
129
+
130
+ async function assertSessionOnContentPage(page, log) {
131
+ return throwIfUnusable(await readSessionOnContentPage(page), log);
132
+ }
133
+
134
+ function throwIfUnusable(st, log) {
99
135
  if (st.status === 'ok') return st;
100
136
  const code = CODES[st.status];
101
137
  const human = st.status === 'banned'
@@ -199,6 +235,8 @@ function publishRateLimitError(blockHit) {
199
235
  module.exports = {
200
236
  readAccountState,
201
237
  assertAccountUsable,
238
+ readSessionOnContentPage,
239
+ assertSessionOnContentPage,
202
240
  readProfileMode,
203
241
  classifyProfileModeSignals,
204
242
  pageNotConfiguredError,