You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
137 lines
5.5 KiB
137 lines
5.5 KiB
// 票 01:v29 原型 182 页全量拉取 → lanhu-pages/ 文本库
|
|
// 通路:.scratch/lanhu-latest/RESEARCH-axure-pipeline.md(Step 1 清单已有,直接 Step 3/4)
|
|
// 用法:node fetch-all-pages.mjs [only=<url关键词>] 仅拉匹配页(调试用)
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
const ROOT = 'e:/code/crm-backend-matt';
|
|
const OSS = 'https://lanhu-axure-file.oss-cn-beijing.aliyuncs.com/';
|
|
const IN_JSON = `${ROOT}/.scratch/lanhu-latest/axure-v29.json`;
|
|
const OUT_DIR = `${ROOT}/.scratch/opportunity-bugfix/lanhu-pages`;
|
|
const SLEEP_MS = 200;
|
|
|
|
const data = JSON.parse(fs.readFileSync(IN_JSON, 'utf8'));
|
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
|
|
|
// ---- sitemap 树 walk:url → { name, path } ----
|
|
const pageMeta = new Map(); // url -> {name, path}
|
|
const walk = (nodes, folder) => {
|
|
for (const n of nodes || []) {
|
|
// 带 url 的 Folder:自身也算页面,再递归 children(Axure sitemap 常见形态,漏了会少 44 页)
|
|
if (n.url) pageMeta.set(n.url, { name: n.pageName, path: [...folder, n.pageName].join(' > ') });
|
|
if (Array.isArray(n.children) && n.children.length) walk(n.children, [...folder, n.pageName]);
|
|
}
|
|
};
|
|
walk(data.sitemap.rootNodes, []);
|
|
|
|
// sitemap 里没有、但 pages 里有的 key:兜底用 key 本身
|
|
const urls = Object.keys(data.pages);
|
|
for (const u of urls) {
|
|
if (!pageMeta.has(u)) pageMeta.set(u, { name: u.replace(/\.html$/, ''), path: u });
|
|
}
|
|
|
|
// ---- 文件名安全化 + 冲突处理 ----
|
|
const used = new Set();
|
|
const safeName = (name) => {
|
|
let s = name.replace(/[\\/:*?"<>|\s]+/g, '_');
|
|
let base = s, i = 2;
|
|
while (used.has(s.toLowerCase())) s = `${base}_${i++}`;
|
|
used.add(s.toLowerCase());
|
|
return s;
|
|
};
|
|
const fileOf = new Map(); // url -> out file
|
|
for (const u of urls) fileOf.set(u, path.join(OUT_DIR, safeName(pageMeta.get(u).name) + '.md'));
|
|
|
|
// ---- 文本提取(继承 extract-html-text.mjs 已验证算法 + 批注区增强)----
|
|
const decode = (s) => s
|
|
.replace(/&#x([0-9A-Fa-f]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
|
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
|
.replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'");
|
|
|
|
const cell = (s) => s.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
|
|
|
|
function extractControls(html) {
|
|
const re = /<!--\s*(.*?)\s*-->\s*<div id="(u\d+)"[^>]*>/g;
|
|
const items = [];
|
|
let m;
|
|
while ((m = re.exec(html)) !== null) {
|
|
const [, comment, id] = m;
|
|
const typeMatch = comment.match(/^(.*?)\s*[((](.+?)[))]$/);
|
|
const name = typeMatch ? typeMatch[1] : comment;
|
|
const type = typeMatch ? typeMatch[2] : '其他';
|
|
// 窗口 20000:id 精确锚定无跨控件误配风险;3000 会截断超长批注 cell(闭合 </div> 落在窗外整段丢失,A1X-1-1 实证)
|
|
const after = html.slice(m.index, m.index + 20000);
|
|
const textRe = new RegExp(`id="${id}_text"[^>]*>[\\s\\S]*?</div>`);
|
|
const tm = after.match(textRe);
|
|
let text = '';
|
|
if (tm) {
|
|
text = (tm[0].match(/<p[^>]*>\s*<span[^>]*>([\s\S]*?)<\/span>\s*<\/p>/g) || [])
|
|
.map(p => decode(
|
|
p.replace(/<br\s*\/?>/gi, ' ')
|
|
.replace(/<\/?p[^>]*>/g, '').replace(/<\/?span[^>]*>/g, '')
|
|
.replace(/<[^>]+>/g, '')
|
|
))
|
|
.filter(t => t.length > 0)
|
|
.join(' | ');
|
|
}
|
|
items.push({ id, name: decode(name).trim(), type: decode(type).trim(), text: text.trim() });
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// ---- 下载(重试一次)----
|
|
async function fetchText(url) {
|
|
for (let i = 0; i < 2; i++) {
|
|
try {
|
|
const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
|
|
if (res.ok) return await res.text();
|
|
console.log(` HTTP ${res.status} (try ${i + 1}) ${url.slice(-30)}`);
|
|
} catch (e) {
|
|
console.log(` ERR ${e.message} (try ${i + 1})`);
|
|
}
|
|
if (i === 0) await new Promise(r => setTimeout(r, 800));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ---- 主流程 ----
|
|
const only = process.argv.find(a => a.startsWith('only='))?.slice(5);
|
|
const todo = urls.filter(u => !only || u.includes(only));
|
|
console.log(`pages total: ${urls.length}, todo: ${todo.length}${only ? ` (filter: ${only})` : ''}`);
|
|
|
|
let ok = 0, skip = 0, fail = 0;
|
|
const failed = [], emptyPages = [];
|
|
for (const u of todo) {
|
|
const meta = pageMeta.get(u);
|
|
const file = fileOf.get(u);
|
|
if (fs.existsSync(file) && fs.statSync(file).size > 100) { skip++; continue; }
|
|
const entry = data.pages[u];
|
|
const html = await fetchText(OSS + entry.html.sign_md5);
|
|
if (!html) { fail++; failed.push(u); continue; }
|
|
const items = extractControls(html);
|
|
const withText = items.filter(i => i.text);
|
|
if (withText.length === 0) emptyPages.push(u);
|
|
const lines = [
|
|
`# ${meta.name}`,
|
|
'',
|
|
`- sitemap: ${meta.path}`,
|
|
`- 源文件: ${u} (v29)`,
|
|
`- 控件: ${items.length} / 有文本: ${withText.length}`,
|
|
'',
|
|
'## 控件文本',
|
|
'',
|
|
'| id | 类型 | 名称 | 文本 |',
|
|
'|---|---|---|---|',
|
|
...withText.map(i => `| ${i.id} | ${cell(i.type)} | ${cell(i.name)} | ${cell(i.text)} |`),
|
|
'',
|
|
];
|
|
fs.writeFileSync(file, lines.join('\n'), 'utf8');
|
|
ok++;
|
|
process.stdout.write(`[${ok + skip + fail}/${todo.length}] ${meta.name} (${withText.length} texts)\n`);
|
|
await new Promise(r => setTimeout(r, SLEEP_MS));
|
|
}
|
|
|
|
console.log(`\n=== done: ok=${ok} skip=${skip} fail=${fail} empty=${emptyPages.length}`);
|
|
if (failed.length) console.log('failed pages:\n' + failed.join('\n'));
|
|
if (emptyPages.length) console.log('empty pages (0 text): ' + emptyPages.join(', '));
|
|
|