channel-worker 2.5.63 → 2.5.67
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 +8 -0
- package/lib/cache-server.js +34 -4
- package/lib/daemon.js +52 -0
- package/lib/nst-agent-locator.js +161 -0
- package/lib/nst-manager.js +63 -1
- package/package.json +1 -1
- package/scripts/upload_facebook.js +52 -11
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
|
|
package/lib/cache-server.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
};
|
package/lib/nst-manager.js
CHANGED
|
@@ -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
|
-
|
|
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
|
@@ -37,6 +37,14 @@ const REEL_ENTRY_SELECTORS = [
|
|
|
37
37
|
"div[role='region'][aria-label='Create post'] [aria-label='Reels']",
|
|
38
38
|
"div[role='region'][aria-label='Create post'] [aria-label='Create reel']",
|
|
39
39
|
"div[role='region'][aria-label='Create post'] [aria-label='Create a reel']",
|
|
40
|
+
// Facebook tiếng Anh ghi "Create a post" — THỪA một chữ "a" so với biến thể
|
|
41
|
+
// trên. Đo thật 2026-08-30 trên profile kenh-26: region trên trang chủ là
|
|
42
|
+
// ["create a post","reels"], nên mọi bộ chọn 'Create post' đều trượt và kênh
|
|
43
|
+
// đó không đăng nổi một video nào suốt từ 14/06.
|
|
44
|
+
"div[role='region'][aria-label='Create a post'] [aria-label='Reel']",
|
|
45
|
+
"div[role='region'][aria-label='Create a post'] [aria-label='Reels']",
|
|
46
|
+
"div[role='region'][aria-label='Create a post'] [aria-label='Create reel']",
|
|
47
|
+
"div[role='region'][aria-label='Create a post'] [aria-label='Create a reel']",
|
|
40
48
|
];
|
|
41
49
|
// Residential proxies can leave Facebook's final "Đang tải..." state active
|
|
42
50
|
// well past three minutes. The API only declares a publish command stale after
|
|
@@ -662,26 +670,46 @@ async function runOnce({ page, payload, log }) {
|
|
|
662
670
|
// text/aria starts with "Thước phim"/"Reel" but is NOT in the sidebar.
|
|
663
671
|
if (!reelOpened) {
|
|
664
672
|
const probed = await page.evaluate(() => {
|
|
665
|
-
|
|
666
|
-
|
|
673
|
+
// Nhánh aria TỪNG chỉ dò tiếng Việt. Tài khoản để tiếng Anh mà mục
|
|
674
|
+
// Thước phim chỉ có ICON (không chữ) thì `t` rỗng, `al` là "Reel" —
|
|
675
|
+
// trượt cả hai vế, coi như không có mục nào, rơi xuống /reels/create/.
|
|
676
|
+
// Đúng cảnh kênh 6a2e66b3 (profile kenh-26) hỏng suốt từ 27/08.
|
|
677
|
+
const wantText = /^thước phim$|^reels?$/i;
|
|
678
|
+
const wantAria = /^(thước phim|reels?|create (a )?reel|tạo thước phim)$/i;
|
|
679
|
+
const inventory = [];
|
|
680
|
+
let regionSeen = 0;
|
|
681
|
+
const allRegions = [];
|
|
682
|
+
for (const region of document.querySelectorAll("[role='region'], [aria-label='Tạo bài viết']")) {
|
|
667
683
|
const aria = (region.getAttribute('aria-label') || '').toLowerCase();
|
|
668
|
-
if (
|
|
669
|
-
|
|
670
|
-
|
|
684
|
+
if (allRegions.length < 10 && aria) allRegions.push(aria.slice(0, 30));
|
|
685
|
+
if (!/tạo bài viết|create (a )?post/.test(aria)) continue;
|
|
686
|
+
regionSeen++;
|
|
687
|
+
for (const b of region.querySelectorAll("[role='button'], button, a")) {
|
|
671
688
|
const t = (b.innerText || b.textContent || '').trim();
|
|
672
689
|
const al = (b.getAttribute('aria-label') || '').trim();
|
|
673
|
-
const
|
|
674
|
-
if (
|
|
675
|
-
|
|
676
|
-
|
|
690
|
+
const r = b.getBoundingClientRect();
|
|
691
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
692
|
+
// Gom lại để nếu vẫn trượt thì log NÓI RA nhãn thật, khỏi đoán mò
|
|
693
|
+
// vòng sau (lần này mất một buổi mới dựng lại được).
|
|
694
|
+
if (inventory.length < 12) inventory.push(`t="${t.slice(0, 24)}" al="${al.slice(0, 24)}"`);
|
|
695
|
+
if (wantText.test(t) || wantAria.test(al)) {
|
|
677
696
|
b.setAttribute('__fbpw_reel_entry__', '1');
|
|
678
697
|
return { selector: "[__fbpw_reel_entry__='1']", text: t.slice(0, 40), aria: al.slice(0, 40) };
|
|
679
698
|
}
|
|
680
699
|
}
|
|
681
700
|
}
|
|
682
|
-
return
|
|
701
|
+
return { miss: true, inventory, regionSeen, allRegions };
|
|
683
702
|
}).catch(() => null);
|
|
684
|
-
if (probed) {
|
|
703
|
+
if (probed && probed.miss) {
|
|
704
|
+
// Phân biệt "có widget mà thiếu mục" với "không thấy widget nào" —
|
|
705
|
+
// hai chuyện cần hai cách xử lý khác hẳn.
|
|
706
|
+
if (!probed.regionSeen) {
|
|
707
|
+
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)}`);
|
|
708
|
+
} else {
|
|
709
|
+
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)}`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
if (probed && probed.selector) {
|
|
685
713
|
try {
|
|
686
714
|
await page.locator(probed.selector).click({ timeout: 3000 });
|
|
687
715
|
log('info', `[fb-pw] Thước phim clicked via region-probe (text="${probed.text}" aria="${probed.aria}")`);
|
|
@@ -691,10 +719,12 @@ async function runOnce({ page, payload, log }) {
|
|
|
691
719
|
}
|
|
692
720
|
}
|
|
693
721
|
}
|
|
722
|
+
let usedCreateRoute = false;
|
|
694
723
|
if (!reelOpened) {
|
|
695
724
|
// English/icon-only Page layouts expose Live video, Photo/video and Reel
|
|
696
725
|
// as bare SVG controls. The old code failed here even though the official
|
|
697
726
|
// create route was usable. Go there before touching any file input.
|
|
727
|
+
usedCreateRoute = true;
|
|
698
728
|
await navigateFacebookReelCreate(page, log);
|
|
699
729
|
}
|
|
700
730
|
await pause(page, 4000);
|
|
@@ -703,6 +733,17 @@ async function runOnce({ page, payload, log }) {
|
|
|
703
733
|
// Sanity check: clicking the sidebar Reels link navigates to /reel/<id>.
|
|
704
734
|
// That means we clicked the wrong button — fail fast so the user knows.
|
|
705
735
|
if (/\/reel\/\d+/.test(afterUrl)) {
|
|
736
|
+
// HAI nguyên nhân hoàn toàn khác nhau, trước đây gộp chung một câu báo
|
|
737
|
+
// "wrong button matched" — và câu đó gửi người đọc đi tìm lỗi DOM không
|
|
738
|
+
// hề tồn tại (kênh 6a2e66b3 hỏng suốt từ 27/08 vì hiểu nhầm chỗ này):
|
|
739
|
+
// a) BẤM nhầm nút → đúng là lỗi layout, sửa selector.
|
|
740
|
+
// b) Không có nút nào để bấm, phải đi thẳng /reels/create/, rồi CHÍNH
|
|
741
|
+
// Facebook đá về feed → tài khoản này không mở được trình tạo
|
|
742
|
+
// Thước phim. Sửa selector bao nhiêu cũng vô ích.
|
|
743
|
+
await dumpFailure(page, 'reel-create-bounced', log).catch(() => {});
|
|
744
|
+
if (usedCreateRoute) {
|
|
745
|
+
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).`);
|
|
746
|
+
}
|
|
706
747
|
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.`);
|
|
707
748
|
}
|
|
708
749
|
|