#!/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(); }