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 +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 +75 -26
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
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
651
|
-
|
|
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
|
-
|
|
655
|
-
for (const b of
|
|
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
|
|
659
|
-
if (
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
};
|