const fs = require('fs') const path = require('path') const http = require('http') const Prerenderer = require('@prerenderer/prerenderer') const PuppeteerRenderer = require('@prerenderer/renderer-puppeteer') const staticDir = path.join(__dirname, '../dist') const API_TARGET = process.env.PRERENDER_API_TARGET || 'http://121.41.91.44:8080' const ROUTE_REQUIREMENTS = { '/': ['hm-banner__title', 'hm-ai-tab', 'hm-logo-item', 'organization-jsonld', 'website-jsonld'], '/home': ['hm-banner__title', 'hm-ai-tab', 'hm-logo-item', 'organization-jsonld', 'website-jsonld'], '/product/version-selection': ['db-hero__title', 'version-selection-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-hr': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-recruitment': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-attendance': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'], '/product/dingtalk-payroll': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'], '/product/dingtalk-yida': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'], '/product/teambition': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-finance': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-contract': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'], '/product/intelligent-marketing': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'], '/ai/qwen-office': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai/deap': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai/ai-table': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai/ai-memo': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai/ai-search': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai/dingtalk-a1': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'], '/ai': ['ot-hero__title', 'ot-mod--block', 'service-jsonld', 'breadcrumb-jsonld'], '/digital-base': ['db-hero__title', 'db-cat-tab', 'service-jsonld', 'breadcrumb-jsonld'], '/organization-digital': ['od-banner-main-title', 'od-mod--product', 'service-jsonld', 'breadcrumb-jsonld'], '/business-digital': ['bd-banner-main-title', 'bd-mod--product', 'service-jsonld', 'breadcrumb-jsonld'], '/solutions': ['banner-kicker', 'solution-section', 'service-jsonld', 'breadcrumb-jsonld'], '/cases': ['cases-hero__title', 'cases-mod'], '/super-service': ['ss-banner__title', 'ss-tab-btn', 'service-jsonld', 'breadcrumb-jsonld'], '/fde': ['fde-hero', 'fde-heading', 'breadcrumb-jsonld'], '/about': ['about-banner__title', 'about-contact-card__name'], '/contact': ['consult-info__title', 'consult-card__title'], '/privacy-policy': ['privacy-page__hero', 'privacy-page__content', 'breadcrumb-jsonld'], '/news': ['news-hero', 'news-list-wrap', 'breadcrumb-jsonld'] } const ROUTE_READY_SELECTORS = { '/': '.hm-ai-tab', '/home': '.hm-ai-tab', '/product/version-selection': '.db-page', '/product/intelligent-hr': '.od-page', '/product/intelligent-recruitment': '.od-page', '/product/intelligent-attendance': '.od-page', '/product/dingtalk-payroll': '.od-page', '/product/dingtalk-yida': '.bd-page', '/product/teambition': '.bd-page', '/product/intelligent-finance': '.bd-page', '/product/intelligent-contract': '.bd-page', '/product/intelligent-marketing': '.bd-page', '/ai/qwen-office': '.ot-page', '/ai/deap': '.ot-page', '/ai/ai-table': '.ot-page', '/ai/ai-memo': '.ot-page', '/ai/ai-search': '.ot-page', '/ai/dingtalk-a1': '.ot-page', '/ai': '.ot-mod--block', '/digital-base': '.db-cat-tab', '/organization-digital': '.od-mod--product', '/business-digital': '.bd-mod--product', '/solutions': '.solution-section', '/cases': '.cases-mod', '/super-service': '.ss-tab-btn', '/fde': '.fde-hero', '/about': '.about-contact-card__name', '/contact': '.consult-card__title', '/privacy-policy': '.privacy-page__content', '/news': '.news-list-wrap' } const PRERENDER_ROUTES = process.env.PRERENDER_ROUTES ? process.env.PRERENDER_ROUTES.split(',').map((route) => route.trim()).filter(Boolean) : [ '/', '/home', '/product/version-selection', '/product/intelligent-hr', '/product/intelligent-recruitment', '/product/intelligent-attendance', '/product/dingtalk-payroll', '/product/dingtalk-yida', '/product/teambition', '/product/intelligent-finance', '/product/intelligent-contract', '/product/intelligent-marketing', '/ai/qwen-office', '/ai/deap', '/ai/ai-table', '/ai/ai-memo', '/ai/ai-search', '/ai/dingtalk-a1', '/ai', '/digital-base', '/organization-digital', '/business-digital', '/solutions', '/cases', '/super-service', '/fde', '/about', '/contact', '/privacy-policy', '/news' ] function fetchPublishedArticles() { const url = new URL('/prod-api/api/article/list?pageNum=1&pageSize=1000', API_TARGET) return new Promise((resolve, reject) => { const request = http.get(url, response => { let body = '' response.setEncoding('utf8') response.on('data', chunk => { body += chunk }) response.on('end', () => { if (response.statusCode !== 200) return reject(new Error(`文章 API 返回 ${response.statusCode}`)) try { const parsed = JSON.parse(body); resolve(Array.isArray(parsed.rows) ? parsed.rows : []) } catch (error) { reject(error) } }) }) request.setTimeout(30000, () => request.destroy(new Error('文章 API 请求超时'))) request.on('error', reject) }) } function writeSitemap(articles) { const staticRoutes = ['home', 'product/version-selection', 'product/intelligent-hr', 'product/intelligent-recruitment', 'product/intelligent-attendance', 'product/dingtalk-payroll', 'product/dingtalk-yida', 'product/teambition', 'product/intelligent-finance', 'product/intelligent-contract', 'product/intelligent-marketing', 'ai', 'ai/qwen-office', 'ai/deap', 'ai/ai-table', 'ai/ai-memo', 'ai/ai-search', 'ai/dingtalk-a1', 'fde', 'digital-base', 'organization-digital', 'business-digital', 'solutions', 'cases', 'super-service', 'about', 'contact', 'privacy-policy', 'news'] const urls = [...staticRoutes.map(route => `${route}/`), ...articles.map(article => `news/${article.slug}/`)] const xml = ['', '', ...urls.map(url => ` https://www.cloudpure.cn/${url}`), '', ''].join('\n') fs.writeFileSync(path.join(staticDir, 'sitemap.xml'), xml, 'utf8') } function getChromePath() { const candidates = [ process.env.CHROME_PATH, 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', process.platform === 'darwin' && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', process.platform === 'linux' && '/usr/bin/google-chrome', process.platform === 'linux' && '/usr/bin/chromium-browser' ].filter(Boolean) return candidates.find((chromePath) => fs.existsSync(chromePath)) } function postProcess(context) { context.route = context.originalRoute context.html = context.html .replace(/http:\/\/127\.0\.0\.1:\d+/g, '') // 防止异常提示层被固化到预渲染 HTML。 .replace(/
]*>[\s\S]*?<\/div>/g, '') .replace(/
]*>[\s\S]*?<\/div>/g, '') return context } function validateRoute(context) { const errors = [] const html = context.html const h1Count = (html.match(/)/g) || []).length if (h1Count !== 1) errors.push(`H1 数量为 ${h1Count},应为 1`) if (!/)/.test(html)) errors.push('缺少 main 语义标签') if (!/rel="canonical"/.test(html)) errors.push('缺少 canonical') if (!html.includes('application/ld+json')) errors.push('缺少 JSON-LD') const localBusinessCount = (html.match(/local-business-jsonld-/g) || []).length if (localBusinessCount < 3) errors.push(`LocalBusiness 数量为 ${localBusinessCount},当前应至少为 3`) for (const marker of ROUTE_REQUIREMENTS[context.originalRoute] || []) { if (!html.includes(marker)) errors.push(`缺少完整内容标记 ${marker}`) } if (errors.length) { throw new Error(`[prerender] ${context.originalRoute} 完整性校验失败: ${errors.join(';')}`) } return context } function writeRouteHtml(route) { const outputPath = path.join(staticDir, route.route, 'index.html') fs.mkdirSync(path.dirname(outputPath), { recursive: true }) fs.writeFileSync(outputPath, route.html.trim(), 'utf8') console.log(`[prerender] ${route.route} -> ${outputPath}`) } async function main() { if (!fs.existsSync(staticDir)) { console.error('[prerender] dist 目录不存在,请先执行 vue-cli-service build') process.exit(1) } const rendererOptions = { renderAfterTime: 100, headless: true, maxConcurrentRoutes: 1, timeout: 30000, // CMS API is proxied through the local origin; external images are non-blocking for HTML output. skipThirdPartyRequests: true, inject: { enabled: true }, pageSetup(page, route) { page.__apiFailures = [] page.on('response', (response) => { const responseUrl = new URL(response.url()) const isLocalApi = responseUrl.hostname === '127.0.0.1' && responseUrl.pathname.startsWith('/prod-api/') if (isLocalApi && response.status() >= 400) { page.__apiFailures.push(`${response.status()} ${response.url()}`) } }) page.on('requestfailed', (request) => { const requestUrl = new URL(request.url()) const isLocalApi = requestUrl.hostname === '127.0.0.1' && requestUrl.pathname.startsWith('/prod-api/') if (isLocalApi) { page.__apiFailures.push(`${request.failure()?.errorText || 'request failed'} ${request.url()}`) } }) }, async pageHandler(page, route) { if (page.__apiFailures.length) { throw new Error(`[prerender] ${route} API 请求失败: ${page.__apiFailures.join(';')}`) } await page.waitForSelector(ROUTE_READY_SELECTORS[route], { timeout: 30000 }) await page.waitForSelector('.jsonld-offices-ready', { timeout: 30000 }) if (page.__apiFailures.length) { throw new Error(`[prerender] ${route} API 请求失败: ${page.__apiFailures.join(';')}`) } }, args: ['--no-sandbox', '--disable-setuid-sandbox'], launchOptions: { protocolTimeout: 120000 } } const chromePath = getChromePath() if (chromePath) { rendererOptions.launchOptions.executablePath = chromePath console.log(`[prerender] 使用 Chrome: ${chromePath}`) } else { console.warn('[prerender] 未找到 Chrome,将尝试使用 Puppeteer 内置 Chromium') } const prerenderer = new Prerenderer({ staticDir, server: { host: '127.0.0.1', proxy: { '/prod-api': { target: API_TARGET, changeOrigin: true, secure: false, proxyTimeout: 30000, timeout: 30000 } } }, renderer: new PuppeteerRenderer(rendererOptions) }) try { const articles = await fetchPublishedArticles() const articleRoutes = articles.map(article => `/news/${article.slug}`) articleRoutes.forEach(route => { ROUTE_READY_SELECTORS[route] = '.article__content' ROUTE_REQUIREMENTS[route] = ['article__content', 'article-jsonld'] }) await prerenderer.initialize() const renderedRoutes = await prerenderer.renderRoutes([...PRERENDER_ROUTES, ...articleRoutes]) renderedRoutes .map(postProcess) .map(validateRoute) .forEach(writeRouteHtml) writeSitemap(articles) console.log(`[prerender] 完成,共 ${renderedRoutes.length} 个路由`) } catch (error) { console.error('[prerender] 预渲染失败:', error) process.exit(1) } finally { await prerenderer.destroy() } } main()