channel-worker 2.5.62 → 2.5.66

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/bin/cli.js CHANGED
@@ -117,6 +117,14 @@ if (cmd === 'pair') {
117
117
  nst_api_key: args['nst-key'] || saved.nst_api_key || '',
118
118
  extension_path: args.extension || saved.extension_path || DEFAULT_EXT_PATH,
119
119
  worker_token: args.token || saved.worker_token || '',
120
+ // Multi-user Windows: two sessions on one box collide on these ports.
121
+ // cache_port = this daemon's local cache server; nst_api_address = the
122
+ // Nstbrowser agent belonging to THIS session (2nd session gets 8849+).
123
+ cache_port: parseInt(args['cache-port'] || saved.cache_port || '8849', 10),
124
+ nst_api_address: args['nst-address'] || saved.nst_api_address || '',
125
+ // Windows user whose Nstbrowser agent this daemon drives. Preferred over a
126
+ // hard-coded nst_api_address: the agent's port depends on login order.
127
+ nst_agent_user: args['nst-user'] || saved.nst_agent_user || '',
120
128
  verbose: !!args.verbose,
121
129
  };
122
130
 
@@ -111,6 +111,25 @@ class CacheServer {
111
111
  res.end(JSON.stringify({ success: false, message: 'not found' }));
112
112
  }
113
113
 
