#!/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 lookupExpression(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) { const seps = ['◆', '^', '|', String.fromCharCode(9), ',']; let best = [row]; for (const sep of seps) { const parts = String(row || '').split(sep).map((part) => part.trim()); if (parts.length > best.length) best = parts; } return best; } 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 findMatches(text, expected, options = {}) { const rows = splitRows(text); const expectedText = String(expected || '').trim(); const exactRows = []; for (let i = 0; i < rows.length; i += 1) { const columns = splitColumns(rows[i]); const candidateColumns = options.columns || columns.map((_, idx) => idx); const matchedColumns = candidateColumns.filter((idx) => String(columns[idx] || '').trim() === expectedText); if (matchedColumns.length) exactRows.push({ row_index: i, matched_columns: matchedColumns, column_count: columns.length }); } return exactRows; } 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 [name, path] of Object.entries(endpoints)) { fetched[name] = await fetchText(path); } const checks = [ { name: 'trip_days', expected: operation.data?.trip?.label || (operation.data?.trip?.days ? operation.data.trip.days + 'D' + Math.max(0, (operation.data.trip.nights ?? operation.data.trip.days - 1)) + 'N' : ''), source: 'static TianShu SelectBox options', summary: summarize('1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N'), matches: findMatches('1D◇2D1N◇3D2N◇4D3N◇5D4N◇6D5N◇7D6N◇8D7N◇9D8N◇10D9N', operation.data?.trip?.label, { columns: [0] }), required_linked_targets: ['TianShu'] }, { name: 'route', expected: operation.data?.route?.name, endpoint_key: 'route', endpoint_path: endpoints.route, summary: summarize(fetched.route.text), matches: findMatches(fetched.route.text, operation.data?.route?.name, { columns: [1] }), required_linked_targets: ['zhuanxianming', 'tuanxuhao1'] }, { name: 'product', expected: operation.data?.product?.name, endpoint_key: 'product', endpoint_path: endpoints.product, summary: summarize(fetched.product.text), matches: findMatches(fetched.product.text, operation.data?.product?.name, { columns: [1] }), required_linked_targets: ['chanpinming', 'GetProduct(cpm) side effects'] }, { name: 'customer', expected: operation.data?.customer?.name, endpoint_key: 'customer', endpoint_path: endpoints.customer, summary: summarize(fetched.customer.text), matches: findMatches(fetched.customer.text, operation.data?.customer?.name, { columns: [2] }), required_linked_targets: ['zutuansheid', 'zutuanshe', 'lianxiren', 'zutuanshegzr', 'bizhong'] }, { name: 'op_user', expected: operation.data?.op_user?.name, endpoint_key: 'staff', endpoint_path: endpoints.staff, summary: summarize(fetched.staff.text || orderWin.top.QJ_Yuangong_Data), matches: findMatches(fetched.staff.text || orderWin.top.QJ_Yuangong_Data, operation.data?.op_user?.name), required_linked_targets: ['gendanren'] }, { name: 'sales_user', expected: operation.data?.sales_user?.name, endpoint_key: 'staff', endpoint_path: endpoints.staff, summary: summarize(fetched.staff.text || orderWin.top.QJ_Yuangong_Data), matches: findMatches(fetched.staff.text || orderWin.top.QJ_Yuangong_Data, operation.data?.sales_user?.name), required_linked_targets: ['xiaoshouren'] } ]; const blockers = []; for (const check of checks) { if (!check.expected) blockers.push(check.name + ': missing expected standard value'); else if (check.matches.length !== 1) blockers.push(check.name + ': expected exactly one existing LTJT option, found ' + check.matches.length); } return { ok: true, status: blockers.length ? 'lookup_validation_blocked' : 'lookup_validation_passed', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, checks: checks.map((check) => ({ name: check.name, expected_redacted: Boolean(check.expected), endpoint_key: check.endpoint_key || '', endpoint_path: check.endpoint_path || check.source || '', summary: check.summary, exact_match_count: check.matches.length, match_shapes: check.matches, required_linked_targets: check.required_linked_targets })), blockers, fetched_statuses: Object.fromEntries(Object.entries(fetched).map(([key, value]) => [key, { ok: value.ok, status: value.status, byte_length: new Blob([value.text]).size }])) }; })()`; } async function main() { const args = parseArgs(process.argv.slice(2)); const inputPath = args.input || args._[0]; if (!inputPath) { console.error('Usage: node tools/validate_order_add_lookups.mjs --input samples/team_order_create.dry-run.example.json --open-form --out reports/order.lookup-validation.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, lookupExpression(operation, args['open-form']), 60000); if (args.out) writeJson(args.out, output); console.log(JSON.stringify({ status: output.status, page: output.page, blockers: output.blockers?.length || 0, checks: output.checks?.map((check) => ({ name: check.name, exact_match_count: check.exact_match_count, row_count: check.summary?.row_count, linked_targets: check.required_linked_targets })) || [], out: args.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); });