179 lines
6.1 KiB
JavaScript
179 lines
6.1 KiB
JavaScript
/**
|
||
* SEO 端到端检测脚本
|
||
* 模拟爬虫与普通用户请求,验证是否返回正确的页面
|
||
*
|
||
* 使用前请先启动服务器: npm run start
|
||
* 然后运行: npm run test 或 node test-seo.js
|
||
*/
|
||
|
||
const http = require('http')
|
||
const https = require('https')
|
||
|
||
const BASE_URL = process.env.SEO_TEST_URL || 'http://localhost:3000'
|
||
|
||
// 要检测的路由及期望的 SEO 标题关键词(与 useSEO.js 一致,天远数据)
|
||
const ROUTES = [
|
||
{ path: '/', titleKeyword: '天远数据' },
|
||
{ path: '/agent', titleKeyword: '天远数据代理' },
|
||
{ path: '/help', titleKeyword: '天远数据帮助中心' },
|
||
{ path: '/inquire/personalData', titleKeyword: '个人综合风险报告' },
|
||
{ path: '/agent/promote', titleKeyword: '推广码' },
|
||
{ path: '/historyQuery', titleKeyword: '我的报告' }
|
||
]
|
||
|
||
function request(url, userAgent) {
|
||
return new Promise((resolve, reject) => {
|
||
const lib = url.startsWith('https') ? https : http
|
||
const req = lib.get(url, {
|
||
headers: { 'User-Agent': userAgent },
|
||
timeout: 10000
|
||
}, res => {
|
||
const chunks = []
|
||
res.on('data', chunk => chunks.push(chunk))
|
||
res.on('end', () => {
|
||
resolve({
|
||
statusCode: res.statusCode,
|
||
headers: res.headers,
|
||
body: Buffer.concat(chunks).toString('utf-8')
|
||
})
|
||
})
|
||
})
|
||
req.on('error', reject)
|
||
req.on('timeout', () => {
|
||
req.destroy()
|
||
reject(new Error('请求超时'))
|
||
})
|
||
})
|
||
}
|
||
|
||
function extractTitle(html) {
|
||
const match = html.match(/<title[^>]*>([^<]+)<\/title>/i)
|
||
return match ? match[1].trim() : null
|
||
}
|
||
|
||
function hasMetaDescription(html) {
|
||
return /<meta\s+name=["']description["']\s+content=["']/i.test(html)
|
||
}
|
||
|
||
function isSEOTemplate(html) {
|
||
return (
|
||
/<meta\s+name=["']description["']/i.test(html) &&
|
||
/<meta\s+name=["']keywords["']/i.test(html) &&
|
||
/<link\s+rel=["']canonical["']/i.test(html)
|
||
)
|
||
}
|
||
|
||
async function runTest(route, titleKeyword) {
|
||
const url = BASE_URL + route
|
||
const results = { route, crawler: null, normal: null }
|
||
|
||
// 1. 爬虫请求
|
||
try {
|
||
const crawlerRes = await request(url, 'Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/search/spider.html)')
|
||
const title = extractTitle(crawlerRes.body)
|
||
const seoHeader = crawlerRes.headers['x-seomiddleware']
|
||
|
||
results.crawler = {
|
||
status: crawlerRes.statusCode,
|
||
title,
|
||
hasSEOMeta: hasMetaDescription(crawlerRes.body),
|
||
isSEOTemplate: isSEOTemplate(crawlerRes.body),
|
||
seoHeader: seoHeader || '(无)'
|
||
}
|
||
|
||
if (crawlerRes.statusCode !== 200) {
|
||
results.crawler.error = `HTTP ${crawlerRes.statusCode}`
|
||
} else if (!title || !title.includes(titleKeyword)) {
|
||
results.crawler.error = `标题不匹配,期望含「${titleKeyword}」,实际: ${title || '未找到'}`
|
||
} else if (!results.crawler.isSEOTemplate) {
|
||
results.crawler.error = '响应中缺少完整 SEO 标签(description/keywords/canonical)'
|
||
}
|
||
} catch (e) {
|
||
results.crawler = { error: e.message || String(e) }
|
||
}
|
||
|
||
// 2. 普通用户请求(仅验证能正常返回)
|
||
try {
|
||
const normalRes = await request(url, 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||
results.normal = {
|
||
status: normalRes.statusCode,
|
||
bodyLength: normalRes.body.length
|
||
}
|
||
if (normalRes.statusCode !== 200) {
|
||
results.normal.error = `HTTP ${normalRes.statusCode}`
|
||
}
|
||
} catch (e) {
|
||
results.normal = { error: e.message || String(e) }
|
||
}
|
||
|
||
return results
|
||
}
|
||
|
||
function printResult(results) {
|
||
const { route, crawler, normal } = results
|
||
|
||
console.log(`\n📍 路由: ${route}`)
|
||
console.log('─'.repeat(60))
|
||
|
||
const crawlerOk = crawler && !crawler.error && crawler.status === 200 && crawler.isSEOTemplate
|
||
if (crawlerOk) {
|
||
console.log(' 爬虫请求: ✓ 通过')
|
||
console.log(` 标题: ${crawler.title}`)
|
||
console.log(` 响应头: X-SEOMiddleware = ${crawler.seoHeader}`)
|
||
} else {
|
||
console.log(' 爬虫请求: ✗ 未通过')
|
||
if (crawler && crawler.error) console.log(` 原因: ${crawler.error}`)
|
||
else if (crawler) console.log(` 状态: ${crawler.status}, 标题: ${crawler.title || '无'}`)
|
||
else console.log(' 请求失败')
|
||
}
|
||
|
||
const normalOk = normal && !normal.error && normal.status === 200
|
||
if (normalOk) {
|
||
console.log(' 普通用户: ✓ 正常 (SPA)')
|
||
} else {
|
||
console.log(' 普通用户: ✗ 异常')
|
||
if (normal && normal.error) console.log(` 原因: ${normal.error}`)
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
console.log('='.repeat(60))
|
||
console.log('SEO 端到端检测')
|
||
console.log('='.repeat(60))
|
||
console.log(`目标地址: ${BASE_URL}`)
|
||
console.log('若服务器未启动,请先执行: npm run start')
|
||
console.log('')
|
||
|
||
let allPass = true
|
||
|
||
for (const r of ROUTES) {
|
||
try {
|
||
const results = await runTest(r.path, r.titleKeyword)
|
||
printResult(results)
|
||
|
||
const crawlerOk = results.crawler && !results.crawler.error && results.crawler.isSEOTemplate
|
||
const normalOk = results.normal && !results.normal.error && results.normal.status === 200
|
||
if (!crawlerOk || !normalOk) allPass = false
|
||
} catch (e) {
|
||
console.log(`\n📍 路由: ${r.path}`)
|
||
console.log(' 错误:', e.message)
|
||
allPass = false
|
||
}
|
||
}
|
||
|
||
console.log('\n' + '='.repeat(60))
|
||
if (allPass) {
|
||
console.log('✓ 全部检测通过:爬虫获得 SEO 模板,普通用户获得 SPA')
|
||
} else {
|
||
console.log('✗ 部分检测未通过,请检查服务器与模板配置')
|
||
}
|
||
console.log('='.repeat(60))
|
||
|
||
process.exit(allPass ? 0 : 1)
|
||
}
|
||
|
||
main().catch(err => {
|
||
console.error('检测失败:', err)
|
||
process.exit(1)
|
||
})
|