372 lines
13 KiB
JavaScript
372 lines
13 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 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);
|
|
});
|