抢救性爬取:色影无忌 xitek.com 全站存档工程实录
一个摄影社区即将关停前的抢救性存档工程。这篇记录从反爬对抗、架构设计到代码实现的全过程。
背景:抢救性爬取意味着什么
色影无忌(xitek.com)是中文影像领域的老牌社区,积累了 20 年、上千篇深度器材评测。这类内容的信息密度极高——相机横评、镜头对比测试、参数实测,很多是别处找不到的一手资料。当站点宣布关停,这些内容将一起消失。
「抢救性爬取」和普通爬虫的区别在于:不是快点拿完,而是确保在有限时间内稳定、不失败地全部拿下。它要处理的不只是网站的 HTML,而是反爬、限流、断点续传、长期运行可靠性这一整套工程问题。
技术选型:为什么是 Playwright 而不是 httrack
最初的直觉是「全站镜像用 httrack / wget 就够了」。但实测立刻否决了这个方案:
curl直连首页 → 被 WAF 拦(返回 403 拦截页)httrack/wget→ 同样必被拦- 连 OpenClaw 的浏览器代理(CDP 通道)访问 xitek 都超时
原因是 xitek 部署了 WAF 防火墙 + JS 指纹校验,普通 HTTP 客户端没有真实的浏览器渲染 + TLS 指纹,直接判为机器人。
只有真实的 Chromium 内核(Playwright)+ 完整 JS 执行能正常访问。技术栈定为:
{
"dependencies": {
"crawlee": "^3.0.0",
"playwright": "^1.40.0"
}
}
注:原本计划用 better-sqlite3 做队列,但在 Node v26 下原生编译失败,改用 Node 内置的
node:sqlite(DatabaseSync),零原生依赖,省去编译烦恼。
核心难点:破解 WAF 限流的真实机制
这是整个项目最关键的部分。我通过一组受控实验摸清了 WAF 的行为。
实验 1:换浏览器指纹能否绕过?
// 每次用全新 browser context + 随机 UA + 篡改 canvas,制造"新指纹"
for (let i = 0; i < 3; i++) {
const ctx = await browser.newContext({
userAgent: UAS[i % UAS.length],
colorScheme: i % 2 ? 'dark' : 'light',
});
// ... 抓一篇详情
}
结果:连续 3 个新指纹,前 2 个仍被 WAF 拦,第 3 个才成功。
实验 2:换代理 IP 能否绕过?
把请求打到 Shadowrocket 代理(HTTP 1082)上:
chromium.launch({ proxy: { server: 'http://127.0.0.1:1082' } });
结果:代理出口 IP 第 1 个请求就被拦(代理 IP 池的 reputation 本身更差)。
结论
WAF 是按 IP 频率限流:约 3-4 个请求/窗口后就触发拦截,冷却约 4-5 分钟恢复。换 IP、换指纹都无法真正绕过——唯一可靠的手段是”严格限速 + 强制冷却”。
于是抓取节奏定为:每批 3 页 → 冷却 7 分钟 → 下一批,约 30 页/小时。
架构:可断点续传的任务队列
用 SQLite 做任务状态机,这是「抢救」场景的命脉——任何时刻中断,重启后接着爬,绝不重爬或漏爬:
// queue.js — 基于 node:sqlite 的抓取队列
import { DatabaseSync } from 'node:sqlite';
export const STATUS = {
PENDING: 'pending', RUNNING: 'running', DONE: 'done',
FAILED: 'failed', SKIPPED: 'skipped',
};
export class CrawlQueue {
constructor(dbPath) {
this.db = new DatabaseSync(dbPath);
this.db.exec(`
CREATE TABLE IF NOT EXISTS queue (
url TEXT PRIMARY KEY,
type TEXT NOT NULL, -- list | article | image
status TEXT NOT NULL,
priority INTEGER DEFAULT 100,
attempts INTEGER DEFAULT 0,
next_retry_at INTEGER DEFAULT 0,
error TEXT,
created_at INTEGER, updated_at INTEGER
);
`);
}
next() {
const row = this.db.prepare(`
SELECT * FROM queue
WHERE status = 'pending' AND next_retry_at <= ?
ORDER BY priority ASC, created_at ASC LIMIT 1
`).get(Date.now());
if (!row) return null;
this.db.prepare(`UPDATE queue SET status='running' WHERE url=?`).run(row.url);
return { ...row, extra: row.extra ? JSON.parse(row.extra) : null };
}
fail(url, error, { permanent = false } = {}) {
// 指数退避:30s → 2m → 10m → 1h
const backoff = [30_000, 120_000, 600_000, 3_600_000][attempts - 1] || 3_600_000;
// ... 更新状态和 next_retry_at
}
done(url) { this.db.prepare(`UPDATE queue SET status='done' WHERE url=?`).run(url); }
}
任务分三类,用 priority 控制优先级:
list(列表页,priority 0-10):抓分页解析出所有详情 URL 入队article(详情页,priority 20):抓正文、落盘image(图片,priority 40):下载文章内引用图
破解评测分页结构
用真实浏览器抓首页后,我从原始 DOM 里找到了分页规律:
评测总数 ≈ 1642 篇
列表页: https://info.xitek.com/reviews/1642-{n}.html (n = 2..152)
详情页: https://info.xitek.com/reviews/YYYYMM/DD-ID.html
图片源: https://cmscdn.xitek.com/uploads/allimg/YYYYMMDD/xxx.jpg
正文容器: .sam_body
标题: h1
日期: /\d{4}-\d{2}-\d{2}/
正文解析用正则而非 DOM 库,避免引入额外依赖:
// article.js — 从详情页 HTML 提取结构化文章
export function parseArticle(html, url) {
const extract = (re, s = html) => {
const m = s.match(re);
return m ? m[1].trim() : null;
};
const title = stripTags(extract(/<h1[^>]*>([\s\S]*?)<\/h1>/i) || '').trim();
const date = extract(/(\d{4}-\d{2}-\d{2})/);
const author = extract(/来源[::]\s*([^<]{1,20})/i) || extract(/作者[::]\s*([^<]{1,20})/i);
// .sam_body 容器内的正文 HTML
let bodyHtml = extract(/<div[^>]*class="[^"]*sam_body[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
// 提取 cmscdn 图片
const images = [];
const imgRe = /src="([^"]+\.jpg)"/gi; let m;
while ((m = imgRe.exec(html))) {
const src = m[1];
if (src.startsWith('data:')) continue;
if (!src.includes('cmscdn')) continue;
images.push(src);
if (images.length >= 60) break;
}
return { title, author, date, bodyHtml, images, url, id };
}
从列表页批量发现详情
export function parseListUrls(html) {
const urls = new Set();
const re = /href="(\/\/(?:info\.xitek\.com)?\/reviews\/\d{6}\/\d+-\d+\.html)"/g;
let m;
while ((m = re.exec(html))) {
const abs = m[1].startsWith('//') ? 'https:' + m[1] : 'https://info.xitek.com' + m[1];
urls.add(abs);
}
return [...urls];
}
列表页每抓一次能发现 15-16 个详情 URL,去重后入队。152 个列表页全部抓完,详情队列自然累积到约 2000+(去重后实际约 1642 篇)。
反爬浏览器封装
Playwright 浏览器封装的关键——隐藏自动化特征 + 限速 + WAF 检测:
// browser.js — 反爬指纹 + 限速 + WAF 检测
import { chromium } from 'playwright';
export class BrowserClient {
constructor({ delayMs = 9000, jitterMs = 5000 } = {}) {
this.delayMs = delayMs; this.jitterMs = jitterMs;
}
async start() {
this.browser = await chromium.launch({
headless: true,
args: ['--disable-blink-features=AutomationControlled', '--no-sandbox'],
});
const ctx = await this.browser.newContext({
locale: 'zh-CN',
timezoneId: 'Asia/Shanghai',
userAgent: 'Mozilla/5.0 (...) Chrome/126.0.0.0 Safari/537.36',
});
// 藏 webdriver,伪装成真实 Chrome
await ctx.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
});
this.page = await ctx.newPage();
}
// 限速:保证距上次请求至少 delayMs + jitter 随机
async throttle() {
const elapsed = Date.now() - this.lastRequest;
const wait = this.delayMs + Math.floor(Math.random() * this.jitterMs);
if (elapsed < wait) await sleep(wait - elapsed);
this.lastRequest = Date.now();
}
// 带绝对超时的 goto,防止页面 WAF 挑战 JS 挂起
async gotoWithTimeout(url, timeout = 65000) {
return await Promise.race([
this.page.goto(url, { waitUntil: 'domcontentloaded', timeout }),
new Promise((_, rej) => setTimeout(() => rej(new Error('goto timeout')), timeout)),
]);
}
async fetch(url) {
await this.throttle();
const resp = await this.gotoWithTimeout(url);
const title = await this.page.title();
if (resp?.status() === 403 || /WAF|拦截/.test(title)) {
return { html: null, waf: true };
}
return { html: await this.page.content(), waf: false };
}
}
主循环:批次 + 冷却 + 断点续传
while (true) {
if (wafCooldown && Date.now() < wafCooldown) {
console.log('[cooldown] 等待冷却'); break; // 交给 crontab 下次调用
}
const task = q.next();
if (!task) break;
const { html, waf } = await client.fetch(task.url);
if (waf) { q.fail(task.url, 'WAF blocked'); wafCooldown = Date.now() + 3*60*1000; continue; }
if (task.type === 'list') {
const urls = parseListUrls(html);
q.enqueueMany(urls.map(u => ({ url: u, type: 'article', priority: 20 })));
q.done(task.url);
} else if (task.type === 'article') {
const art = parseArticle(html, task.url);
saveArchive(art, html); // 原始 HTML 落盘
saveKnowledge(art); // 清洗后 Markdown 落盘
enqueueArticleImages(q, art); // 图片入队
q.done(task.url);
}
}
可靠运行的工程细节:从守护脚本到 crontab
踩坑 1:bash 守护脚本卡死
最初用 crawl-loop.sh 常驻进程,内层 while 循环负责「批次→冷却→批次」。跑了几个小时发现某个批次 node 进程挂起,守护脚本的清理逻辑也连带卡住,6 小时没产出。
根因是守护脚本里 pkill -f "src/index.js" 与批次统计的 node src/index.js --stats 互相误杀,状态错乱。
改为 crontab 驱动:每次跑一个独立短命批次(最多 240s 超时强制结束),由系统 crontab 每 8 分钟触发一次。进程天然隔离,单批崩溃不影响后续:
# crawl-batch.sh — 单批执行,PID 锁防重入,240s 超时保护
./crawl-batch.sh # 每次抓 3 页后退出
PATH=/opt/homebrew/bin:/usr/bin:/bin
*/8 * * * * /Users/zhangshengrui/projects/xitek-save/crawler/crawl-batch.sh
踩坑 2:cron guardian 用 LLM 不可靠
最初用 OpenClaw 的 cron 任务(agentTurn 型)做进程守护,结果某次 LLM 请求超时导致 guardian 没执行,进程死了一晚上没人管。改用系统 crontab 直接调 shell,完全不依赖 LLM/OpenClaw,可靠性最高。
三层存储架构
| 层 | 位置 | 内容 | 用途 |
|---|---|---|---|
| 原始存档 | archive/ | 完整 HTML + 图片 | 离线浏览、保真备份 |
| 结构化 DB | data/xitek.db | 队列 + 元数据 | 断点续传、检索索引 |
| 知识库 | knowledge/reviews/<年>/<id>.md | 清洗后正文 | 喂给 RAG |
// knowledge 落盘:清洗正文为 Markdown,供 RAG
function saveKnowledge(art) {
const y = (art.date || 'unknown').slice(0, 4);
const file = `knowledge/reviews/${y}/${art.id || Date.now()}.md`;
const md = [
`# ${art.title}`,
`- URL: ${art.url}`,
`- 日期: ${art.date}`,
`- 作者: ${art.author}`,
``,
`## 正文`, ``,
art.bodyText,
``, `## 图片`,
...(art.images || []).map(i => `- ${i}`),
].join('\n');
writeFileSync(file, md);
}
现状与后续
工程已进入稳定生产爬取阶段。列表页 152 个全部抓完,评测详情页已落盘 100+ 篇(archive HTML + knowledge Markdown 双份),队列自动从列表推进到详情、再到图片,断点续传 + crontab 自动续跑保证不中断。
后续规划:
- 评测全量:约 1642 篇详情 + 图片全部落盘
- 扩展到 P1/P2:info 其他资讯栏目、vision/she/travel 内容频道
- RAG 构建:把
knowledge/*.md做切分 → 向量化 → 接入已有的 rag-service,形成可检索的摄影知识库 - 离线浏览:archive 目录结构天然可作为静态站点离线查阅
这篇的完整代码和运行配置都在 ~/projects/xitek-save/,规划书在 docs/PLAN.md。
抢救性爬取最有价值的不是代码,而是「敬畏内容」的态度——当一个承载了二十年社区记忆的平台即将消失,把它完整、稳定、可检索地保存下来,本身就是有长期意义的事。