114
+ // Is this PID a node process (i.e. plausibly a leaked daemon of ours)?
115
+ // On a multi-user Windows box the port may be held by Nstbrowser's agent.exe
116
+ // — the 2nd session's agent binds 8849 when 8848 is taken. Killing that would
117
+ // take down the whole browser fleet, so only ever kill our own kind.
118
+ _isOwnKind(pid) {
119
+ try {
120
+ if (process.platform === 'win32') {
121
+ const out = execSync(`tasklist /FI "PID eq ${pid}" /NH /FO CSV`,
122
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
123
+ return /^"node\.exe"/i.test(out.trim());
124
+ }
125
+ const out = execSync(`ps -p ${pid} -o comm=`,
126
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
127
+ return /node/i.test(out.trim());
128
+ } catch {
129
+ return false;
130
+ }
131
+ }
132
+
114
133
  // Find PID(s) holding our port and kill them. Cross-platform best-effort.
115
134
  // Used when bind fails with EADDRINUSE — typically a leaked previous daemon.
116
135
  _killPortHolder() {
@@ -124,23 +143,34 @@ class CacheServer {
124
143
  const m = line.trim().match(/\s(\d+)\s*$/);
125
144
  if (m) pids.add(m[1]);
126
145
  }
146
+ let killed = 0;
127
147
  for (const pid of pids) {
128
148
  if (Number(pid) === process.pid) continue;
149
+ if (!this._isOwnKind(pid)) {
150
+ console.warn(`[cache-server] Port ${this.port} held by non-node PID ${pid} `
151
+ + '(Nstbrowser agent?) — NOT killing. Set cache_port to a free port.');
152
+ continue;
153
+ }
129
154
  console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
130
- try { execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' }); } catch {}
155
+ try { execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' }); killed++; } catch {}
131
156
  }
132
- return pids.size;
157
+ return killed;
133
158
  }
134
159
  // mac/linux
135
160
  const out = execSync(`lsof -ti:${this.port} -sTCP:LISTEN`,
136
161
  { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
137
162
  const pids = out.split('\n').map(s => s.trim()).filter(Boolean);
163
+ let killed = 0;
138
164
  for (const pid of pids) {
139
165
  if (Number(pid) === process.pid) continue;
166
+ if (!this._isOwnKind(pid)) {
167
+ console.warn(`[cache-server] Port ${this.port} held by non-node PID ${pid} — NOT killing.`);
168
+ continue;
169
+ }
140
170
  console.log(`[cache-server] Killing leaked PID ${pid} holding port ${this.port}`);
141
- try { execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); } catch {}
171
+ try { execSync(`kill -9 ${pid}`, { stdio: 'ignore' }); killed++; } catch {}
142
172
  }
143
- return pids.length;
173
+ return killed;
144
174
  } catch {
145
175
  return 0;
146
176
  }
package/lib/daemon.js CHANGED
@@ -5,10 +5,15 @@ const { CommandPoller } = require('./command-poller');
5
5
  const { UpdateChecker, getLocalVersion } = require('./updater');
6
6
  const { checkAndUpdateExtension } = require('./extension-updater');
7
7
  const { CacheServer } = require('./cache-server');
8
+ const { agentAddressForUser } = require('./nst-agent-locator');
8
9
 
9
10
  class Daemon {
10
11
  constructor(config) {
11
12
  this.config = config;
13
+ // Point every NstManager instance (11 call sites in command-poller) at the
14
+ // Nstbrowser agent of THIS Windows session. On a multi-user box the second
15
+ // session's agent binds 8849+ because 8848 is taken machine-wide.
16
+ if (config.nst_api_address) process.env.NST_API_ADDRESS = config.nst_api_address;
12
17
  this.api = new ApiClient(config.api_url, config.worker_token);
13
18
  this.heartbeat = new Heartbeat(this.api, config.worker_id, 30000, config);
14
19
  this.poller = new JobPoller(this.api, config);
@@ -18,9 +23,45 @@ class Daemon {
18
23
  this.commandPoller = new CommandPoller(this.api, config);
19
24
  this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
20
25
  this.extCheckTimer = null;
26
+ this.nstAgentTimer = null;
27
+ }
28
+
29
+ /**
30
+ * Point NST_API_ADDRESS at the agent of the Windows user we're paired with.
31
+ *
32
+ * `nst_api_address` (a hard-coded port) still wins when set, but it goes
33
+ * stale the moment login order changes: agents claim 8848, 8849, … first
34
+ * come first served, so yesterday's port can be the other person's agent
35
+ * today. Pinning to `nst_agent_user` and re-reading the port survives that.
36
+ */
37
+ async _resolveNstAgent({ quiet = false } = {}) {
38
+ if (this.config.nst_api_address) return;
39
+ const user = this.config.nst_agent_user;
40
+ if (!user) return;
41
+ let found = null;
42
+ try {
43
+ found = await agentAddressForUser(user);
44
+ } catch (err) {
45
+ if (!quiet) console.warn(`[daemon] NST agent lookup failed: ${err.message}`);
46
+ return;
47
+ }
48
+ if (!found) {
49
+ if (!quiet) {
50
+ console.warn(`[daemon] No Nstbrowser agent for Windows user "${user}" — `
51
+ + 'NST commands will fail until that user is logged in.');
52
+ }
53
+ return;
54
+ }
55
+ if (process.env.NST_API_ADDRESS !== found.address) {
56
+ const before = process.env.NST_API_ADDRESS || '(none)';
57
+ process.env.NST_API_ADDRESS = found.address;
58
+ console.log(`[daemon] NST agent for "${user}": ${found.address} `
59
+ + `(pid ${found.pid}, session ${found.sessionId}/${found.sessionName}) [was ${before}]`);
60
+ }
21
61
  }
22
62
 
23
63
  async start() {
64
+ await this._resolveNstAgent();
24
65
  const version = getLocalVersion();
25
66
  console.log(`
26
67
  ╔══════════════════════════════════════╗
@@ -31,6 +72,8 @@ class Daemon {
31
72
  ║ Concurrent: ${String(this.config.max_concurrent).padEnd(22)}║
32
73
  ╚══════════════════════════════════════╝
33
74
  `);
75
+ console.log(`[daemon] cache server port: ${this.config.cache_port || 8849}`);
76
+ console.log(`[daemon] NST agent: ${process.env.NST_API_ADDRESS || 'http://localhost:8848/api/v2 (default)'}`);
34
77
 
35
78
  // Start local cache server (extension shares this across all profiles)
36
79
  try {
@@ -80,6 +123,14 @@ class Daemon {
80
123
  this.commandPoller.start();
81
124
  console.log('[daemon] Command poller started (every 3s)');
82
125
 
126
+ // Re-read the agent's port — it moves when that user logs out and back in.
127
+ if (this.config.nst_agent_user && !this.config.nst_api_address) {
128
+ this.nstAgentTimer = setInterval(() => {
129
+ this._resolveNstAgent({ quiet: true }).catch(() => {});
130
+ }, 60000);
131
+ console.log(`[daemon] NST agent watcher started (user "${this.config.nst_agent_user}", every 60s)`);
132
+ }
133
+
83
134
  // Start auto-update checker
84
135
  this.updateChecker.start();
85
136
  console.log('[daemon] Auto-update checker started (every 5min)');
@@ -109,6 +160,7 @@ class Daemon {
109
160
  this.commandPoller.stop();
110
161
  this.updateChecker.stop();
111
162
  if (this.extCheckTimer) clearInterval(this.extCheckTimer);
163
+ if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
112
164
  try { this.cacheServer.stop(); } catch {}
113
165
 
114
166
  // Mark offline
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Locate the Nstbrowser agent belonging to a specific Windows user.
3
+ *
4
+ * On a box where two people are logged in at once, each session runs its own
5
+ * `agent.exe`. They pick their port by first-come-first-served: whoever logs
6
+ * in first takes 8848, the next one lands on 8849. So a hard-coded port in
7
+ * config silently points at the OTHER person's agent whenever the login order
8
+ * flips — commands go to the wrong desktop, and two daemons can end up driving
9
+ * the same profile.
10
+ *
11
+ * Pinning to the *user* instead of the port survives that: find agent.exe owned
12
+ * by the user, then read whichever port that PID is listening on.
13
+ */
14
+
15
+ const { execSync } = require('child_process');
16
+
17
+ const sh = (cmd) => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 });
18
+
19
+ // PIDs of agent.exe, optionally narrowed to one owner. Owner filtering happens
20
+ // in tasklist rather than in JS because listing owners needs WMI/CIM, which is
21
+ // an order of magnitude slower and is disabled on some hardened boxes.
22
+ function parseTasklistCsv(out) {
23
+ const pids = [];
24
+ for (const line of String(out).split('\n')) {
25
+ // "agent.exe","37700","RDP-Tcp#103","3","131,584 K"
26
+ const m = line.match(/^"[^"]*","(\d+)","([^"]*)","(\d+)"/);
27
+ if (m) pids.push({ pid: m[1], sessionName: m[2], sessionId: m[3] });
28
+ }
29
+ return pids;
30
+ }
31
+
32
+ function agentPids(username = null) {
33
+ const filters = ['/FI "IMAGENAME eq agent.exe"'];
34
+ if (username) filters.push(`/FI "USERNAME eq ${username}"`);
35
+ try {
36
+ return parseTasklistCsv(sh(`tasklist ${filters.join(' ')} /NH /FO CSV`));
37
+ } catch {
38
+ return [];
39
+ }
40
+ }
41
+
42
+ // Every TCP port in LISTENING state, keyed by the PID holding it.
43
+ function parseListening(out) {
44
+ const byPid = new Map();
45
+ for (const line of String(out).split('\n')) {
46
+ if (!/LISTENING/.test(line)) continue;
47
+ // " TCP 0.0.0.0:8849 0.0.0.0:0 LISTENING 37700" (or "[::]:8848")
48
+ const m = line.match(/^\s*TCP\s+(.+?):(\d+)\s+\S+\s+LISTENING\s+(\d+)\s*$/);
49
+ if (!m) continue;
50
+ const [, host, port, pid] = m;
51
+ if (!byPid.has(pid)) byPid.set(pid, []);
52
+ byPid.get(pid).push({ host, port: Number(port) });
53
+ }
54
+ return byPid;
55
+ }
56
+
57
+ function listeningPortsByPid() {
58
+ try {
59
+ return parseListening(sh('netstat -ano -p TCP'));
60
+ } catch {
61
+ return new Map();
62
+ }
63
+ }
64
+
65
+ /**
66
+ * All Nstbrowser agents running on this machine.
67
+ * → [{ pid, sessionId, sessionName, port, address }]
68
+ */
69
+ function listAgents(username = null) {
70
+ if (process.platform !== 'win32') return [];
71
+ const pids = agentPids(username);
72
+ if (!pids.length) return [];
73
+ const ports = listeningPortsByPid();
74
+ const agents = [];
75
+ for (const p of pids) {
76
+ const bound = ports.get(p.pid) || [];
77
+ // The agent's HTTP API is the lowest port it listens on; it opens a couple
78
+ // of internal ones too, and those sort above the API port in practice.
79
+ const api = bound.map(b => b.port).sort((a, b) => a - b)[0];
80
+ if (!api) continue;
81
+ agents.push({
82
+ pid: p.pid,
83
+ sessionId: p.sessionId,
84
+ sessionName: p.sessionName,
85
+ port: api,
86
+ address: `http://127.0.0.1:${api}/api/v2`,
87
+ });
88
+ }
89
+ return agents;
90
+ }
91
+
92
+ /**
93
+ * The agent belonging to `username`, or null.
94
+ *
95
+ * A port answering on 127.0.0.1 is not proof it is the agent — our own cache
96
+ * server binds loopback explicitly, and Windows routes localhost to the most
97
+ * specific listener, so a wildcard-bound agent can sit behind it invisibly.
98
+ * The caller should verify with probeIsNstAgent() before trusting the address.
99
+ */
100
+ function resolveAgentForUser(username) {
101
+ if (!username) return null;
102
+ const found = listAgents(username);
103
+ if (!found.length) return null;
104
+ // Two agent.exe for one user shouldn't happen; take the lowest port if it does.
105
+ return found.sort((a, b) => a.port - b.port)[0];
106
+ }
107
+
108
+ /**
109
+ * Does `address` actually speak the Nstbrowser API?
110
+ * The agent answers an unauthenticated request with 401; anything else on that
111
+ * port (our cache server, say) answers 404 or refuses the connection.
112
+ */
113
+ async function probeIsNstAgent(address) {
114
+ try {
115
+ const res = await fetch(`${address}/profiles?page=1&limit=1`, { method: 'GET' });
116
+ return res.status === 401 || res.status === 200;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ // This machine's LAN IPv4 — the fallback when loopback is shadowed.
123
+ function lanAddress(port) {
124
+ const os = require('os');
125
+ for (const list of Object.values(os.networkInterfaces() || {})) {
126
+ for (const ni of list || []) {
127
+ if (ni.family === 'IPv4' && !ni.internal) return `http://${ni.address}:${port}/api/v2`;
128
+ }
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /**
134
+ * Reachable API address of `username`'s agent, or null.
135
+ * Tries loopback first, then the LAN address — the agent binds 0.0.0.0, so a
136
+ * loopback-specific listener on the same port (our cache server) hides it from
137
+ * 127.0.0.1 while the LAN address still reaches it.
138
+ */
139
+ async function agentAddressForUser(username) {
140
+ const agent = resolveAgentForUser(username);
141
+ if (!agent) return null;
142
+ for (const addr of [agent.address, lanAddress(agent.port)]) {
143
+ if (addr && await probeIsNstAgent(addr)) return { ...agent, address: addr };
144
+ }
145
+ return null;
146
+ }
147
+
148
+ // listAgents() shells out twice; launches call it on every profile open, so
149
+ // hold the answer briefly. Agents only move port on logout/login.
150
+ let _agentCache = { at: 0, agents: [] };
151
+ function listAgentsCached(ttlMs = 60000, now = Date.now()) {
152
+ if (now - _agentCache.at < ttlMs) return _agentCache.agents;
153
+ const agents = listAgents();
154
+ _agentCache = { at: now, agents };
155
+ return agents;
156
+ }
157
+
158
+ module.exports = {
159
+ listAgents, listAgentsCached, resolveAgentForUser, agentAddressForUser, probeIsNstAgent, lanAddress,
160
+ parseTasklistCsv, parseListening,
161
+ };
@@ -1,3 +1,5 @@
1
+ const { listAgentsCached, lanAddress } = require('./nst-agent-locator');
2
+
1
3
  function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
2
4
  const raw = String(response?.msg || 'Failed to connect browser').trim();
3
5
  if (!/exceeded plan limits/i.test(raw)) return new Error(raw);
@@ -22,7 +24,10 @@ function nstLaunchError(response, { profileId = '', runningCount = 0 } = {}) {
22
24
  class NstManager {
23
25
  constructor(apiKey, options = {}) {
24
26
  this.apiKey = apiKey;
25
- this.baseUrl = options.apiAddress || 'http://localhost:8848/api/v2';
27
+ // Multi-user Windows: each logged-in session runs its OWN Nstbrowser agent,
28
+ // and the second one lands on 8849+ because 8848 is already taken machine-wide.
29
+ // NST_API_ADDRESS lets a daemon target its own session's agent.
30
+ this.baseUrl = options.apiAddress || process.env.NST_API_ADDRESS || 'http://localhost:8848/api/v2';
26
31
  }
27
32
 
28
33
  async api(path, options = {}) {
@@ -190,6 +195,49 @@ class NstManager {
190
195
  }
191
196
  }
192
197
 
198
+ // Which port is THIS manager talking to?
199
+ get _port() {
200
+ const m = String(this.baseUrl).match(/:(\d+)(?:\/|$)/);
201
+ return m ? Number(m[1]) : null;
202
+ }
203
+
204
+ // Ask one peer agent what it currently has open. Tries loopback then LAN:
205
+ // the agent binds 0.0.0.0, so a loopback-specific listener on the same port
206
+ // (another daemon's cache server) can hide it from 127.0.0.1.
207
+ async _peerBrowsers(agent) {
208
+ for (const addr of [agent.address, lanAddress(agent.port)]) {
209
+ if (!addr) continue;
210
+ try {
211
+ const res = await fetch(`${addr}/browsers`, { headers: { 'x-api-key': this.apiKey } });
212
+ if (res.status === 404) continue; // not the agent — something else holds this port
213
+ const data = await res.json();
214
+ if (data && data.data) return data.data;
215
+ } catch { /* try the next address */ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ /**
221
+ * Is `profileId` already open in ANOTHER session's agent on this machine?
222
+ *
223
+ * Both daemons see the same cloud-synced profile list, so both can be told to
224
+ * open the same profile. Letting that happen puts two browsers on one account:
225
+ * whichever closes last writes its cookie jar over the other's, which reads as
226
+ * a random logout. Cheaper to refuse the launch than to re-login the account.
227
+ */
228
+ async findProfileOnPeerAgents(profileId) {
229
+ if (process.platform !== 'win32') return null;
230
+ let agents = [];
231
+ try { agents = listAgentsCached(); } catch { return null; }
232
+ const myPort = this._port;
233
+ for (const a of agents) {
234
+ if (a.port === myPort) continue;
235
+ const list = await this._peerBrowsers(a);
236
+ if (list && list.some(b => b.profileId === profileId)) return a;
237
+ }
238
+ return null;
239
+ }
240
+
193
241
  // Launch browser — skip if already running, set proxy if provided
194
242
  async launchProfile(profileIdOrName, options = {}) {
195
243
  let profileId = profileIdOrName;
@@ -258,6 +306,20 @@ class NstManager {
258
306
  console.log(`[nst] Loading extension: ${options.extensionPath}`);
259
307
  }
260
308
 
309
+ const peer = await this.findProfileOnPeerAgents(profileId);
310
+ if (peer) {
311
+ const err = new Error(
312
+ `NST_PROFILE_ON_PEER_AGENT: profile ${profileId} đang mở ở agent khác trên cùng máy `
313
+ + `(pid ${peer.pid}, session ${peer.sessionId}/${peer.sessionName}, cổng ${peer.port}). `
314
+ + 'Mở song song cùng một profile ở hai phiên Windows sẽ ghi đè cookie của nhau '
315
+ + '→ tài khoản bị đăng xuất. Đóng bên kia trước, hoặc giao việc cho đúng worker.',
316
+ );
317
+ err.code = 'NST_PROFILE_ON_PEER_AGENT';
318
+ err.profileId = profileId;
319
+ err.peerPort = peer.port;
320
+ throw err;
321
+ }
322
+
261
323
  console.log(`[nst] Connecting browser for profile: ${profileId}`);
262
324
  const apiUrl = `${this.baseUrl}/connect/${profileId}?config=${encodeURIComponent(JSON.stringify(connectConfig))}`;
263
325
  const rawRes = await fetch(apiUrl, { headers: { 'x-api-key': this.apiKey } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.62",
3
+ "version": "2.5.66",
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": {
@@ -22,12 +22,22 @@ const path = require('path');
22
22
  const { downloadToTemp, safeUnlink } = require('./lib/download');
23
23
 
24
24
  const FB_HOME_URL = 'https://www.facebook.com/';
25
+ const FB_REEL_CREATE_URL = 'https://www.facebook.com/reels/create/';
25
26
  const REEL_CREATE_SELECTORS = [
26
27
  "a[aria-label='Tạo thước phim']",
27
28
  "a[aria-label='Create reel']",
28
29
  "a[aria-label='Create a reel']",
29
30
  "a[href*='/reels/create']",
30
31
  ];
32
+ const REEL_ENTRY_SELECTORS = [
33
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Thước phim']",
34
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reel']",
35
+ "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reels']",
36
+ "div[role='region'][aria-label='Create post'] [aria-label='Reel']",
37
+ "div[role='region'][aria-label='Create post'] [aria-label='Reels']",
38
+ "div[role='region'][aria-label='Create post'] [aria-label='Create reel']",
39
+ "div[role='region'][aria-label='Create post'] [aria-label='Create a reel']",
40
+ ];
31
41
  // Residential proxies can leave Facebook's final "Đang tải..." state active
32
42
  // well past three minutes. The API only declares a publish command stale after
33
43
  // 20 minutes, so give the real upload enough room instead of failing while FB
@@ -91,6 +101,18 @@ async function navigateFacebookHome(page, log) {
91
101
  throw lastError || new Error('FB home navigation failed');
92
102
  }
93
103
 
104
+ // Some Facebook cohorts render the Page-wall create-post widget as icon-only
105
+ // controls (no Reel text/aria-label). In that layout there is no safe DOM
106
+ // selector that distinguishes the composer trigger from the sidebar Reels feed.
107
+ // Navigate to Facebook's dedicated CREATE route instead. This is only called
108
+ // before a file is selected, so repeating it cannot create a duplicate post.
109
+ async function navigateFacebookReelCreate(page, log) {
110
+ log('warn', '[fb-pw] Reel entry unavailable — hard navigating to /reels/create/ before upload…');
111
+ await page.goto(FB_REEL_CREATE_URL, { waitUntil: 'commit', timeout: 60_000 });
112
+ await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {});
113
+ await pause(page, 7000);
114
+ }
115
+
94
116
  // FB drops a "Thông báo mới" toast at the BOTTOM-LEFT of the wall (e.g. "… và
95
117
  // 96 người khác thích thước phim của bạn") that lands ON TOP of the Reels
96
118
  // composer's bottom CTA. Playwright's hit-target check then blocks every
@@ -513,7 +535,7 @@ async function runOnce({ page, payload, log }) {
513
535
  } = payload || {};
514
536
  if (!video_url) throw new Error('No video_url provided');
515
537
 
516
- log('info', '[fb-pw] selectors version=2026.08.06-composer-retry-metadata-poll');
538
+ log('info', '[fb-pw] selectors version=2026.08.28-icon-only-create-route-fallback');
517
539
 
518
540
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
519
541
 
@@ -625,15 +647,8 @@ async function runOnce({ page, payload, log }) {
625
647
  // instead of opening the composer (observed on the brain-made-simple
626
648
  // page wall — clicked feed link, played random viral reel).
627
649
  log('info', '[fb-pw] click "Thước phim" entry inside "Tạo bài viết"…');
628
- const reelEntryCandidates = [
629
- // Scoped to the create-post widget — this is the only correct trigger.
630
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Thước phim']",
631
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reel']",
632
- "div[role='region'][aria-label='Tạo bài viết'] [aria-label='Reels']",
633
- "div[role='region'][aria-label='Create post'] [aria-label='Reels']",
634
- ];
635
650
  let reelOpened = false;
636
- for (const sel of reelEntryCandidates) {
651
+ for (const sel of REEL_ENTRY_SELECTORS) {
637
652
  const btn = await firstVisible(page.locator(sel), 5);
638
653
  if (!btn) continue;
639
654
  try {
@@ -647,26 +662,46 @@ async function runOnce({ page, payload, log }) {
647
662
  // text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
648
663
  if (!reelOpened) {
649
664
  const probed = await page.evaluate(() => {
650
- const regions = document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết']");
651
- for (const region of regions) {
665
+ // Nhánh aria TỪNG chỉ dò tiếng Việt. Tài khoản để tiếng Anh mà mục
666
+ // Thước phim chỉ có ICON (không chữ) thì `t` rỗng, `al` là "Reel" —
667
+ // trượt cả hai vế, coi như không có mục nào, rơi xuống /reels/create/.
668
+ // Đúng cảnh kênh 6a2e66b3 (profile kenh-26) hỏng suốt từ 27/08.
669
+ const wantText = /^thước phim$|^reels?$/i;
670
+ const wantAria = /^(thước phim|reels?|create (a )?reel|tạo thước phim)$/i;
671
+ const inventory = [];
672
+ let regionSeen = 0;
673
+ const allRegions = [];
674
+ for (const region of document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết']")) {
652
675
  const aria = (region.getAttribute('aria-label') || '').toLowerCase();
676
+ if (allRegions.length < 10 && aria) allRegions.push(aria.slice(0, 30));
653
677
  if (!/tạo bài viết|create post/.test(aria)) continue;
654
- const btns = region.querySelectorAll("[role='button'], button, a");
655
- for (const b of btns) {
678
+ regionSeen++;
679
+ for (const b of region.querySelectorAll("[role='button'], button, a")) {
656
680
  const t = (b.innerText || b.textContent || '').trim();
657
681
  const al = (b.getAttribute('aria-label') || '').trim();
658
- const sig = (t + '|' + al).toLowerCase();
659
- if (/^thước phim|^reels?$/i.test(t.trim()) || /thước phim/i.test(al)) {
660
- const r = b.getBoundingClientRect();
661
- if (r.width < 8 || r.height < 8) continue;
682
+ const r = b.getBoundingClientRect();
683
+ if (r.width < 8 || r.height < 8) continue;
684
+ // Gom lại để nếu vẫn trượt thì log NÓI RA nhãn thật, khỏi đoán mò
685
+ // vòng sau (lần này mất một buổi mới dựng lại được).
686
+ if (inventory.length < 12) inventory.push(`t="${t.slice(0, 24)}" al="${al.slice(0, 24)}"`);
687
+ if (wantText.test(t) || wantAria.test(al)) {
662
688
  b.setAttribute('__fbpw_reel_entry__', '1');
663
689
  return { selector: "[__fbpw_reel_entry__='1']", text: t.slice(0, 40), aria: al.slice(0, 40) };
664
690
  }
665
691
  }
666
692
  }
667
- return null;
693
+ return { miss: true, inventory, regionSeen, allRegions };
668
694
  }).catch(() => null);
669
- if (probed) {
695
+ if (probed && probed.miss) {
696
+ // Phân biệt "có widget mà thiếu mục" với "không thấy widget nào" —
697
+ // hai chuyện cần hai cách xử lý khác hẳn.
698
+ if (!probed.regionSeen) {
699
+ log('warn', `[fb-pw] KHÔNG thấy widget "Tạo bài viết"/"Create post" nào trên trang chủ — các region đang có: ${JSON.stringify(probed.allRegions)}`);
700
+ } else {
701
+ log('warn', `[fb-pw] có widget "Tạo bài viết" nhưng KHÔNG có mục Thước phim — nút đang có: ${JSON.stringify(probed.inventory)}`);
702
+ }
703
+ }
704
+ if (probed && probed.selector) {
670
705
  try {
671
706
  await page.locator(probed.selector).click({ timeout: 3000 });
672
707
  log('info', `[fb-pw] Thước phim clicked via region-probe (text="${probed.text}" aria="${probed.aria}")`);
@@ -676,10 +711,13 @@ async function runOnce({ page, payload, log }) {
676
711
  }
677
712
  }
678
713
  }
714
+ let usedCreateRoute = false;
679
715
  if (!reelOpened) {
680
- await dumpInventory(page, log, 'no-reel-entry');
681
- await dumpFailure(page, 'no-reel-entry', log);
682
- throw new Error('FB home: "Thước phim" entry not found in "Tạo bài viết" widget (sidebar Reels feed link doesn\'t count — it navigates instead of opening composer)');
716
+ // English/icon-only Page layouts expose Live video, Photo/video and Reel
717
+ // as bare SVG controls. The old code failed here even though the official
718
+ // create route was usable. Go there before touching any file input.
719
+ usedCreateRoute = true;
720
+ await navigateFacebookReelCreate(page, log);
683
721
  }
684
722
  await pause(page, 4000);
685
723
  const afterUrl = page.url();
@@ -687,6 +725,17 @@ async function runOnce({ page, payload, log }) {
687
725
  // Sanity check: clicking the sidebar Reels link navigates to /reel/<id>.
688
726
  // That means we clicked the wrong button — fail fast so the user knows.
689
727
  if (/\/reel\/\d+/.test(afterUrl)) {
728
+ // HAI nguyên nhân hoàn toàn khác nhau, trước đây gộp chung một câu báo
729
+ // "wrong button matched" — và câu đó gửi người đọc đi tìm lỗi DOM không
730
+ // hề tồn tại (kênh 6a2e66b3 hỏng suốt từ 27/08 vì hiểu nhầm chỗ này):
731
+ // a) BẤM nhầm nút → đúng là lỗi layout, sửa selector.
732
+ // b) Không có nút nào để bấm, phải đi thẳng /reels/create/, rồi CHÍNH
733
+ // Facebook đá về feed → tài khoản này không mở được trình tạo
734
+ // Thước phim. Sửa selector bao nhiêu cũng vô ích.
735
+ await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
736
+ if (usedCreateRoute) {
737
+ throw new Error(`FB không mở được trình tạo Thước phim cho tài khoản này: widget "Tạo bài viết" không có mục Thước phim, và /reels/create/ bị Facebook đá về feed (${afterUrl}). Đây KHÔNG phải lỗi selector — cần người đăng nhập profile này và thử tạo Thước phim bằng tay (tài khoản có thể chưa đủ điều kiện đăng Reels, hoặc đang đăng nhập sai trang).`);
738
+ }
690
739
  throw new Error(`FB Thước phim click navigated to Reels feed (${afterUrl}) — wrong button matched, should be composer modal. Check "Tạo bài viết" widget layout on this Page.`);
691
740
  }
692
741
 
@@ -748,10 +797,7 @@ async function runOnce({ page, payload, log }) {
748
797
  // stuck client-side router. No video has been selected yet, so this
749
798
  // cannot create a duplicate post.
750
799
  if (!reelsRoot) {
751
- log('warn', '[fb-pw] create link still produced no composer — hard navigating to /reels/create/…');
752
- await page.goto('https://www.facebook.com/reels/create/', { waitUntil: 'commit', timeout: 60_000 });
753
- await page.waitForLoadState('domcontentloaded', { timeout: 60_000 }).catch(() => {});
754
- await pause(page, 7000);
800
+ await navigateFacebookReelCreate(page, log);
755
801
  await dismissBsOnboarding(page, log).catch(() => {});
756
802
  reelsRoot = await findComposerRoot(8000);
757
803
  }
@@ -2721,6 +2767,9 @@ module.exports.__testables = {
2721
2767
  hasVisibleReelComposer,
2722
2768
  SAFE_RETRY_COMPOSER_CLOSED,
2723
2769
  navigateFacebookHome,
2770
+ navigateFacebookReelCreate,
2724
2771
  PUBLISH_ENABLE_TIMEOUT_MS,
2725
2772
  REEL_CREATE_SELECTORS,
2773
+ REEL_ENTRY_SELECTORS,
2774
+ FB_REEL_CREATE_URL,
2726
2775
  };