351 lines
11 KiB
JavaScript
351 lines
11 KiB
JavaScript
|
|
// scripts/_cipc_service.js
|
||
|
|
//
|
||
|
|
// Long-lived wrapper for the frozen CIPC BizPortal .py scripts. Architecture
|
||
|
|
// A6 from the analysis: instead of Next.js `spawnSync`ing the .py per
|
||
|
|
// request (which hangs the Node thread when BizPortal auth stalls), this
|
||
|
|
// service owns one Python process at a time, keeps the agent-browser
|
||
|
|
// session warm via a watchdog, and answers TCP requests from the dev
|
||
|
|
// server. If the service is offline, the dev server falls back to the
|
||
|
|
// legacy `spawnSync` path — so this is fully backwards-compatible.
|
||
|
|
//
|
||
|
|
// PROTOCOL (line-delimited JSON, one request per connection):
|
||
|
|
// client → service: {"kind":"enterprise_no" | "director_id_no",
|
||
|
|
// "query":"K2018435959" | "8709225206083"}\n
|
||
|
|
// service → client: {"ok":true, "stdout":"<full .py stdout>",
|
||
|
|
// "exitCode":0,
|
||
|
|
// "duration_ms":73000}\n
|
||
|
|
// or {"ok":false, "error":"..."}\n
|
||
|
|
//
|
||
|
|
// The service never modifies the .py files — it's a thin executor that:
|
||
|
|
// 1. Ensures the agent-browser session is warm (logs in if not).
|
||
|
|
// 2. Spawns the matching .py with one positional arg.
|
||
|
|
// 3. Captures stdout/stderr.
|
||
|
|
// 4. Returns the captured output for the route to parse + enrich.
|
||
|
|
//
|
||
|
|
// A 90-second hard timeout wraps the .py spawn so even if BizPortal
|
||
|
|
// deadlocks the script, the client gets an error in <100s instead of
|
||
|
|
// hanging forever.
|
||
|
|
//
|
||
|
|
// Watchdog: every 30s, checks the agent-browser URL; if it has decayed
|
||
|
|
// away from bizprofile/user_profile, runs a login flow silently.
|
||
|
|
|
||
|
|
const net = require('net');
|
||
|
|
const { spawn } = require('node:child_process');
|
||
|
|
const path = require('node:path');
|
||
|
|
const fs = require('node:fs');
|
||
|
|
|
||
|
|
const SCRIPTS_DIR = path.resolve(__dirname);
|
||
|
|
const ENV_FILE = path.resolve(SCRIPTS_DIR, '..', '.env.local');
|
||
|
|
const VENV_PYTHON = path.join(SCRIPTS_DIR, '.venv', 'Scripts', 'python.exe');
|
||
|
|
const AGENT_BROWSER_EXE =
|
||
|
|
'C:\\Users\\SJ\\AppData\\Roaming\\npm\\node_modules\\agent-browser\\bin\\agent-browser-win32-x64.exe';
|
||
|
|
const SESSION = 'biz-search';
|
||
|
|
const SERVICE_HOST = '127.0.0.1';
|
||
|
|
const SERVICE_PORT = 6002;
|
||
|
|
const PY_TIMEOUT_MS = 90_000;
|
||
|
|
const WATCHDOG_INTERVAL_MS = 30_000;
|
||
|
|
const LOG_PATH = path.join(SCRIPTS_DIR, '_cipc_service.log');
|
||
|
|
|
||
|
|
// Reads one key from the app's .env.local, so credentials stay out of source.
|
||
|
|
function envValue(key) {
|
||
|
|
try {
|
||
|
|
for (const line of fs.readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) {
|
||
|
|
const t = line.trim();
|
||
|
|
if (t.startsWith(`${key}=`)) return t.slice(key.length + 1).trim();
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
// fall through to the explicit error below
|
||
|
|
}
|
||
|
|
throw new Error(`${key} not found in ${ENV_FILE}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Login 3 — the one that was rotating successfully when Login 1 had its
|
||
|
|
// subscription expire. Code and password both come from .env.local.
|
||
|
|
const LOGIN_CREDS = {
|
||
|
|
customer_code: envValue('CIPC_LOGIN3_CODE'),
|
||
|
|
password: envValue('CIPC_LOGIN3_PASSWORD'),
|
||
|
|
};
|
||
|
|
const LOGIN_URL = 'https://www.bizportal.gov.za/login.aspx';
|
||
|
|
|
||
|
|
const SCRIPTS = {
|
||
|
|
enterprise_no: 'Bizportal Enterprise No. Search.py',
|
||
|
|
director_id_no: 'Bizportal Directopr id no search.py',
|
||
|
|
};
|
||
|
|
|
||
|
|
function log(level, msg, extra) {
|
||
|
|
const line = `${new Date().toISOString()} [${level}] ${msg}` +
|
||
|
|
(extra ? ' ' + JSON.stringify(extra) : '');
|
||
|
|
try {
|
||
|
|
fs.appendFileSync(LOG_PATH, line + '\n');
|
||
|
|
} catch {}
|
||
|
|
process.stdout.write(line + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- Agent-browser helpers ----------
|
||
|
|
function abCmdParts(args) {
|
||
|
|
return [AGENT_BROWSER_EXE, '--session', SESSION, ...args].map((c) =>
|
||
|
|
/[\s"]/.test(c) ? `"${c}"` : c
|
||
|
|
).join(' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function runAb(args, timeoutMs = 30_000) {
|
||
|
|
// Runs a single agent-browser command by spawning the .exe directly
|
||
|
|
// (no cmd.exe wrapper), which avoids quoting surprises on Windows.
|
||
|
|
// Returns trimmed stdout; throws on timeout or spawn error.
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
let stdout = '', stderr = '', killed = false;
|
||
|
|
let proc;
|
||
|
|
try {
|
||
|
|
proc = spawn(AGENT_BROWSER_EXE, ['--session', SESSION, ...args], {
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
} catch (e) { return reject(e); }
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
killed = true;
|
||
|
|
try { proc.kill('SIGTERM'); } catch {}
|
||
|
|
}, timeoutMs);
|
||
|
|
proc.stdout.on('data', (d) => (stdout += d));
|
||
|
|
proc.stderr.on('data', (d) => (stderr += d));
|
||
|
|
proc.on('error', (e) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
reject(e);
|
||
|
|
});
|
||
|
|
proc.on('close', (code) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
if (killed) return reject(new Error('agent-browser timeout'));
|
||
|
|
// agent-browser returns 0 even on bad JSON-eval results — the
|
||
|
|
// actual error message is encoded in the JSON-stringified stdout.
|
||
|
|
resolve({ stdout: stdout.trim(), stderr: stderr.trim(), code });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function abEval(js, timeoutMs = 30_000) {
|
||
|
|
const b64 = Buffer.from(js, 'utf-8').toString('base64');
|
||
|
|
const r = await runAb(['eval', '-b', b64], timeoutMs);
|
||
|
|
// Strip surrounding JSON-string quotes if present.
|
||
|
|
return r.stdout.replace(/^"|"$/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- Login / warm-state management ----------
|
||
|
|
async function getUrl() {
|
||
|
|
return abEval('window.location.href');
|
||
|
|
}
|
||
|
|
|
||
|
|
async function isWarm() {
|
||
|
|
try {
|
||
|
|
const url = await getUrl();
|
||
|
|
return url.includes('bizprofile.aspx') || url.includes('user_profile.aspx');
|
||
|
|
} catch (e) {
|
||
|
|
log('WARN', 'isWarm probe failed', { err: e.message });
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function login() {
|
||
|
|
log('INFO', 'logging in');
|
||
|
|
await runAb(['open', LOGIN_URL], 30_000);
|
||
|
|
await sleep(4_000);
|
||
|
|
await abEval(
|
||
|
|
"document.getElementById('cntMain_chkId_ToggleButton').click(); 'T'"
|
||
|
|
);
|
||
|
|
await sleep(2_000);
|
||
|
|
const chk = await abEval(
|
||
|
|
"!!document.getElementById('cntMain_txtCustCode') ? 'OK' : 'MISSING'"
|
||
|
|
);
|
||
|
|
if (!chk.includes('OK')) {
|
||
|
|
await abEval(
|
||
|
|
"document.getElementById('cntMain_chkId_ToggleButton').click(); 'T2'"
|
||
|
|
);
|
||
|
|
await sleep(2_000);
|
||
|
|
}
|
||
|
|
const js = [
|
||
|
|
"var el=document.getElementById('cntMain_txtCustCode');",
|
||
|
|
`el.value='${LOGIN_CREDS.customer_code}';`,
|
||
|
|
"el.dispatchEvent(new Event('input',{bubbles:true}));",
|
||
|
|
"var el2=document.getElementById('cntMain_txtPassword');",
|
||
|
|
`el2.value='${LOGIN_CREDS.password}';`,
|
||
|
|
"el2.dispatchEvent(new Event('input',{bubbles:true}));",
|
||
|
|
"document.getElementById('cntMain_btnLogin').click();",
|
||
|
|
"'L'",
|
||
|
|
].join('');
|
||
|
|
await abEval(js);
|
||
|
|
await sleep(18_000);
|
||
|
|
const url = await getUrl();
|
||
|
|
log('INFO', 'login flow complete', { url: url.replace(/^"|"$/g, '') });
|
||
|
|
return url;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- Serialised mutex so watchdog + requests don't race ----------
|
||
|
|
let inFlight = false;
|
||
|
|
const queue = [];
|
||
|
|
async function serialised(fn) {
|
||
|
|
return new Promise((resolve, reject) => {
|
||
|
|
queue.push({ fn, resolve, reject });
|
||
|
|
drain();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
async function drain() {
|
||
|
|
if (inFlight || queue.length === 0) return;
|
||
|
|
inFlight = true;
|
||
|
|
const { fn, resolve, reject } = queue.shift();
|
||
|
|
try { resolve(await fn()); }
|
||
|
|
catch (e) { reject(e); }
|
||
|
|
finally {
|
||
|
|
inFlight = false;
|
||
|
|
setImmediate(drain);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ensureWarm() {
|
||
|
|
return serialised(async () => {
|
||
|
|
if (await isWarm()) return { warmed: false };
|
||
|
|
log('INFO', 'session cold, logging in');
|
||
|
|
await login();
|
||
|
|
return { warmed: true };
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
||
|
|
|
||
|
|
// ---------- .py execution ----------
|
||
|
|
function runPy(scriptName, arg) {
|
||
|
|
const scriptPath = path.join(SCRIPTS_DIR, scriptName);
|
||
|
|
// Spawn the python binary directly with the .py and query as positional
|
||
|
|
// args — no cmd.exe shell, no quoting dance. This sidesteps the Windows
|
||
|
|
// cmd-line escaping surprises we'd hit with `cmd /d /s /c "python.exe ..."`.
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const start = Date.now();
|
||
|
|
let stdout = '', stderr = '', killedByTimeout = false;
|
||
|
|
let proc;
|
||
|
|
try {
|
||
|
|
proc = spawn(VENV_PYTHON, [scriptPath, arg], {
|
||
|
|
cwd: SCRIPTS_DIR,
|
||
|
|
windowsHide: true,
|
||
|
|
});
|
||
|
|
} catch (e) {
|
||
|
|
return resolve({ ok: false, error: `spawn failed: ${e.message}` });
|
||
|
|
}
|
||
|
|
const timer = setTimeout(() => {
|
||
|
|
killedByTimeout = true;
|
||
|
|
try { proc.kill('SIGTERM'); } catch {}
|
||
|
|
}, PY_TIMEOUT_MS);
|
||
|
|
proc.stdout.on('data', (d) => (stdout += d));
|
||
|
|
proc.stderr.on('data', (d) => (stderr += d));
|
||
|
|
proc.on('error', (e) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
resolve({ ok: false, error: `spawn error: ${e.message}` });
|
||
|
|
});
|
||
|
|
proc.on('close', (code) => {
|
||
|
|
clearTimeout(timer);
|
||
|
|
const duration = Date.now() - start;
|
||
|
|
if (killedByTimeout) {
|
||
|
|
resolve({
|
||
|
|
ok: false,
|
||
|
|
error: `python timeout (${PY_TIMEOUT_MS}ms)`,
|
||
|
|
duration_ms: duration,
|
||
|
|
stdout,
|
||
|
|
stderr,
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
resolve({
|
||
|
|
ok: code === 0,
|
||
|
|
exitCode: code,
|
||
|
|
stdout,
|
||
|
|
stderr,
|
||
|
|
duration_ms: duration,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- TCP server ----------
|
||
|
|
async function handleRequest(req) {
|
||
|
|
const { kind, query } = req || {};
|
||
|
|
if (!SCRIPTS[kind]) return { ok: false, error: `unknown kind: ${kind}` };
|
||
|
|
if (typeof query !== 'string' || !query.trim()) {
|
||
|
|
return { ok: false, error: 'missing query' };
|
||
|
|
}
|
||
|
|
const warmResult = await ensureWarm();
|
||
|
|
const scriptName = SCRIPTS[kind];
|
||
|
|
const pyResult = await runPy(scriptName, query);
|
||
|
|
if (!pyResult.ok) {
|
||
|
|
return {
|
||
|
|
ok: false,
|
||
|
|
error: pyResult.error || `python exited ${pyResult.exitCode}`,
|
||
|
|
stdout: pyResult.stdout,
|
||
|
|
stderr: pyResult.stderr,
|
||
|
|
duration_ms: pyResult.duration_ms,
|
||
|
|
warmed_up: warmResult.warmed,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
ok: true,
|
||
|
|
stdout: pyResult.stdout,
|
||
|
|
exitCode: pyResult.exitCode,
|
||
|
|
duration_ms: pyResult.duration_ms,
|
||
|
|
warmed_up: warmResult.warmed,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function singleConnection(socket) {
|
||
|
|
let buf = '';
|
||
|
|
socket.on('data', (chunk) => {
|
||
|
|
buf += chunk.toString('utf-8');
|
||
|
|
let idx;
|
||
|
|
while ((idx = buf.indexOf('\n')) >= 0) {
|
||
|
|
const line = buf.slice(0, idx);
|
||
|
|
buf = buf.slice(idx + 1);
|
||
|
|
if (!line) continue;
|
||
|
|
handleRequestLine(line, socket);
|
||
|
|
return; // one request per connection
|
||
|
|
}
|
||
|
|
});
|
||
|
|
socket.on('error', () => {});
|
||
|
|
socket.on('close', () => {});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleRequestLine(line, socket) {
|
||
|
|
let req;
|
||
|
|
try { req = JSON.parse(line); }
|
||
|
|
catch (e) {
|
||
|
|
socket.write(JSON.stringify({ ok: false, error: 'invalid json' }) + '\n');
|
||
|
|
return socket.end();
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
const res = await handleRequest(req);
|
||
|
|
socket.write(JSON.stringify(res) + '\n');
|
||
|
|
} catch (e) {
|
||
|
|
socket.write(JSON.stringify({ ok: false, error: e.message || String(e) }) + '\n');
|
||
|
|
} finally {
|
||
|
|
socket.end();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
net.createServer(singleConnection).listen(SERVICE_PORT, SERVICE_HOST, () => {
|
||
|
|
log('INFO', `service listening on ${SERVICE_HOST}:${SERVICE_PORT}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
// Watchdog: keep the session warm silently.
|
||
|
|
setInterval(() => {
|
||
|
|
serialised(async () => {
|
||
|
|
if (!(await isWarm())) {
|
||
|
|
log('INFO', 'watchdog: session decayed, re-logging in');
|
||
|
|
try { await login(); log('INFO', 'watchdog: re-login complete'); }
|
||
|
|
catch (e) { log('ERROR', 'watchdog login failed', { err: e.message }); }
|
||
|
|
}
|
||
|
|
}).catch((e) => log('ERROR', 'watchdog tick crashed', { err: e.message }));
|
||
|
|
}, WATCHDOG_INTERVAL_MS);
|
||
|
|
|
||
|
|
log('INFO', 'service starting', {
|
||
|
|
python: VENV_PYTHON,
|
||
|
|
scripts_dir: SCRIPTS_DIR,
|
||
|
|
watchdog_ms: WATCHDOG_INTERVAL_MS,
|
||
|
|
py_timeout_ms: PY_TIMEOUT_MS,
|
||
|
|
});
|
||
|
|
|
||
|
|
// Hard refuse to run twice — concurrency would corrupt the BizPortal session.
|
||
|
|
// (Linux would use a flock; on Windows we use a TCP-port-already-bound check
|
||
|
|
// via net.createServer().listen — calling .listen twice yields EADDRINUSE.)
|