| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- 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 = ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', ...urls.map(url => ` <url><loc>https://www.cloudpure.cn/${url}</loc></url>`), '</urlset>', ''].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(/<div class="van-overlay[^"]*"[^>]*>[\s\S]*?<\/div>/g, '')
- .replace(/<div class="van-toast[^"]*"[^>]*>[\s\S]*?<\/div>/g, '')
- return context
- }
- function validateRoute(context) {
- const errors = []
- const html = context.html
- const h1Count = (html.match(/<h1(?:\s|>)/g) || []).length
- if (h1Count !== 1) errors.push(`H1 数量为 ${h1Count},应为 1`)
- if (!/<main(?:\s|>)/.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()
|