-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathprerender.cjs
More file actions
198 lines (180 loc) · 6.47 KB
/
Copy pathprerender.cjs
File metadata and controls
198 lines (180 loc) · 6.47 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
/**
* 构建后预渲染脚本(SSG)
*
* 用 puppeteer-core 驱动系统 Chrome 把各路由渲染成带完整正文的静态 HTML,
* 解决纯 CSR 下爬虫抓到空 <div id="root"> 导致不收录的问题。
*
* 产物:
* dist/index.html ← 首页(覆盖 SPA 入口,含已渲染正文)
* dist/faq/index.html ← FAQ 页
* dist/formats/index.html ← 格式表页
*
* nginx 配合 try_files $uri $uri/ /index.html 即可按目录返回对应预渲染页。
*
* 用法:在 `yarn build` 末尾自动执行(见 package.json 的 build 脚本)。
*/
const path = require('path')
const fs = require('fs')
const http = require('http')
const puppeteer = require('puppeteer-core')
const distDir = path.join(__dirname, '..', 'dist')
const chromeUserDataDir = path.join(__dirname, '.chrome-prerender-profile')
const routes = ['/', '/faq', '/formats']
// 常见浏览器可执行文件路径(按优先级),避免依赖 puppeteer 自带 chromium 下载
const CHROME_CANDIDATES = [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
]
function findChrome() {
for (const p of CHROME_CANDIDATES) {
if (fs.existsSync(p)) return p
}
throw new Error(
'[prerender] 未找到系统 Chrome/Chromium,请安装 Google Chrome,或在 scripts/prerender.cjs 的 CHROME_CANDIDATES 中补充可执行文件路径。'
)
}
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.ico': 'image/x-icon',
'.webp': 'image/webp',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.txt': 'text/plain; charset=utf-8',
'.xml': 'application/xml; charset=utf-8',
}
// 极简静态服务器:带文件扩展名的按文件返回,无扩展名的 SPA 路由回退到 index.html
function createServer(root) {
const indexHtml = path.join(root, 'index.html')
return http.createServer((req, res) => {
const urlPath = decodeURIComponent(req.url.split('?')[0].split('#')[0])
const filePath = path.join(root, urlPath)
const hasExt = path.extname(urlPath) !== ''
const sendFile = (file, status = 200) => {
fs.readFile(file, (err, data) => {
if (err) {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
return
}
res.writeHead(status, {
'Content-Type': MIME[path.extname(file)] || 'application/octet-stream',
})
res.end(data)
})
}
// 有扩展名:尝试作为静态资源返回
if (hasExt) {
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
sendFile(filePath)
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' })
res.end('Not Found')
}
return
}
// 无扩展名:SPA 路由,返回 index.html(如 /faq、/formats)
sendFile(indexHtml)
})
}
;(async () => {
if (!fs.existsSync(distDir)) {
throw new Error('[prerender] dist 目录不存在,请先执行 vite build。')
}
const executablePath = findChrome()
console.log('[prerender] 使用浏览器:', executablePath)
// 启动静态服务器
const server = createServer(distDir)
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const port = server.address().port
const baseURL = `http://127.0.0.1:${port}`
console.log('[prerender] 静态服务器:', baseURL)
const browser = await puppeteer.launch({
executablePath,
userDataDir: chromeUserDataDir,
headless: true,
args: [
'--lang=zh-CN', // 强制中文环境,保证预渲染产物为站点主语言
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-crash-reporter',
'--disable-breakpad',
],
})
try {
for (const route of routes) {
const page = await browser.newPage()
await page.setViewport({ width: 1440, height: 900 })
// 屏蔽第三方请求(如统计脚本),避免产生构建期脏数据
await page.setRequestInterception(true)
page.on('request', (req) => {
const u = req.url()
if (u.startsWith(baseURL) || u.startsWith('data:') || u.startsWith('blob:')) {
req.continue()
} else {
req.abort()
}
})
// 收集控制台错误,便于排查渲染失败
const errors = []
page.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text()
// 忽略第三方资源被拦截产生的 ERR_FAILED(构建期预期行为)
if (text.includes('net::ERR_FAILED')) return
errors.push(text)
}
})
page.on('pageerror', (err) => errors.push(String(err)))
await page.goto(`${baseURL}${route}`, {
waitUntil: 'networkidle0',
timeout: 30000,
})
// 等待 React 挂载(#root 出现子节点)
await page.waitForFunction(
() => document.querySelector('#root') && document.querySelector('#root').children.length > 0,
{ timeout: 15000 }
)
// 额外等待 i18n 初始化与副作用完成
await new Promise((r) => setTimeout(r, 1500))
const html = await page.content()
const routePath = route === '/' ? '' : route
const outDir = path.join(distDir, routePath)
fs.mkdirSync(outDir, { recursive: true })
const outFile = path.join(outDir, 'index.html')
fs.writeFileSync(outFile, html.trim())
const rootText = await page.evaluate(
() => (document.querySelector('#root')?.textContent || '').replace(/\s+/g, ' ').slice(0, 80)
)
console.log(
`[prerender] 已写入 ${path.relative(process.cwd(), outFile)}` +
(errors.length ? ` ⚠️ 控制台错误: ${errors.length} 条` : '') +
` | 正文预览: ${rootText}`
)
if (errors.length) {
errors.forEach((e) => console.log(` └─ ${e}`))
}
await page.close()
}
console.log('[prerender] 完成')
} finally {
await browser.close()
server.close()
}
})().catch((err) => {
console.error('[prerender] 失败:', err)
process.exit(1)
})