深夜爬书:一个DBA转后端的AI初探实战
“你这数据库索引建得不错,但爬虫写得跟屎一样。”
—— 我们组新来的算法工程师上周五下午在茶水间说的原话。
好吧,我认了。作为一个从DBA半路出家的后端开发,我对SQL执行计划、死锁分析、索引碎片率这些玩意儿如数家珍,但一碰到网络请求、反爬策略、动态渲染页面,就感觉像被扔进了异世界的副本——技能全废,连回城卷轴都用不了。
不过事情总得有人干。上个月我们产品提了个需求:“能不能把市面上主流技术书籍的目录结构和关键词抓下来,用来训练我们的AI推荐模型?” 我一听,好家伙,这不是典型的爬虫+AI+书籍三件套吗?领导眼睛一亮,转头就拍我肩膀:“小李啊,你不是刚学AI嘛,这个项目交给你了,两周上线。”
我当时差点一口老血喷在工位键盘上。两周?还要处理反爬、解析PDF、去重、入库、清洗……产品经理你怕不是以为爬虫是点外卖?
但没办法,谁让我两个月前入职时简历上写了“对新技术有强烈好奇心”呢。于是,一个深夜,我泡了杯速溶咖啡(别笑,真·打工人标配),打开了VS Code,开始了这段“痛并快乐着”的技术探索。
一场始于“书籍目录”的执念
其实我对书籍一直有种奇怪的执念。以前做DBA的时候,最爱看《高性能MySQL》《数据库系统概念》这种厚砖头,觉得里面每一个章节标题都像精心设计的B+树索引——层次分明、指向明确。现在转后端了,反而发现很多技术书的电子版目录乱得像没加索引的全表扫描:有的用PDF嵌套图片,有的用JS动态加载,还有的干脆就是一张大图糊你脸上。
而这次的需求,偏偏就是要从这些混乱中提取出干净的层级目录结构。比如:
第1章 引言
1.1 背景
1.2 目标
第2章 系统架构
2.1 微服务拆分
2.2 数据库选型
2.2.1 MySQL vs PostgreSQL
2.2.2 分库分表策略
这种结构对后续的AI训练至关重要——它能告诉模型“2.2.1”是“2.2”的子话题,而不是平级内容。但现实是,90%的在线书籍平台(包括某些知名电商)根本不提供结构化API,只能靠爬虫硬啃。
爬虫初体验:从“Hello World”到被封IP
一开始我天真地以为,用requests + BeautifulSoup不就完事了?写个循环,解析HTML里的<h1>、<h2>标签,搞定!
结果第一天晚上跑脚本,刚抓了50本书,IP就被封了。返回的全是:
<div class="error">Access Denied. Please contact support.</div>
运维同事路过看了一眼,冷笑:“兄弟,你这是拿裸奔的脚本去撞人家的WAF啊?知道他们用了Cloudflare + 验证码 + 行为分析吗?”
行吧,我认栽。开始研究反爬对策。
反爬对抗三板斧
- User-Agent轮换:不能老用默认的
python-requests/2.28.1,那等于举着“我是机器人”的牌子逛街。 - 请求间隔随机化:加个
time.sleep(random.uniform(1, 3)),模拟人类手速。 - 代理池:搞了个免费代理列表,虽然不稳定,但聊胜于无。
但这些还是不够。有些网站检测鼠标轨迹、点击频率,甚至要求你完成reCAPTCHA。这时候我意识到:静态HTML解析已经过时了。
于是祭出了终极武器——Playwright。
from playwright.sync_api import sync_playwright
def fetch_book_toc(url):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.set_extra_http_headers({
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
})
page.goto(url)
page.wait_for_selector("div.toc-container", timeout=10000) # 等待目录加载
toc_html = page.content()
browser.close()
return toc_html
Playwright能模拟真实浏览器行为,自动处理JS渲染、AJAX加载,甚至可以截图debug。虽然慢了点(单页平均2秒),但成功率从30%飙到90%。代价是——我的MacBook风扇狂转,像个小型直升机。
解析地狱:PDF、图片、还有那个该死的“扫描版”
你以为拿到HTML就万事大吉了?Too young.
很多出版社把书做成PDF嵌入网页,或者直接上传扫描版图片。这种情况下,目录根本不是文本,而是图像!这意味着:
- 无法用CSS选择器提取
- 无法直接喂给AI模型
- 必须OCR!
于是我不得不引入Tesseract OCR + PyMuPDF(又名fitz)来处理PDF。
import fitz # PyMuPDF
import pytesseract
from PIL import Image
def extract_text_from_pdf(pdf_path):
doc = fitz.open(pdf_path)
text = ""
for page_num in range(len(doc)):
page = doc.load_page(page_num)
# 先尝试提取文本(如果是可复制PDF)
text += page.get_text()
# 如果文本为空,说明是扫描版,走OCR
if not text.strip():
pix = page.get_pixmap()
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
text += pytesseract.image_to_string(img, lang='chi_sim+eng')
return text
但OCR的准确率……嗯,只能说“仅供参考”。特别是技术书里的代码片段、数学公式,OCR出来经常是:
def get_user(id):
retum User.find_by_id(id) # 注意:retum 应该是 return
这种脏数据要是直接进数据库,我怕半夜会被MySQL的binlog日志追杀。
数据入库:DBA的倔强
作为一个前DBA,我无法忍受把爬下来的数据随便塞进MongoDB就完事。不行,必须规范化!
我设计了一张表:
| 字段 | 类型 | 说明 |
|---|---|---|
| book_id | VARCHAR(64) | 书籍唯一ID(ISBN或URL哈希) |
| title | TEXT | 书名 |
| chapter_level | TINYINT | 章节层级(1=章,2=节,3=小节) |
| chapter_title | TEXT | 章节标题 |
| parent_id | VARCHAR(64) | 父章节ID(用于构建树) |
| raw_content | LONGTEXT | 原始抓取内容(用于回溯) |
| clean_flag | BOOLEAN | 是否已清洗 |
| created_at | DATETIME | 创建时间 |
重点来了:parent_id + 自引用外键。这样就能用递归CTE(Common Table Expression)轻松查出整棵树:
WITH RECURSIVE toc_tree AS (
SELECT id, chapter_title, chapter_level, parent_id
FROM book_toc
WHERE book_id = 'isbn123' AND parent_id IS NULL
UNION ALL
SELECT b.id, b.chapter_title, b.chapter_level, b.parent_id
FROM book_toc b
INNER JOIN toc_tree t ON b.parent_id = t.id
)
SELECT * FROM toc_tree ORDER BY chapter_level;
看到没?这才是DBA的浪漫。产品经理想要“扁平化列表”?行,我给他;想要“树形JSON”?也行,前端自己递归。但我的数据库必须保持范式,否则晚上睡不着觉。
AI初探:从“喂数据”到“理解结构”
数据入库后,下一步是训练AI模型。我最近在学Hugging Face的Transformers,于是尝试用bert-base-chinese做章节分类。
但很快发现问题:BERT擅长理解语义,但不擅长理解层级关系。比如“2.2.1 MySQL vs PostgreSQL”和“3.1.1 Redis持久化”,语义不同,但结构相似。如果只喂标题文本,模型根本不知道“2.2.1”属于“第2章”。
于是我想了个歪招:把章节路径拼成前缀。
# 输入给模型的文本
input_text = "[CHAPTER_PATH] 第2章/2.2/2.2.1 [TITLE] MySQL vs PostgreSQL"
这样,模型就能通过[CHAPTER_PATH]学习到层级上下文。训练时用交叉熵损失,预测时输出最可能的父节点。
效果?勉强能用。准确率78%,比纯文本高了15个百分点。虽然离产品要的“95%准确”还差得远,但至少证明了结构信息对AI理解很重要——这不就是我们DBA天天喊的“元数据驱动”嘛!
性能优化:从单机到分布式
最初脚本是单线程跑的,抓1000本书要两天。眼看deadline逼近,我一咬牙,上了Celery + Redis。
# tasks.py
@celery.task
def crawl_book(book_url):
try:
html = fetch_book_toc(book_url)
toc_items = parse_toc(html)
save_to_db(toc_items)
except Exception as e:
logger.error(f"Failed to crawl {book_url}: {str(e)}")
raise
启动10个worker,配合代理池,速度提升5倍。但新问题来了:数据库写入成为瓶颈。
每秒几百条INSERT,InnoDB buffer pool疯狂刷盘。我赶紧做了三件事:
- 批量插入:攒够100条再commit
- 关闭autocommit:手动控制事务
- 临时调大innodb_log_file_size:避免频繁checkpoint
# 批量插入示例
with connection.cursor() as cursor:
cursor.executemany(
"INSERT INTO book_toc (...) VALUES (%s, %s, ...)",
batch_data
)
connection.commit()
最终,10万条目录数据,2小时内搞定。凌晨三点,看着监控图表上的平滑曲线,我长舒一口气——终于不用被产品经理在晨会上diss了。
经验教训:别让爬虫变成“犯罪工具”
最后说点掏心窝子的话。
- 遵守robots.txt:别真当自己是黑客帝国。我们只抓公开、允许爬取的内容。
- 设置合理的速率限制:别把人家服务器干挂了,否则法务函比offer来得快。
- 数据清洗比抓取更耗时:80%的时间花在处理脏数据上,别低估。
- DBA思维救我命:规范化设计、事务控制、索引优化,这些老本行在AI时代依然有用。
写在最后
现在这个系统已经稳定运行一个月了,每天自动抓取新书目录,喂给AI模型做增量训练。上周产品还夸我:“小李,你这爬虫写得越来越像人了。”
我笑了笑,没告诉他——其实是因为我把所有异常处理都加上了,连验证码弹窗都能自动跳过(好吧,其实是绕过去了)。
作为一个从DBA转过来的后端,我越来越觉得:技术没有高低贵贱,只有适不适合场景。爬虫不是玩具,书籍不是静态资源,AI也不是魔法。它们都是工具,而我们要做的,是在混乱中建立秩序——就像当年我给那个烂到爆的业务库重建索引一样。
对了,如果你也在做类似项目,欢迎交流。不过别在周一上午找我,那时候我通常在修周末爬虫崩掉导致的数据库死锁……
(完)
附:关键依赖清单
requests==2.31.0 beautifulsoup4==4.12.2 playwright==1.39.0 PyMuPDF==1.23.6 pytesseract==0.3.10 celery==5.3.4 redis==5.0.1 transformers==4.35.0 torch==2.1.0

评论 0