#!/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 usage() { return [ 'Usage:', ' node tools/inspect_order_add_product_effect.mjs --input order.json --open-form --out reports/product-effect.json', ' node tools/inspect_order_add_product_effect.mjs --sample-first-existing-product --open-form --out reports/product-effect.sample-redacted.json', '', 'Options:', ' --input Standard operation JSON. Product is exact-matched against LTJT options.', ' --sample-first-existing-product Use the first available existing LTJT product option for redacted non-submit inspection.', ' --open-form Navigate the LTJT main iframe to a fresh orders_add.asp form first.', ' --out Write redacted report JSON.', ` --port Chrome DevTools port. Default: ${PORT}`, ' --help Show this help.', '', 'This tool changes only the current browser form draft. 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`); } 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 productEffectExpression({ operation, openForm, sampleFirstExistingProduct }) { return `(async () => { const operation = ${JSON.stringify(operation || null)}; const openForm = ${JSON.stringify(Boolean(openForm))}; const sampleFirstExistingProduct = ${JSON.stringify(Boolean(sampleFirstExistingProduct))}; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const fabu = operation?.data?.system_defaults?.fabudanwei || '老挝联泰'; const entryUrl = 'https://ltjt.yunzhi.run/System/Business/orders_add.asp?fabudanwei=' + encodeURIComponent(fabu) + '©=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; } 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(); 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', top: topState() }; const doc = orderWin.document; const form = doc.querySelector('#ListForm'); const fabudanwei = form.elements.fabudanwei?.value || fabu; 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 summarizeValues(values) { const normalized = values.map((value) => String(value ?? '')); return { value_count: normalized.length, nonempty_count: normalized.filter((value) => value !== '').length, total_value_length: normalized.reduce((sum, value) => sum + value.length, 0), value_redacted: true }; } function snapshotForm() { const snapshot = {}; Array.from(form.elements).forEach((el, index) => { const name = el.name || el.id || '__element_' + index; let value; const type = String(el.type || '').toLowerCase(); if (type === 'checkbox' || type === 'radio') value = el.checked ? el.value : ''; else value = el.value ?? ''; if (!snapshot[name]) snapshot[name] = []; snapshot[name].push(String(value)); }); return snapshot; } function compareSnapshots(before, after) { const names = Array.from(new Set([...Object.keys(before), ...Object.keys(after)])).sort(); return names .filter((name) => JSON.stringify(before[name] || []) !== JSON.stringify(after[name] || [])) .map((name) => ({ name, before: summarizeValues(before[name] || []), after: summarizeValues(after[name] || []) })); } function groupChangedFields(changes) { const groups = {}; for (const change of changes) { let group = 'other'; if (/^(Text|shuoming|oldxingcheng|xingcheng)/.test(change.name)) group = 'itinerary_text'; else if (/^(zao|zhong|wan|zhusu|jingdian)/.test(change.name)) group = 'resources'; else if (/^(ys_|youyingshou|baojia|yingfu)/.test(change.name)) group = 'pricing_receivable'; else if (/^(TianShu|chanpinming|tuanxuhao|zhuanxianming|zutuanshe|bizhong|lianxiren)/.test(change.name)) group = 'core'; groups[group] = (groups[group] || 0) + 1; } return groups; } 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 }; } const productList = await fetchText('../dat/AjaxPublicFun.asp?Act=ProGetProductname&fls=1&fabudanwei=' + encodeURIComponent(fabudanwei)); const rows = splitRows(productList.text).map((row, rowIndex) => ({ rowIndex, columns: splitColumns(row) })); const expectedProduct = operation?.data?.product?.name ? String(operation.data.product.name).trim() : ''; const exactMatches = expectedProduct ? rows.filter((row) => String(row.columns[1] || '').trim() === expectedProduct) : []; let selected = null; let selectionMode = 'none'; const blockers = []; if (exactMatches.length === 1) { selected = exactMatches[0]; selectionMode = 'standard_operation_exact_match'; } else if (expectedProduct && exactMatches.length !== 1 && !sampleFirstExistingProduct) { blockers.push('product: expected exactly one existing LTJT option, found ' + exactMatches.length); } else if (sampleFirstExistingProduct) { selected = rows.find((row) => String(row.columns[1] || '').trim()); selectionMode = 'first_existing_product_redacted_sample'; if (!selected) blockers.push('product: no existing LTJT product option was available'); } else { blockers.push('product: missing expected standard value'); } const functionSource = typeof orderWin.Find_product === 'function' ? String(orderWin.Find_product) : ''; const functionSummary = { Find_product_present: typeof orderWin.Find_product === 'function', Find_product_length: functionSource.length, references_GetProduct: functionSource.includes('GetProduct'), references_chanpinming: functionSource.includes('chanpinming') }; if (blockers.length || !selected) { return { ok: true, status: 'product_side_effect_blocked', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, product_lookup: { row_count: rows.length, selected_product_value_redacted: true, exact_match_count: exactMatches.length, selection_mode: selectionMode }, function_summary: functionSummary, blockers, submit_safety: { live_submit_attempted: false, live_submit_supported_by_this_tool: false } }; } const productName = String(selected.columns[1] || '').trim(); const ajaxRecords = []; const $ = orderWin.jQuery || orderWin.$; if (!$ || typeof $.ajax !== 'function') { return { ok: true, status: 'product_side_effect_blocked', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, product_lookup: { row_count: rows.length, selected_product_value_redacted: true, exact_match_count: exactMatches.length, selection_mode: selectionMode }, function_summary: functionSummary, blockers: ['jQuery.ajax is not available in the order form window'], submit_safety: { live_submit_attempted: false, live_submit_supported_by_this_tool: false } }; } const originalAjax = $.ajax; $.ajax = function ajaxWrapper(options, ...rest) { const config = typeof options === 'string' ? { url: options } : { ...(options || {}) }; const isProductEffect = String(config.url || '').includes('Act=GetProduct'); if (!isProductEffect) return originalAjax.call(this, options, ...rest); const record = { url_path_redacted: '../dat/AjaxPublicFun.asp?Act=GetProduct&cpm=[redacted]', method: config.type || config.method || 'GET', dataType: config.dataType || '', completed: false, ok: false, text_status: '', response_byte_length: 0, contains_login_timeout_text: false }; ajaxRecords.push(record); const originalSuccess = config.success; const originalError = config.error; const originalComplete = config.complete; config.success = function successWrapper(data, textStatus, jqXHR) { const text = String(data || ''); record.ok = true; record.text_status = textStatus || ''; record.response_byte_length = new Blob([text]).size; record.contains_login_timeout_text = /登陆|登录|login/i.test(text); return originalSuccess ? originalSuccess.apply(this, arguments) : undefined; }; config.error = function errorWrapper(jqXHR, textStatus, errorThrown) { record.ok = false; record.text_status = textStatus || ''; record.error_text_redacted = errorThrown ? String(errorThrown).slice(0, 80) : ''; return originalError ? originalError.apply(this, arguments) : undefined; }; config.complete = function completeWrapper() { record.completed = true; return originalComplete ? originalComplete.apply(this, arguments) : undefined; }; return originalAjax.call(this, config, ...rest); }; const before = snapshotForm(); const chanpinEl = form.elements.chanpinming || doc.querySelector('#chanpinming'); if (!chanpinEl) blockers.push('chanpinming element was not found'); else chanpinEl.value = productName; if (typeof orderWin.Find_product !== 'function') blockers.push('Find_product() is not available'); if (!blockers.length) { try { orderWin.Find_product(); } catch (err) { blockers.push('Find_product() threw: ' + (err.message || String(err)).slice(0, 160)); } } const effectStarted = Date.now(); while (Date.now() - effectStarted < 15000) { if (ajaxRecords.some((record) => record.completed)) break; await sleep(250); } await sleep(1000); $.ajax = originalAjax; const after = snapshotForm(); const changes = compareSnapshots(before, after); const productAjax = ajaxRecords.find((record) => record.url_path_redacted.includes('GetProduct')) || null; const sideEffectBlockers = [...blockers]; if (!productAjax) sideEffectBlockers.push('GetProduct ajax request was not observed'); else if (!productAjax.ok) sideEffectBlockers.push('GetProduct ajax request did not complete successfully'); else if (productAjax.contains_login_timeout_text) sideEffectBlockers.push('GetProduct response looked like a login-timeout script'); if (!changes.length) sideEffectBlockers.push('Find_product/GetProduct completed without changing any serialized form fields'); return { ok: true, status: sideEffectBlockers.length ? 'product_side_effect_blocked' : 'product_side_effect_inspected', page: { url: doc.location.href, title: doc.title, form_element_count: form.elements.length }, product_lookup: { row_count: rows.length, selected_product_value_redacted: true, selected_row_index: selected.rowIndex, selected_column_count: selected.columns.length, exact_match_count: exactMatches.length, selection_mode: selectionMode }, function_summary: functionSummary, ajax_records: ajaxRecords, changed_field_count: changes.length, changed_field_groups: groupChangedFields(changes), changed_fields: changes, blockers: sideEffectBlockers, submit_safety: { live_submit_attempted: false, live_submit_supported_by_this_tool: false, note: 'This tool only mutates the current browser form draft and observes page-side effects. It never clicks SubmitButton and never calls DoInfoJH.' } }; })()`; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } const inputPath = args.input || args._[0] || ''; const operation = inputPath ? readJson(inputPath) : null; if (!operation && !args['sample-first-existing-product']) { console.error(usage()); process.exit(1); } 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, productEffectExpression({ operation, openForm: args['open-form'], sampleFirstExistingProduct: args['sample-first-existing-product'] }), 60000); if (args.out) writeJson(args.out, { generated_at: new Date().toISOString(), source_input: inputPath || '', ...output }); console.log(JSON.stringify({ status: output.status, blockers: output.blockers?.length || 0, product_rows: output.product_lookup?.row_count || 0, selection_mode: output.product_lookup?.selection_mode || '', ajax_records: output.ajax_records?.length || 0, changed_field_count: output.changed_field_count || 0, changed_field_groups: output.changed_field_groups || {}, out: args.out || '' }, null, 2)); if (output.blockers?.length) process.exit(2); } finally { cdp.close(); } } main().catch((error) => { console.error(error.stack || error.message); process.exit(1); });