prerender.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. const fs = require('fs')
  2. const path = require('path')
  3. const http = require('http')
  4. const Prerenderer = require('@prerenderer/prerenderer')
  5. const PuppeteerRenderer = require('@prerenderer/renderer-puppeteer')
  6. const staticDir = path.join(__dirname, '../dist')
  7. const API_TARGET = process.env.PRERENDER_API_TARGET || 'http://121.41.91.44:8080'
  8. const ROUTE_REQUIREMENTS = {
  9. '/': ['hm-banner__title', 'hm-ai-tab', 'hm-logo-item', 'organization-jsonld', 'website-jsonld'],
  10. '/home': ['hm-banner__title', 'hm-ai-tab', 'hm-logo-item', 'organization-jsonld', 'website-jsonld'],
  11. '/product/version-selection': ['db-hero__title', 'version-selection-jsonld', 'breadcrumb-jsonld'],
  12. '/product/intelligent-hr': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'],
  13. '/product/intelligent-recruitment': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'],
  14. '/product/intelligent-attendance': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'],
  15. '/product/dingtalk-payroll': ['od-banner-main-title', 'organization-product-jsonld', 'breadcrumb-jsonld'],
  16. '/product/dingtalk-yida': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'],
  17. '/product/teambition': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'],
  18. '/product/intelligent-finance': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'],
  19. '/product/intelligent-contract': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'],
  20. '/product/intelligent-marketing': ['bd-banner-main-title', 'business-product-jsonld', 'breadcrumb-jsonld'],
  21. '/ai/qwen-office': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  22. '/ai/deap': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  23. '/ai/ai-table': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  24. '/ai/ai-memo': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  25. '/ai/ai-search': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  26. '/ai/dingtalk-a1': ['ot-hero__title', 'ai-product-jsonld', 'breadcrumb-jsonld'],
  27. '/ai': ['ot-hero__title', 'ot-mod--block', 'service-jsonld', 'breadcrumb-jsonld'],
  28. '/digital-base': ['db-hero__title', 'db-cat-tab', 'service-jsonld', 'breadcrumb-jsonld'],
  29. '/organization-digital': ['od-banner-main-title', 'od-mod--product', 'service-jsonld', 'breadcrumb-jsonld'],
  30. '/business-digital': ['bd-banner-main-title', 'bd-mod--product', 'service-jsonld', 'breadcrumb-jsonld'],
  31. '/solutions': ['banner-kicker', 'solution-section', 'service-jsonld', 'breadcrumb-jsonld'],
  32. '/cases': ['cases-hero__title', 'cases-mod'],
  33. '/super-service': ['ss-banner__title', 'ss-tab-btn', 'service-jsonld', 'breadcrumb-jsonld'],
  34. '/fde': ['fde-hero', 'fde-heading', 'breadcrumb-jsonld'],
  35. '/about': ['about-banner__title', 'about-contact-card__name'],
  36. '/contact': ['consult-info__title', 'consult-card__title'],
  37. '/privacy-policy': ['privacy-page__hero', 'privacy-page__content', 'breadcrumb-jsonld'],
  38. '/news': ['news-hero', 'news-list-wrap', 'breadcrumb-jsonld']
  39. }
  40. const ROUTE_READY_SELECTORS = {
  41. '/': '.hm-ai-tab',
  42. '/home': '.hm-ai-tab',
  43. '/product/version-selection': '.db-page',
  44. '/product/intelligent-hr': '.od-page',
  45. '/product/intelligent-recruitment': '.od-page',
  46. '/product/intelligent-attendance': '.od-page',
  47. '/product/dingtalk-payroll': '.od-page',
  48. '/product/dingtalk-yida': '.bd-page',
  49. '/product/teambition': '.bd-page',
  50. '/product/intelligent-finance': '.bd-page',
  51. '/product/intelligent-contract': '.bd-page',
  52. '/product/intelligent-marketing': '.bd-page',
  53. '/ai/qwen-office': '.ot-page',
  54. '/ai/deap': '.ot-page',
  55. '/ai/ai-table': '.ot-page',
  56. '/ai/ai-memo': '.ot-page',
  57. '/ai/ai-search': '.ot-page',
  58. '/ai/dingtalk-a1': '.ot-page',
  59. '/ai': '.ot-mod--block',
  60. '/digital-base': '.db-cat-tab',
  61. '/organization-digital': '.od-mod--product',
  62. '/business-digital': '.bd-mod--product',
  63. '/solutions': '.solution-section',
  64. '/cases': '.cases-mod',
  65. '/super-service': '.ss-tab-btn',
  66. '/fde': '.fde-hero',
  67. '/about': '.about-contact-card__name',
  68. '/contact': '.consult-card__title',
  69. '/privacy-policy': '.privacy-page__content',
  70. '/news': '.news-list-wrap'
  71. }
  72. const PRERENDER_ROUTES = process.env.PRERENDER_ROUTES
  73. ? process.env.PRERENDER_ROUTES.split(',').map((route) => route.trim()).filter(Boolean)
  74. : [
  75. '/',
  76. '/home',
  77. '/product/version-selection',
  78. '/product/intelligent-hr',
  79. '/product/intelligent-recruitment',
  80. '/product/intelligent-attendance',
  81. '/product/dingtalk-payroll',
  82. '/product/dingtalk-yida',
  83. '/product/teambition',
  84. '/product/intelligent-finance',
  85. '/product/intelligent-contract',
  86. '/product/intelligent-marketing',
  87. '/ai/qwen-office',
  88. '/ai/deap',
  89. '/ai/ai-table',
  90. '/ai/ai-memo',
  91. '/ai/ai-search',
  92. '/ai/dingtalk-a1',
  93. '/ai',
  94. '/digital-base',
  95. '/organization-digital',
  96. '/business-digital',
  97. '/solutions',
  98. '/cases',
  99. '/super-service',
  100. '/fde',
  101. '/about',
  102. '/contact',
  103. '/privacy-policy',
  104. '/news'
  105. ]
  106. function fetchPublishedArticles() {
  107. const url = new URL('/prod-api/api/article/list?pageNum=1&pageSize=1000', API_TARGET)
  108. return new Promise((resolve, reject) => {
  109. const request = http.get(url, response => {
  110. let body = ''
  111. response.setEncoding('utf8')
  112. response.on('data', chunk => { body += chunk })
  113. response.on('end', () => {
  114. if (response.statusCode !== 200) return reject(new Error(`文章 API 返回 ${response.statusCode}`))
  115. try { const parsed = JSON.parse(body); resolve(Array.isArray(parsed.rows) ? parsed.rows : []) } catch (error) { reject(error) }
  116. })
  117. })
  118. request.setTimeout(30000, () => request.destroy(new Error('文章 API 请求超时')))
  119. request.on('error', reject)
  120. })
  121. }
  122. function writeSitemap(articles) {
  123. 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']
  124. const urls = [...staticRoutes.map(route => `${route}/`), ...articles.map(article => `news/${article.slug}/`)]
  125. 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')
  126. fs.writeFileSync(path.join(staticDir, 'sitemap.xml'), xml, 'utf8')
  127. }
  128. function getChromePath() {
  129. const candidates = [
  130. process.env.CHROME_PATH,
  131. 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
  132. 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
  133. process.platform === 'darwin' && '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
  134. process.platform === 'linux' && '/usr/bin/google-chrome',
  135. process.platform === 'linux' && '/usr/bin/chromium-browser'
  136. ].filter(Boolean)
  137. return candidates.find((chromePath) => fs.existsSync(chromePath))
  138. }
  139. function postProcess(context) {
  140. context.route = context.originalRoute
  141. context.html = context.html
  142. .replace(/http:\/\/127\.0\.0\.1:\d+/g, '')
  143. // 防止异常提示层被固化到预渲染 HTML。
  144. .replace(/<div class="van-overlay[^"]*"[^>]*>[\s\S]*?<\/div>/g, '')
  145. .replace(/<div class="van-toast[^"]*"[^>]*>[\s\S]*?<\/div>/g, '')
  146. return context
  147. }
  148. function validateRoute(context) {
  149. const errors = []
  150. const html = context.html
  151. const h1Count = (html.match(/<h1(?:\s|>)/g) || []).length
  152. if (h1Count !== 1) errors.push(`H1 数量为 ${h1Count},应为 1`)
  153. if (!/<main(?:\s|>)/.test(html)) errors.push('缺少 main 语义标签')
  154. if (!/rel="canonical"/.test(html)) errors.push('缺少 canonical')
  155. if (!html.includes('application/ld+json')) errors.push('缺少 JSON-LD')
  156. const localBusinessCount = (html.match(/local-business-jsonld-/g) || []).length
  157. if (localBusinessCount < 3) errors.push(`LocalBusiness 数量为 ${localBusinessCount},当前应至少为 3`)
  158. for (const marker of ROUTE_REQUIREMENTS[context.originalRoute] || []) {
  159. if (!html.includes(marker)) errors.push(`缺少完整内容标记 ${marker}`)
  160. }
  161. if (errors.length) {
  162. throw new Error(`[prerender] ${context.originalRoute} 完整性校验失败: ${errors.join(';')}`)
  163. }
  164. return context
  165. }
  166. function writeRouteHtml(route) {
  167. const outputPath = path.join(staticDir, route.route, 'index.html')
  168. fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  169. fs.writeFileSync(outputPath, route.html.trim(), 'utf8')
  170. console.log(`[prerender] ${route.route} -> ${outputPath}`)
  171. }
  172. async function main() {
  173. if (!fs.existsSync(staticDir)) {
  174. console.error('[prerender] dist 目录不存在,请先执行 vue-cli-service build')
  175. process.exit(1)
  176. }
  177. const rendererOptions = {
  178. renderAfterTime: 100,
  179. headless: true,
  180. maxConcurrentRoutes: 1,
  181. timeout: 30000,
  182. // CMS API is proxied through the local origin; external images are non-blocking for HTML output.
  183. skipThirdPartyRequests: true,
  184. inject: { enabled: true },
  185. pageSetup(page, route) {
  186. page.__apiFailures = []
  187. page.on('response', (response) => {
  188. const responseUrl = new URL(response.url())
  189. const isLocalApi = responseUrl.hostname === '127.0.0.1' && responseUrl.pathname.startsWith('/prod-api/')
  190. if (isLocalApi && response.status() >= 400) {
  191. page.__apiFailures.push(`${response.status()} ${response.url()}`)
  192. }
  193. })
  194. page.on('requestfailed', (request) => {
  195. const requestUrl = new URL(request.url())
  196. const isLocalApi = requestUrl.hostname === '127.0.0.1' && requestUrl.pathname.startsWith('/prod-api/')
  197. if (isLocalApi) {
  198. page.__apiFailures.push(`${request.failure()?.errorText || 'request failed'} ${request.url()}`)
  199. }
  200. })
  201. },
  202. async pageHandler(page, route) {
  203. if (page.__apiFailures.length) {
  204. throw new Error(`[prerender] ${route} API 请求失败: ${page.__apiFailures.join(';')}`)
  205. }
  206. await page.waitForSelector(ROUTE_READY_SELECTORS[route], { timeout: 30000 })
  207. await page.waitForSelector('.jsonld-offices-ready', { timeout: 30000 })
  208. if (page.__apiFailures.length) {
  209. throw new Error(`[prerender] ${route} API 请求失败: ${page.__apiFailures.join(';')}`)
  210. }
  211. },
  212. args: ['--no-sandbox', '--disable-setuid-sandbox'],
  213. launchOptions: {
  214. protocolTimeout: 120000
  215. }
  216. }
  217. const chromePath = getChromePath()
  218. if (chromePath) {
  219. rendererOptions.launchOptions.executablePath = chromePath
  220. console.log(`[prerender] 使用 Chrome: ${chromePath}`)
  221. } else {
  222. console.warn('[prerender] 未找到 Chrome,将尝试使用 Puppeteer 内置 Chromium')
  223. }
  224. const prerenderer = new Prerenderer({
  225. staticDir,
  226. server: {
  227. host: '127.0.0.1',
  228. proxy: {
  229. '/prod-api': {
  230. target: API_TARGET,
  231. changeOrigin: true,
  232. secure: false,
  233. proxyTimeout: 30000,
  234. timeout: 30000
  235. }
  236. }
  237. },
  238. renderer: new PuppeteerRenderer(rendererOptions)
  239. })
  240. try {
  241. const articles = await fetchPublishedArticles()
  242. const articleRoutes = articles.map(article => `/news/${article.slug}`)
  243. articleRoutes.forEach(route => {
  244. ROUTE_READY_SELECTORS[route] = '.article__content'
  245. ROUTE_REQUIREMENTS[route] = ['article__content', 'article-jsonld']
  246. })
  247. await prerenderer.initialize()
  248. const renderedRoutes = await prerenderer.renderRoutes([...PRERENDER_ROUTES, ...articleRoutes])
  249. renderedRoutes
  250. .map(postProcess)
  251. .map(validateRoute)
  252. .forEach(writeRouteHtml)
  253. writeSitemap(articles)
  254. console.log(`[prerender] 完成,共 ${renderedRoutes.length} 个路由`)
  255. } catch (error) {
  256. console.error('[prerender] 预渲染失败:', error)
  257. process.exit(1)
  258. } finally {
  259. await prerenderer.destroy()
  260. }
  261. }
  262. main()