Files
LWLT-AI/tools/browser_order_add_dry_run.mjs
2026-07-13 19:57:46 +08:00

331 lines
11 KiB
JavaScript
Executable File

#!/usr/bin/env node
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const DEFAULT_PORT = '9223';
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) {
args._.push(arg);
continue;
}
const key = arg.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
args[key] = true;
} else {
args[key] = next;
i += 1;
}
}
return args;
}
function usage() {
return [
'Usage:',
' node tools/browser_order_add_dry_run.mjs --report reports/<dry-run-report>.json --open-form',
'',
'Options:',
' --report <path> Dry-run report from tools/dry_run_order_create.mjs',
` --port <number> Chrome DevTools port. Default: ${DEFAULT_PORT}`,
' --target-id <prefix> Optional CDP page target id prefix',
' --open-form Navigate the LTJT main iframe to orders_add.asp before filling',
' --out <path> Write browser dry-run comparison report',
' --unsafe-include-session-fields Do not redact session_id in output',
' --help Show this help',
'',
'This tool fills and serializes the browser form only. It never clicks SubmitButton and never calls DoInfoJH.'
].join('\n');
}
function readJson(path) {
return JSON.parse(readFileSync(path, 'utf8'));
}
function writeJson(path, data) {
mkdirSync(dirname(resolve(path)), { recursive: true });
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getJson(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}: ${url}`);
return res.json();
}
async function getTarget(port, targetId = '') {
const targets = await getJson(`http://127.0.0.1:${port}/json`);
const pages = targets.filter((target) => target.type === 'page');
if (targetId) {
const found = pages.find((target) => target.id.startsWith(targetId));
if (!found) throw new Error(`No page target matching ${targetId}`);
return found;
}
const ltjt = pages.find((target) => target.url.includes('ltjt.yunzhi.run'));
if (!ltjt) throw new Error(`No ltjt.yunzhi.run page target on CDP port ${port}`);
return ltjt;
}
class CDP {
constructor(wsUrl) {
this.wsUrl = wsUrl;
this.id = 0;
this.pending = new Map();
}
connect() {
return new Promise((resolve, reject) => {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = resolve;
this.ws.onerror = (event) => reject(new Error(event.message || event.type || 'WebSocket error'));
this.ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (!msg.id || !this.pending.has(msg.id)) return;
const { resolve: ok, reject: fail, timer } = this.pending.get(msg.id);
clearTimeout(timer);
this.pending.delete(msg.id);
msg.error ? fail(new Error(msg.error.message)) : ok(msg.result);
};
});
}
send(method, params = {}, timeoutMs = 20000) {
const id = ++this.id;
this.ws.send(JSON.stringify({ id, method, params }));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timeout: ${method}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
});
}
close() {
this.ws?.close();
}
}
async function evaluate(cdp, expression, timeoutMs = 20000) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true
}, timeoutMs);
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || 'Runtime.evaluate failed');
}
return result.result.value;
}
function redactSerialized(value, includeSensitive) {
if (includeSensitive) return value;
return String(value || '')
.replace(/(session_id=)[^&"]*/g, '$1[redacted]')
.replace(/((?:UserPwd|UserName|Code|password|pwd)=)[^&"]*/ig, '$1[redacted]');
}
function fillAndSerializeExpression(mappedValues, openForm, entryUrl) {
return `(async () => {
const mappedValues = ${JSON.stringify(mappedValues)};
const openForm = ${JSON.stringify(Boolean(openForm))};
const entryUrl = ${JSON.stringify(entryUrl)};
const entryStamp = new URL(entryUrl).searchParams.get('_');
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function findOrderWindow() {
const queue = [window];
while (queue.length) {
const win = queue.shift();
let doc;
try { doc = win.document; } catch (err) { continue; }
if (doc.querySelector('#ListForm') && doc.location.href.includes('/orders_add.asp')) return win;
Array.from(win.frames).forEach((frame) => queue.push(frame));
}
return null;
}
function topState() {
return { url: location.href, title: document.title };
}
if (/login/i.test(location.href) || /云智办公i-5.1/.test(document.title || '')) {
return { ok: false, status: 'login_required', top: topState() };
}
if (openForm) {
const frame = document.getElementById('Iframe_Home') || document.querySelector('iframe[name="MainIframe"], iframe');
if (!frame) return { ok: false, status: 'main_iframe_not_found', top: topState() };
frame.src = entryUrl;
const started = Date.now();
while (Date.now() - started < 20000) {
const found = findOrderWindow();
if (found && found.document.location.href.includes('_=' + entryStamp)) break;
await sleep(500);
}
}
let orderWin = findOrderWindow();
if (!orderWin) return { ok: false, status: 'orders_add_form_not_found', top: topState() };
let doc = orderWin.document;
let form = doc.querySelector('#ListForm');
const formStarted = Date.now();
while (form && form.elements.length < 800 && !form.elements.session_id && Date.now() - formStarted < 20000) {
await sleep(500);
orderWin = findOrderWindow();
if (!orderWin) continue;
doc = orderWin.document;
form = doc.querySelector('#ListForm');
}
if (!form) return { ok: false, status: 'orders_add_form_not_found_after_wait', top: topState() };
const missingElements = [];
const setResults = [];
for (const [name, value] of Object.entries(mappedValues)) {
const elements = Array.from(form.elements).filter((el) => el.name === name);
if (!elements.length) {
missingElements.push(name);
continue;
}
const textValue = String(value ?? '');
const type = (elements[0].type || elements[0].tagName || '').toLowerCase();
if (type === 'radio') {
const matched = elements.find((el) => String(el.value) === textValue) || elements[0];
elements.forEach((el) => { el.checked = el === matched; });
} else if (type === 'checkbox') {
elements[0].checked = Boolean(value);
} else {
elements[0].value = textValue;
}
setResults.push({ name, type, valueLength: textValue.length });
}
const serialize = orderWin.jQuery
? orderWin.jQuery(form).serialize()
: Array.from(new FormData(form).entries()).map(([key, value]) => encodeURIComponent(key) + '=' + encodeURIComponent(value)).join('&');
return {
ok: true,
status: 'browser_dry_run_serialized',
top: topState(),
form: {
url: doc.location.href,
title: doc.title,
id: form.id || '',
action: form.action || form.getAttribute('action') || '',
method: form.getAttribute('method') || ''
},
missingElements,
setResults,
serializedForm: serialize
};
})()`;
}
function compareMappedValues(mappedValues, liveSerialized) {
const params = new URLSearchParams(liveSerialized || '');
const mismatches = [];
for (const [name, expected] of Object.entries(mappedValues || {})) {
const actual = params.get(name);
if (actual !== String(expected ?? '')) {
mismatches.push({
field: name,
expected: String(expected ?? ''),
actual: actual == null ? null : actual
});
}
}
return {
mapped_field_count: Object.keys(mappedValues || {}).length,
live_field_count: Array.from(params.keys()).length,
mismatches
};
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(usage());
return;
}
const reportPath = args.report || args._[0];
if (!reportPath) {
console.error(usage());
process.exit(1);
}
const localReport = readJson(reportPath);
const mappedValues = localReport.mapped_values || {};
const fabudanwei = mappedValues.fabudanwei || '';
const entryUrl = `https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=${encodeURIComponent(fabudanwei)}&copy=0&ddid=0&_=${Date.now()}`;
const includeSensitive = Boolean(args['unsafe-include-session-fields']);
const port = args.port || process.env.LWLT_CDP_PORT || DEFAULT_PORT;
const target = await getTarget(port, args['target-id'] || process.env.LWLT_TARGET_ID || '');
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
try {
await cdp.send('Runtime.enable');
const browserResult = await evaluate(cdp, fillAndSerializeExpression(mappedValues, args['open-form'], entryUrl), 30000);
const comparison = browserResult.ok
? compareMappedValues(mappedValues, browserResult.serializedForm)
: { mapped_field_count: Object.keys(mappedValues).length, live_field_count: 0, mismatches: [] };
const output = {
generated_at: new Date().toISOString(),
status: browserResult.ok && !comparison.mismatches.length && !browserResult.missingElements.length
? 'browser_dry_run_passed'
: browserResult.status || 'browser_dry_run_blocked',
source_report: reportPath,
target: {
id: target.id,
url: target.url,
title: target.title
},
browser_result: {
...browserResult,
serializedForm: redactSerialized(browserResult.serializedForm || '', includeSensitive)
},
comparison: includeSensitive ? comparison : {
...comparison,
mismatches: comparison.mismatches.map((item) => {
if (item.field === 'session_id') return { ...item, expected: '[redacted]', actual: '[redacted]' };
return item;
})
},
submit_safety: {
live_submit_attempted: false,
live_submit_supported_by_this_tool: false,
note: 'This tool fills and serializes the browser form only. It never clicks SubmitButton and never calls DoInfoJH.'
}
};
if (args.out) writeJson(args.out, output);
console.log(JSON.stringify({
status: output.status,
browser_status: browserResult.status,
missing_elements: browserResult.missingElements?.length || 0,
mismatches: comparison.mismatches.length,
mapped_field_count: comparison.mapped_field_count,
live_field_count: comparison.live_field_count,
out: args.out || ''
}, null, 2));
} finally {
cdp.close();
}
}
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});