#!/usr/bin/env node import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; const PORT = process.env.LWLT_CDP_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 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`); } 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) { const targets = await getJson(`http://127.0.0.1:${port}/json`); const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run')); if (!target) throw new Error(`No ltjt.yunzhi.run page target on port ${port}`); return target; } 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 = 30000) { 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 = 30000) { 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 resolverExpression(operation, openForm) { return `(async () => { const operation = ${JSON.stringify(operation)}; const openForm = ${JSON.stringify(Boolean(openForm))}; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const entryUrl = 'https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=' + encodeURIComponent(operation.data?.system_defaults?.fabudanwei || '老挝联泰') + '©=0&ddid=0&_=' + Date.now(); const entryStamp = new URL(entryUrl).searchParams.get('_'); 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; } if (openForm) { const frame = document.getElementById('Iframe_Home') || document.querySelector('iframe[name="MainIframe"], iframe'); if (!frame) return { ok: false, status: 'main_iframe_not_found' }; frame.src = entryUrl; } const started = Date.now(); let orderWin = findOrderWindow(); while ((!orderWin || !orderWin.document.querySelector('#ListForm') || orderWin.document.querySelector('#ListForm').elements.length < 800 || (openForm && !orderWin.document.location.href.includes('_=' + entryStamp))) && Date.now() - started < 25000) { await sleep(500); orderWin = findOrderWindow(); } if (!orderWin) return { ok: false, status: 'orders_add_form_not_found' }; const doc = orderWin.document; const form = doc.querySelector('#ListForm'); const fabudanwei = form.elements.fabudanwei?.value || operation.data?.system_defaults?.fabudanwei || '老挝联泰'; async function fetchText(path) { const url = new URL(path, doc.location.href).href; const res = await orderWin.fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } }); const text = await res.text(); return { ok: res.ok, status: res.status, url, text }; } function splitRows(text) { const str = String(text || ''); if (!str) return []; if (str.includes('◇')) return str.split('◇').map((row) => row.trim()).filter(Boolean); if (/\\r?\\n/.test(str)) return str.split(/\\r?\\n/).map((row) => row.trim()).filter(Boolean); return [str.trim()].filter(Boolean); } function splitColumns(row) { return String(row || '').split('◆').map((part) => part.trim()); } function summarize(text) { const rows = splitRows(text); const columnHistogram = {}; for (const row of rows.slice(0, 200)) { const count = splitColumns(row).length; columnHistogram[count] = (columnHistogram[count] || 0) + 1; } return { byte_length: new Blob([String(text || '')]).size, row_count: rows.length, column_count_histogram_first_200_rows: columnHistogram, value_redacted: true }; } function findExact(text, expected, column) { const rows = splitRows(text); const expectedText = String(expected || '').trim(); const matches = []; rows.forEach((row, rowIndex) => { const columns = splitColumns(row); if (String(columns[column] || '').trim() === expectedText) { matches.push({ rowIndex, columns }); } }); return matches; } function applySetValues(columns, setValues) { const fields = {}; for (const [target, col] of Object.entries(setValues)) { fields[target] = columns[Number(col)] ?? ''; } return fields; } const endpoints = { route: '../dat/AjaxPublicFun.asp?Act=GetInformation&fl=3&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei), product: '../dat/AjaxPublicFun.asp?Act=ProGetProductname&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei), customer: '../dat/AjaxPublicFun.asp?Act=ProTravel&fabudanwei=' + encodeURIComponent(fabudanwei), staff: '../dat/AjaxPublicFun.asp?Act=ProDanwei_Yuangong&fabudanwei=' + encodeURIComponent(fabudanwei) }; const fetched = {}; for (const [key, path] of Object.entries(endpoints)) fetched[key] = await fetchText(path); const staticTrip = '1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N'; const specs = [ { name: 'trip_days', expected: operation.data?.trip?.label, text: staticTrip, endpoint_key: '', match_column: 0, set_values: { TianShu: 0 }, required_linked_targets: ['TianShu'] }, { name: 'route', expected: operation.data?.route?.name, text: fetched.route.text, endpoint_key: 'route', match_column: 1, set_values: { zhuanxianming: 1, tuanxuhao1: 3 }, required_linked_targets: ['zhuanxianming', 'tuanxuhao1'] }, { name: 'product', expected: operation.data?.product?.name, text: fetched.product.text, endpoint_key: 'product', match_column: 1, set_values: { chanpinming: 1 }, required_linked_targets: ['chanpinming'], side_effect_required: '../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=' + '{chanpinming}' }, { name: 'customer', expected: operation.data?.customer?.name, text: fetched.customer.text, endpoint_key: 'customer', match_column: 2, set_values: { bizhong: 0, zutuansheid: 1, zutuanshe: 2, lianxiren: 3, zutuanshegzr: 4 }, required_linked_targets: ['bizhong', 'zutuansheid', 'zutuanshe', 'lianxiren', 'zutuanshegzr'] }, { name: 'op_user', expected: operation.data?.op_user?.name, text: fetched.staff.text || orderWin.top.QJ_Yuangong_Data, endpoint_key: 'staff', match_column: 0, set_values: { gendanren: 0 }, required_linked_targets: ['gendanren'] }, { name: 'sales_user', expected: operation.data?.sales_user?.name, text: fetched.staff.text || orderWin.top.QJ_Yuangong_Data, endpoint_key: 'staff', match_column: 0, set_values: { xiaoshouren: 0 }, required_linked_targets: ['xiaoshouren'] } ]; const blockers = []; const resolved_fields = {}; const checks = specs.map((spec) => { const matches = spec.expected ? findExact(spec.text, spec.expected, spec.match_column) : []; const summary = summarize(spec.text); let resolved = {}; if (!spec.expected) blockers.push(spec.name + ': missing expected standard value'); else if (matches.length !== 1) blockers.push(spec.name + ': expected exactly one existing LTJT option, found ' + matches.length); else { resolved = applySetValues(matches[0].columns, spec.set_values); Object.assign(resolved_fields, resolved); } return { name: spec.name, expected_redacted: Boolean(spec.expected), endpoint_key: spec.endpoint_key, summary, exact_match_count: matches.length, match_shape: matches.length === 1 ? { row_index: matches[0].rowIndex, column_count: matches[0].columns.length, set_value_targets: Object.keys(spec.set_values) } : null, required_linked_targets: spec.required_linked_targets, side_effect_required: spec.side_effect_required || '', resolved_field_names: Object.keys(resolved) }; }); return { ok: true, status: blockers.length ? 'lookup_resolution_blocked' : 'lookup_resolution_passed', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, checks, blockers, resolved_fields, warnings: checks.filter((check) => check.side_effect_required && check.exact_match_count === 1) .map((check) => check.name + ': product/page side-effect still needs browser execution before submit'), fetched_statuses: Object.fromEntries(Object.entries(fetched).map(([key, value]) => [key, { ok: value.ok, status: value.status, byte_length: new Blob([value.text]).size }])) }; })()`; } function redactResolution(output) { return { ...output, resolved_fields: Object.fromEntries(Object.keys(output.resolved_fields || {}).map((key) => [key, '[redacted]'])) }; } async function main() { const args = parseArgs(process.argv.slice(2)); const inputPath = args.input || args._[0]; if (!inputPath) { console.error('Usage: node tools/resolve_order_add_lookups.mjs --input order.json --open-form --out reports/lookup-resolution.json [--resolved-out reports/lookup-resolution.values.json]'); process.exit(1); } const operation = readJson(inputPath); const target = await getTarget(args.port || PORT); const cdp = new CDP(target.webSocketDebuggerUrl); await cdp.connect(); try { await cdp.send('Runtime.enable'); const output = await evaluate(cdp, resolverExpression(operation, args['open-form']), 60000); const report = redactResolution(output); if (args.out) writeJson(args.out, report); if (args['resolved-out']) { if (output.blockers?.length) { console.error('Refusing to write resolved-out because lookup resolution is blocked.'); } else { writeJson(args['resolved-out'], { generated_at: new Date().toISOString(), source_input: inputPath, status: output.status, resolved_fields: output.resolved_fields, warnings: output.warnings || [] }); } } console.log(JSON.stringify({ status: output.status, blockers: output.blockers?.length || 0, resolved_field_names: Object.keys(output.resolved_fields || {}), checks: output.checks?.map((check) => ({ name: check.name, exact_match_count: check.exact_match_count, resolved_field_names: check.resolved_field_names, side_effect_required: Boolean(check.side_effect_required) })) || [], out: args.out || '', resolved_out: output.blockers?.length ? '' : (args['resolved-out'] || '') }, null, 2)); if (output.blockers?.length && !args['allow-blockers']) process.exit(2); } finally { cdp.close(); } } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });