300 lines
12 KiB
JavaScript
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();
|
|
}
|