Files
LWLT-AI/tools/inspect_order_add_product_effect.mjs
2026-07-13 19:57:46 +08:00

459 lines
18 KiB
JavaScript

#!/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 <path> 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 <path> Write redacted report JSON.',
` --port <number> 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) + '&copy=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);
});