web-statics/scripts/build.mjs
2026-10-22 22:19:50 +08:00

156 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import fs from 'node:fs/promises';
import path from 'node:path';
import * as cheerio from 'cheerio';
import { sites, languages } from './sites.mjs';
import { root, cacheRoot, jsonFile, write, writeJSON, canonical, routeOf, blockedPath, relative, escapeHTML as E } from './lib.mjs';
import { Assets } from './assets.mjs';
import { prepareTranslations, localize } from './translate.mjs';
import { addFonts } from './fonts.mjs';
const args=process.argv.slice(2);
const selected=sites.filter(s=>!args.some(a=>!a.startsWith('--'))||args.includes(s.id));
const date=t=>new Date(t*1000).toISOString().slice(0,10);
const detail=(item,type)=>item.detail?.[type]||item.detail||item;
for(const site of selected){
const folder=path.join(root,site.id);
const sourceFolder=path.join(cacheRoot,'sites',site.id);
const data=await jsonFile(path.join(sourceFolder,'data.json'));
const captured=await jsonFile(path.join(sourceFolder,'pages.json'),[]);
if(!captured.some(p=>p.file==='index.html'))throw new Error(`Capture ${site.id} first`);
const sourceReport=await jsonFile(path.join(sourceFolder,'capture-report.json'),{});
const asset=new Assets(site);
const records=new Map(captured.map(p=>[p.file,p]));
const routes=new Map(captured.map(p=>[canonical(p.url,site),p.file]));
const template=captured.find(p=>p.file==='document.html')||captured.find(p=>p.file==='index.html');
function shell(file,title,body,url){
const $=cheerio.load(template.html);const header=$('header').first().prop('outerHTML')||'';const footer=$('footer').first().prop('outerHTML')||$('body > .uk-section-primary').map((_,el)=>$(el).prop('outerHTML')).get().join('');
$('body').empty().append(header).append(`<main class="static-main">${body}</main>`).append(footer);
$('title').text(`${title} - ${site.name}`);
const entry={file,url:url||site.origin+'/'+file,html:$.html(),title};records.set(file,entry);routes.set(canonical(entry.url,site),file);return entry;
}
function list(items,type){const short=type==='announcement'?'announce':type;return `<ul class="static-list">${items.map(item=>`<li><a href="/${short}/${item.id}.html">${E(item.title)}</a><time datetime="${date(item.create_time)}">${date(item.create_time)}</time></li>`).join('')||'<li class="static-empty">暂无相关内容</li>'}</ul>`;}
for(const type of ['news','announcement']){
const short=type==='announcement'?'announce':type;const title=type==='announcement'?'官方公告':'新闻资讯';const items=data.contents[type];
shell(`${short}.html`,title,`<h1>${title}</h1>${list(items,type)}`);
for(const item of items){
const article=detail(item,type);const content=cheerio.load(article.content||'');
shell(`${short}/${item.id}.html`,article.title||item.title,`<article class="static-article"><h1>${E(article.title||item.title)}</h1><div class="static-date">${date(item.create_time)}</div>${content('body').html()||''}<a class="static-back" href="/${short}.html">返回列表</a></article>`,`${site.origin}/${short}-details.html?id=${item.id}`);
}
for(const category of data.api[`/${type}/type`]?.list||[]){
const filtered=items.filter(item=>String(detail(item,type)[`addon_idcsmart_${type}_type_id`])===String(category.id));
const pages=Math.max(1,Math.ceil(filtered.length/10));
for(let n=1;n<=pages;n++){
const file=`${short}-classify-id-${category.id}${n>1?'-page-'+n:''}.html`;
const pagination=pages>1?`<nav class="static-pagination" aria-label="分页">${Array.from({length:pages},(_,i)=>i+1===n?`<span aria-current="page">${i+1}</span>`:`<a href="/${short}-classify-id-${category.id}${i?'-page-'+(i+1):''}.html">${i+1}</a>`).join('')}</nav>`:'';
shell(file,category.name,`<h1>${E(category.name)}</h1>${list(filtered.slice((n-1)*10,n*10),type)}${pagination}`,`${site.origin}/${short}-classify.html?id=${category.id}${n>1?'&page='+n:''}`);
}
}
}
for(const item of data.contents.help){
const article=detail(item,'help');const content=cheerio.load(article.content||'');
content('span').each((_,el)=>{const $el=content(el);if(!$el.find('img,strong,b,i,em,a').length)$el.replaceWith($el.contents());});
shell(`docs/${item.id}.html`,article.title||item.title,`<article class="static-article"><h1>${E(article.title||item.title)}</h1>${content('body').html()||''}<a class="static-back" href="/document.html">返回列表</a></article>`,`${site.origin}/plugin/26/helpTotal.htm?id=${item.id}`);
}
const docGroups=(data.api['/help']?.list||[]).map(group=>`<section><h2>${E(group.name)}</h2><ul class="static-list">${data.contents.help.filter(x=>String(x.categoryId)===String(group.id)).map(item=>`<li><a href="/docs/${item.id}.html">${E(item.title)}</a></li>`).join('')||'<li class="static-empty">暂无相关内容</li>'}</ul></section>`).join('');
shell('document.html','文档中心',`<h1>文档中心</h1><div class="input-search static-search"><input id="document-input" type="search" placeholder="搜索文档"><button class="search-btn" type="button">搜索</button></div>${docGroups}`);
shell('document-result.html','文档搜索',`<h1>文档搜索</h1><form class="static-search" id="static-search-form"><input id="static-search-query" name="search" type="search" placeholder="搜索文档"><button type="submit">搜索</button></form><ul class="static-list" id="static-search-results"></ul>`);
const product=data.api['/product/4']?.product;
const options=data.api['/product/4/mf_cloud/order_page'];
if(!product||!options)throw new Error(`${site.id}: public cloud product data missing`);
const values=(items,unit='')=>(items||[]).map(x=>x.type==='step'?`${x.min_value}–${x.max_value}${unit}`:x.value+unit).sort((a,b)=>parseFloat(a)-parseFloat(b)).join(' / ');
const regions=options.data_center.map(country=>country.city.map(city=>city.area.map(area=>`<tr data-filter-row><td>${E(country.name)} / ${E(city.name)}</td><td>${area.line.map(line=>E(line.name)).join(' / ')}</td></tr>`).join('')).join('')).join('');
const currency=data.api['/common']?.currency_code||'';
const price=product.price!==undefined?`<span data-no-translate>${E(currency)} ${E(product.price)}</span> / <span>${E(product.cycle)}</span>`:'联系咨询';
shell('cloud.html',product.name,`<h1>${E(product.name)}</h1><h2>公开起售价</h2><p class="static-price" data-no-translate>${price}</p><h2>可选配置</h2><div class="static-table-wrap"><table class="static-table"><tbody><tr><th>CPU(核)</th><td data-no-translate>${E(values(options.cpu))}</td></tr><tr><th>内存</th><td data-no-translate>${E(values(options.memory,options.config.memory_unit))}</td></tr><tr><th>系统盘</th><td data-no-translate>${E(values(options.system_disk,'GB'))}</td></tr><tr><th>数据盘</th><td data-no-translate>${E(values(options.data_disk,'GB'))}</td></tr></tbody></table></div><h2>地区与线路</h2><div class="static-search"><input type="search" data-local-filter placeholder="筛选地区或线路"></div><div class="static-table-wrap"><table class="static-table"><thead><tr><th>地区</th><th>线路</th></tr></thead><tbody>${regions}</tbody></table></div><p><a href="/contact.html">联系我们</a></p>`);
routes.set(canonical('/cart/goods.htm?id=4',site),'cloud.html');
const prepared=[];
for(const page of records.values()){
const $=cheerio.load(page.html);
function stripComments(node){for(const child of [...node.children||[]]){if(child.type==='comment')$(child).remove();else stripComments(child);}}
stripComments($.root()[0]);
$('script').each((_,el)=>{const src=$(el).attr('src')||'';if(!/\/vender\/(?:jQuery|bootstrap|swiper|uikit)\//i.test(src))$(el).remove();});
$('base,noscript,iframe,object,embed,.no-login,.login-in,.login-menu,.header-clientarea,#loginBtn,#registBtn,#accountBtn,#financeBtn,#ticketBtn,#logout,.register-advert,.swiper-slide-duplicate').remove();
$('.js-buy,.add-cart').each((_,el)=>{const availability=$(el).find('.qty').text().trim();if(availability)$(el).replaceWith($('<span class="static-availability"></span>').text(availability));else $(el).remove();});
$('html').removeAttr('addons_js').removeAttr('id');
$('.static-price').removeAttr('data-no-translate');
$('*').each((_,el)=>{
for(const key of Object.keys(el.attribs||{}))if(/^on/i.test(key))$(el).removeAttr(key);
if($(el).attr('uk-scrollspy')){$(el).removeAttr('uk-scrollspy');const style=$(el).attr('style');if(style)$(el).attr('style',style.replace(/(?:visibility\s*:\s*hidden|opacity\s*:\s*0)\s*;?/gi,''));}
});
$('a[href]').each((_,el)=>{
const a=$(el);const href=(a.attr('href')||'').trim();
try{const u=new URL(href,page.url);if(site.hosts.includes(u.hostname)&&blockedPath(u.pathname)&&!/^\/cart\/goods\.htm$/.test(u.pathname)){if(a.closest('li').length)a.closest('li').remove();else a.remove();}}
catch{/* Relative fragment links need no normalization here. */}
if(/^mailto:\?@|^https?:\/\/t\.me\/\?/.test(href))a.remove();
});
$('a,button,.btn,.buy,.add-cart').each((_,el)=>{const text=$(el).text().trim();if(/^(?:立即购买|立即开通|立即创建|立即注册|免费试用|注册|登录|会员中心|控制台|Buy\s*now|Sign\s*up|Log\s*in|Client\s*Area|Create\s*now)$/i.test(text))$(el).remove();});
$('form').not('#static-search-form').each((_,el)=>$(el).replaceWith($(el).contents()));
$('a[href=""],a[href="javascript:;"],a[href="javascript:void(0)"]').each((_,el)=>{$(el).attr('href','#');});
if(page.file==='index.html'){
for(const [selector,type] of [['#newsList','news'],['#announceList','announcement']]){
$(selector).html(data.contents[type].slice(0,5).map(item=>`<div class="news-item"><a href="/${type==='announcement'?'announce':'news'}/${item.id}.html"><span class="news-text link-hover">${E(item.title)}</span><span class="news-time">${date(item.create_time)}</span></a></div>`).join(''));
}
}
$('header .uk-parent').each((_,el)=>{const children=$(el).find('.uk-navbar-dropdown a');if($(el).find('.uk-navbar-dropdown').length&&!children.length)$(el).remove();});
$('img').each((_,el)=>{const img=$(el);if(!img.attr('src')&&!img.attr('data-src')&&!img.attr('srcset'))img.remove();});
prepared.push({...page,$,html:$.html()});
}
await write(path.join(folder,'assets','site.js'),await fs.readFile(path.join(root,'scripts','static','site.js')));
await write(path.join(folder,'assets','site.css'),await fs.readFile(path.join(root,'scripts','static','site.css')));
const variants=args.includes('--base-only')?['zh-cn']:languages;
if(!args.includes('--base-only'))await prepareTranslations(prepared.map(p=>p.$));
const output=[];
for(const page of prepared){
for(const language of variants){
const targets=[`${language}/${page.file}`];if(language===site.defaultLanguage||args.includes('--base-only'))targets.push(page.file);
for(const file of targets){
const $=cheerio.load(page.html);await localize($,language);
$('html').attr('lang',language==='en'?'en':language==='zh-hk'?'zh-Hant':'zh-Hans');$('body').attr('data-static-site',site.id);
$('a[href]').each((_,el)=>{
const a=$(el);const value=a.attr('href')?.trim();if(!value||/^(?:#|mailto:|tel:|javascript:|data:)/i.test(value))return;
let u;try{u=new URL(value,page.url);}catch{return;}
if(!site.hosts.includes(u.hostname)){a.attr('href',value.trim());if(a.attr('target')==='_blank')a.attr('rel','noopener noreferrer');return;}
const key=canonical(u.href,site);let target=routes.get(key);const mapped=routeOf(u.href,site);if(!target&&records.has(mapped))target=mapped;
if(!target){a.replaceWith(a.contents());return;}
a.attr('href',relative(file,`${language}/${target}`)+u.hash).removeAttr('target');
});
const labels={'zh-cn':'简体中文','zh-hk':'繁體中文','en':'English'};
$('.language-option').each((_,el)=>{const a=$(el);const code=({'en-us':'en','zh':'zh-cn','ftzh':'zh-hk'})[a.attr('data-lang')]||a.attr('data-lang');if(!languages.includes(code))return;const link=$('<a></a>').attr('class',a.attr('class')||'language-option').attr('data-lang',code).attr('href',relative(file,`${code}/${page.file}`)).attr('lang',code).attr('hreflang',code);if(language===code)link.attr('aria-current','true');link.text(labels[code]);a.replaceWith(link);});
$('.language-code,.current-lang-label').text(labels[language]);
$('meta[http-equiv="Content-Language"]').attr('content',language);$('link[rel="canonical"],link[rel="alternate"][hreflang]').remove();
$('head').append(`<link rel="canonical" href="${relative(file,`${language}/${page.file}`)}">`);
for(const lang of languages)$('head').append(`<link rel="alternate" hreflang="${lang==='zh-hk'?'zh-Hant':lang==='zh-cn'?'zh-Hans':'en'}" href="${relative(file,`${lang}/${page.file}`)}">`);
for(const [selector,attr,hint] of [['img','src',''],['source','src',''],['video','src',''],['video','poster',''],['audio','src',''],['script[src]','src','.js'],['link[rel="stylesheet"]','href','.css'],['link[rel="icon"],link[rel="shortcut icon"]','href','']]){
$(selector).each((_,el)=>{const value=$(el).attr(attr);if(!value)return;const target=asset.ensure(value,page.url,hint);if(target)$(el).attr(attr,target.startsWith('data:')?target:relative(file,target));});
}
$('[srcset]').each((_,el)=>{const value=$(el).attr('srcset');if(value.startsWith('data:'))return;$(el).attr('srcset',value.split(',').map(part=>{const [url,...size]=part.trim().split(/\s+/);const target=asset.ensure(url,page.url);return [relative(file,target),...size].join(' ');}).join(', '));});
$('[data-src]').each((_,el)=>{const value=$(el).attr('data-src');if(!value||value.startsWith('#'))return;const target=asset.ensure(value,page.url);if(target)$(el).attr('data-src',target.startsWith('data:')?target:relative(file,target));});
$('style').each((_,el)=>$(el).text(asset.css($(el).text(),page.url,file)));
$('[style]').each((_,el)=>$(el).attr('style',asset.value($(el).attr('style'),page.url,file)));
$('link[rel="preconnect"],link[rel="dns-prefetch"],link[rel="prefetch"],link[rel="preload"]').remove();
$('head').append(`<link rel="stylesheet" href="${relative(file,'assets/site.css')}">`);
$('#document-input').attr('data-results',relative(file,`${language}/document-result.html`));
if(page.file==='document-result.html'){
const searchItems=[];
for(const item of data.contents.help){const source=prepared.find(p=>p.file===`docs/${item.id}.html`);const t=cheerio.load(source.html);await localize(t,language);searchItems.push({title:t('.static-article h1').text(),text:t('.static-article').text(),href:relative(file,`${language}/docs/${item.id}.html`)});}
const searchData={items:searchItems,empty:language==='en'?'No results found.':language==='zh-hk'?'未找到相關內容':'未找到相关内容'};
$('body').append(`<script id="static-search-data" type="application/json">${JSON.stringify(searchData).replace(/</g,'\\u003c')}</script>`);
}
$('body').append(`<script src="${relative(file,'assets/site.js')}" defer></script>`);
await write(path.join(folder,file),$.html());output.push({file,language,source:page.url,title:$('title').text()});
}
}
console.log(`${site.id}: generated ${page.file} (${variants.length} languages)`);
}
await addFonts(asset,site,output);
await asset.settle();
const missingFonts=[...asset.records.values()].filter(entry=>entry.error&&/^https?:/.test(entry.url)&&/fonts\.googleapis\.com|fonts\.gstatic\.com/.test(new URL(entry.url).hostname));
if(missingFonts.length)throw new Error(`${site.id}: required offline fonts unavailable: ${missingFonts.map(entry=>entry.file).join(', ')}`);
await asset.prune(output.map(item=>item.file));
await writeJSON(path.join(folder,'site-manifest.json'),{site:site.id,origin:site.origin,capturedAt:data.capturedAt,generatedAt:new Date().toISOString(),defaultLanguage:site.defaultLanguage,languages:variants,pages:output,sourceFailures:sourceReport.failures||[],assetFailures:asset.failures,sourceMap:Object.fromEntries(routes)});
console.log(`${site.id}: ${output.length} HTML files, ${asset.records.size} resources, ${asset.failures.length} resource failures`);
}