331 lines
11 KiB
JavaScript
Executable File
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)}©=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);
|
|
});
|