#!/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/.json --open-form', '', 'Options:', ' --report Dry-run report from tools/dry_run_order_create.mjs', ` --port Chrome DevTools port. Default: ${DEFAULT_PORT}`, ' --target-id Optional CDP page target id prefix', ' --open-form Navigate the LTJT main iframe to orders_add.asp before filling', ' --out 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); });