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

300 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { writeFileSync } from 'node:fs';
const port = process.env.LWLT_CDP_PORT || '9223';
const outJson = process.argv[2] || 'schemas/orders_add_form_schema.json';
const outMd = process.argv[3] || 'schemas/orders_add_form_schema.md';
async function getTarget() {
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const target = targets.find((item) => item.type === 'page' && item.url.includes('ltjt.yunzhi.run'));
if (!target) throw new Error(`No ltjt page target found on CDP 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 = reject;
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) {
this.ws.send(JSON.stringify({ id: ++this.id, method, params }));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(this.id);
reject(new Error(`Timeout: ${method}`));
}, timeoutMs);
this.pending.set(this.id, { resolve, reject, timer });
});
}
close() {
this.ws?.close();
}
}
function extractionExpression() {
return `JSON.stringify((() => {
const clean = (value, max = 160) => String(value || '').replace(/\\s+/g, ' ').trim().slice(0, max);
const attr = (el, name) => el.getAttribute(name) || '';
const stack = [window];
let addWin = null;
while (stack.length) {
const win = stack.shift();
let doc;
try { doc = win.document; } catch (err) { continue; }
if (doc.location.href.includes('/orders_add.asp')) { addWin = win; break; }
Array.from(win.frames).forEach((frame) => stack.push(frame));
}
if (!addWin) return { error: 'orders_add.asp frame not found' };
const doc = addWin.document;
const form = doc.querySelector('#ListForm') || doc.querySelector('form');
const controls = Array.from(doc.querySelectorAll('input, select, textarea'));
const sectionKeywords = [
['basic', /基本|出发|专线|客户|产品|团号|人数|销售|跟单|订单|团队/],
['description', /说明|备注|行程|附件|上传/],
['receivable', /应收|收款|金额|币种|数量|单价|费用|结算/],
['transport', /航班|大交通|班号|关口|车|火车|接送/],
['itinerary', /早餐|中餐|晚餐|住宿|景点|第\\d+天|行程安排/],
['system', /权限|审核|发布单位|操作人|隐藏|session|状态/]
];
function nearestText(el) {
const pieces = [];
const td = el.closest('td,th,div,li,p,tr');
if (td) {
const clone = td.cloneNode(true);
clone.querySelectorAll('input,select,textarea,script,style').forEach((node) => node.remove());
pieces.push(clean(clone.textContent, 120));
}
const prev = el.previousSibling;
if (prev && prev.textContent) pieces.push(clean(prev.textContent, 80));
const parentPrev = el.parentElement?.previousElementSibling;
if (parentPrev) pieces.push(clean(parentPrev.textContent, 80));
return pieces.filter(Boolean).join(' / ').slice(0, 180);
}
function sectionOf(el, name, label) {
const text = [name, label, nearestText(el), attr(el, 'InputName'), attr(el, 'title')].join(' ');
for (const [section, re] of sectionKeywords) if (re.test(text)) return section;
if (/^ys_/.test(name)) return 'receivable';
if (/^(ban|che|jj_|sj_)/.test(name)) return 'transport';
if (/^(zao|zhong|wan|zhusu|jingdian|xingcheng|Text|shuoming)/.test(name)) return 'itinerary';
if (/^(ddid|tdid|quanxian|fabudanwei|caozuoren|session_id|old|PicFile)/.test(name)) return 'system';
return 'other';
}
function prefixOf(name) {
return name
.replace(/_?\\d+_\\d+$/g, '')
.replace(/_?\\d+$/g, '')
.replace(/\\d+$/g, '') || name;
}
function indexedPattern(name) {
if (/^ys_/.test(name)) return 'receivable_rows[0..9].' + name.replace(/\\d+$/,'');
if (/^(banri|banhao|bancheng|banchengs|banchang|banchangs|banshi|banshis|guankou)\\d+$/.test(name)) return 'transport_rows[0..3].' + name.replace(/\\d+$/,'');
if (/^(zao|zaos|zaoss|zaosss|zhong|zhongs|zhongss|zhongsss|wan|wans|wanss|wansss|zhusu|zhusus|zhususs|xingcheng)\\d+$/.test(name)) return 'itinerary_days[1..15].' + name.replace(/\\d+$/,'');
if (/^(jingdian|jingdians|jingdianss)\\d+_\\d+$/.test(name)) return 'itinerary_days[1..15].attractions[4..10].' + name.replace(/\\d+_\\d+$/,'');
if (/^Text\\d+_\\d+$/.test(name)) return 'description_text_groups[0..2][0..5].Text';
if (/^shuoming\\d+$/.test(name)) return 'description_blocks[0..5].shuoming';
if (/^frenshu\\d+$/.test(name)) return 'passenger_breakdown[0..6].frenshu';
if (/^PicFile\\d+$/.test(name)) return 'attachments[0..1].PicFile';
if (/^tuanxuhao\\d+$/.test(name)) return 'order_number_parts[1..2].tuanxuhao';
return '';
}
const fields = controls.map((el, index) => {
const name = attr(el, 'name') || el.id || '';
const inputName = attr(el, 'InputName');
const label = inputName || attr(el, 'title') || attr(el, 'placeholder') || nearestText(el);
const tag = el.tagName.toLowerCase();
const type = tag === 'input' ? (el.type || 'text') : tag;
return {
index,
name,
id: el.id || '',
tag,
type,
section: sectionOf(el, name, label),
prefix: prefixOf(name),
pattern: indexedPattern(name),
label: clean(label, 180),
inputName: clean(inputName, 100),
required: attr(el, 'MastInput') === '1' || el.required || attr(el, 'required') !== '',
readonly: el.readOnly || attr(el, 'readonly') !== '',
disabled: el.disabled || attr(el, 'disabled') !== '',
hidden: type === 'hidden' || el.offsetParent == null,
maxLength: attr(el, 'maxlength'),
minLength: attr(el, 'mimlength') || attr(el, 'minlength'),
numeric: attr(el, 'isNumber') === '1',
form: el.form ? (el.form.id || attr(el.form, 'name') || '') : ''
};
}).filter((field) => field.name);
const prefixCounts = {};
const sections = {};
for (const field of fields) {
prefixCounts[field.prefix] = (prefixCounts[field.prefix] || 0) + 1;
sections[field.section] = (sections[field.section] || 0) + 1;
}
const groups = {};
for (const field of fields) {
const key = field.pattern || field.name;
if (!groups[key]) {
groups[key] = {
key,
section: field.section,
count: 0,
names: [],
type: field.type,
required: false,
label: field.label
};
}
groups[key].count += 1;
groups[key].required ||= field.required;
if (groups[key].names.length < 20) groups[key].names.push(field.name);
}
const scripts = Array.from(doc.querySelectorAll('script:not([src])')).map((script) => script.textContent || '').join('\\n');
const ajaxCalls = scripts.split(/\\n/)
.map((line) => clean(line, 500))
.filter((line) => /\\.asp|Act=|ajax|serialize|SubmitInfoForm|SelectBox|Find_|GetProduct|DoInfoJH/i.test(line))
.slice(0, 220);
return {
sourceUrl: doc.location.href,
title: doc.title,
form: form ? {
id: form.id || '',
name: attr(form, 'name'),
method: attr(form, 'method'),
action: form.action || attr(form, 'action')
} : null,
submit: {
endpoint: '/System/DAT/orders.asp',
action: 'DoInfoJH',
payload: 'Act=DoInfoJH& + ' + (form ? '#' + (form.id || 'ListForm') + '.serialize()' : 'ListForm.serialize()'),
responseType: 'script'
},
counts: {
totalControls: fields.length,
bySection: sections,
byPrefix: prefixCounts
},
requiredFields: fields.filter((field) => field.required).map((field) => ({
name: field.name,
section: field.section,
type: field.type,
label: field.label,
pattern: field.pattern
})),
fields,
groups: Object.values(groups),
ajaxCalls
};
})())`;
}
function buildMarkdown(schema) {
if (schema.error) return `# 独立团下单字段 Schema\n\nError: ${schema.error}\n`;
const lines = [];
lines.push('# 独立团下单字段 Schema');
lines.push('');
lines.push(`Source: \`${schema.sourceUrl}\``);
lines.push(`Title: \`${schema.title}\``);
lines.push('');
lines.push('## Submit Contract');
lines.push(`- Form: \`${schema.form?.id || ''}\``);
lines.push(`- Action: \`${schema.form?.action || ''}\``);
lines.push(`- Save endpoint: \`${schema.submit.endpoint}\``);
lines.push(`- Save action: \`${schema.submit.action}\``);
lines.push(`- Payload: \`${schema.submit.payload}\``);
lines.push(`- Response type: \`${schema.submit.responseType}\``);
lines.push('');
lines.push('## Counts');
lines.push(`- Total controls: ${schema.counts.totalControls}`);
for (const [section, count] of Object.entries(schema.counts.bySection)) {
lines.push(`- ${section}: ${count}`);
}
lines.push('');
lines.push('## Required Fields');
lines.push('| Field | Section | Type | Label | Pattern |');
lines.push('|---|---|---|---|---|');
for (const field of schema.requiredFields) {
const pattern = field.pattern ? `\`${field.pattern}\`` : '';
lines.push(`| \`${field.name}\` | ${field.section} | ${field.type} | ${field.label || ''} | ${pattern} |`);
}
lines.push('');
lines.push('## Field Groups');
lines.push('| Group | Section | Count | Type | Required | Examples |');
lines.push('|---|---|---:|---|---|---|');
for (const group of schema.groups.sort((a, b) => a.section.localeCompare(b.section) || a.key.localeCompare(b.key))) {
const examples = group.names.map((name) => `\`${name}\``).join(', ');
lines.push(`| \`${group.key}\` | ${group.section} | ${group.count} | ${group.type} | ${group.required ? 'yes' : ''} | ${examples} |`);
}
lines.push('');
lines.push('## Adapter-Oriented Standard Model Draft');
lines.push('- `basic`: departure date, route, days, customer, product, salesperson, operator, order number, status, filing flag, remarks.');
lines.push('- `passengers`: adult/child/infant/leader counts and breakdown rows.');
lines.push('- `receivables[]`: unit, item, currency, quantity, unit price, amount, remark, hidden ids/payment metadata.');
lines.push('- `transport[]`: flight/traffic rows and pickup/dropoff fields.');
lines.push('- `itinerary.days[]`: breakfast/lunch/dinner/accommodation/attraction rows by day.');
lines.push('- `attachments[]`: uploaded file references for `PicFile0..1`.');
lines.push('- `system`: existing ids, permission flags, publisher unit, operator, audit flags, session id.');
lines.push('');
lines.push('## Notes');
lines.push('- This schema intentionally omits field values and business rows.');
lines.push('- The final adapter should fill a controlled browser page and inspect `ListForm.serialize()` in dry-run mode before enabling submission.');
lines.push('- Actual submission to `Act=DoInfoJH` is a write operation and needs explicit approval/test data.');
lines.push('');
return `${lines.join('\n')}\n`;
}
const target = await getTarget();
const cdp = new CDP(target.webSocketDebuggerUrl);
await cdp.connect();
try {
await cdp.send('Runtime.enable');
const result = await cdp.send('Runtime.evaluate', {
expression: extractionExpression(),
returnByValue: true,
awaitPromise: true
}, 60000);
const schema = JSON.parse(result.result.value || '{}');
writeFileSync(outJson, `${JSON.stringify(schema, null, 2)}\n`);
writeFileSync(outMd, buildMarkdown(schema));
console.log(JSON.stringify({
outJson,
outMd,
totalControls: schema.counts?.totalControls,
requiredFields: schema.requiredFields?.length,
groups: schema.groups?.length
}, null, 2));
} finally {
cdp.close();
}