深夜爬书:一个DBA转后端的AI初探实战

李建军_创新
2025-12-17 22:13
阅读 1360

“你这数据库索引建得不错,但爬虫写得跟屎一样。”
—— 我们组新来的算法工程师上周五下午在茶水间说的原话。

好吧,我认了。作为一个从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 + 验证码 + 行为分析吗?”

行吧,我认栽。开始研究反爬对策。

反爬对抗三板斧

  1. User-Agent轮换:不能老用默认的python-requests/2.28.1,那等于举着“我是机器人”的牌子逛街。
  2. 请求间隔随机化:加个time.sleep(random.uniform(1, 3)),模拟人类手速。
  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疯狂刷盘。我赶紧做了三件事:

  1. 批量插入:攒够100条再commit
  2. 关闭autocommit:手动控制事务
  3. 临时调大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了。


经验教训:别让爬虫变成“犯罪工具”

最后说点掏心窝子的话。

  1. 遵守robots.txt:别真当自己是黑客帝国。我们只抓公开、允许爬取的内容。
  2. 设置合理的速率限制:别把人家服务器干挂了,否则法务函比offer来得快。
  3. 数据清洗比抓取更耗时:80%的时间花在处理脏数据上,别低估。
  4. 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

最热最新
暂无评论
李建军_创新Lv.1
0
影响力
0
文章
0
粉丝