web-statics/scripts/build.mjs
2026-10-22 23:51:34 +08:00

192 lines
18 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;
const jskTelegram={url:'https://t.me/JSKCDN999',handle:'@JSKCDN999'};
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>`);
if(site.id==='jskcloud'){
const contact=records.get('contact.html');
const $=cheerio.load(contact.html);
const telegramImage=$('.aside-tools .tools-item img').first().attr('src');
const documentImage=$('.support-box').filter((_,el)=>$(el).text().includes('自助文档')).find('img').attr('src');
$('.server-banner-desc').text('我们关怀每一位客户,致力于提供更高品质的服务。无论是产品咨询还是技术问题,欢迎通过 Telegram 联系 JSKCloud。');
$('.contact-support,.product-help').each((_,el)=>{
const previous=el.prev;
if(previous?.type==='text'&&!previous.data.trim())$(previous).remove();
$(el).remove();
});
$('.server-banner2').after(`<main class="static-contact">
<div class="static-contact-grid">
<section class="static-contact-method" aria-labelledby="contact-telegram-title">
<img class="static-contact-icon" src="${E(telegramImage)}" alt="" width="56" height="56">
<h2 id="contact-telegram-title">Telegram</h2>
<a class="static-contact-handle" href="${jskTelegram.url}" target="_blank" rel="noopener noreferrer">${jskTelegram.handle}</a>
<p>产品选型、配置咨询与技术支持,请通过 Telegram 与我们沟通。</p>
<a class="static-contact-action" href="${jskTelegram.url}" target="_blank" rel="noopener noreferrer">联系 JSKCloud</a>
</section>
<section class="static-contact-method" aria-labelledby="contact-document-title">
<img class="static-contact-icon" src="${E(documentImage)}" alt="" width="56" height="56">
<h2 id="contact-document-title">文档中心</h2>
<p>查阅产品说明、使用指南与常见问题,快速获取所需信息。</p>
<a class="static-contact-action static-contact-action-secondary" href="/document.html">查看帮助文档</a>
</section>
</div>
<p class="static-contact-note">咨询时请说明所需产品、部署地区与配置,或描述遇到的问题,方便我们为您提供帮助。</p>
</main>`);
records.set(contact.file,{...contact,html:$.html()});
}
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);
if(site.id==='jskcloud'){
$('footer .footer-nav-right .footer-nav-box').html(`<div class="footer-nav-head">联系我们</div><div class="footer-nav-cont"><div class="footer-nav-item">JSKCloud.net</div><div class="footer-nav-item mt-20"><a class="link-hover" href="${jskTelegram.url}" target="_blank">Telegram ${jskTelegram.handle}</a></div></div>`);
}
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`);
}