Python爬虫从抓取到入库:用MySQL连接池实现小说数据持久化
简介这份面向Python爬虫入门者的实操型示例工程围绕如何抓取在线小说并存入MySQL数据库展开适合正在学习网络爬虫与数据落地的开发者参考。资源压缩包仅4KB共含4个文件两个Python脚本分别实现数据库工具封装与爬虫主流程一个配置文件管理MySQL连接参数一份Markdown说明梳理了从建库建表到分页抓取的完整思路。已有898人学习下载代码量不大却覆盖了请求发送、页面解析、数据清洗、SQL写入、异常捕获与批量插入等关键环节附带的文档还补充了日志记录、定时任务等优化方案。读者可借此快速搭建自己的小说下载工具并迁移到其他静态网站的数据采集任务是一份轻量且可扩展的入门参考资料。整体而言该资源麻雀虽小五脏俱全既能巩固爬虫基础也能为后续工程化开发提供参考。1. 用 Python 把整本小说塞进 MySQL先想清楚这四件事拿到 Day04 这套源码里面没有花哨框架只有 requests、BeautifulSoup、pymysql 和 DBUtils 四个主力但足以跑通“抓取—解析—入库”的完整管线。很多人写爬虫卡住的地方不在抓取而在最后一步数据写不进 MySQL、写着写着连接断开、重新运行又产生大量重复章节。要解决这些不是拼接 SQL 就够了还得想清楚连接怎么复用、文本怎么清洗、重复怎么防止、失败怎么重试。下面按 mysql_DBUtils.py、dbMysqlConfig.cnf、book_db.py 三个文件拆开讲为什么连接池要单独放一个文件为什么小说正文用 MEDIUMTEXT为什么批量插入比逐条插入靠谱以及爬一半断了如何续爬。适合刚学完 Python 基础、想把自己追的小说同步到本地数据库的开发者。2. 环境准备与数据库表设计requests、bs4、pymysql 的选型和建表 SQL2.1 依赖库requests、BeautifulSoup、pymysql、DBUtils 各自解决什么问题先明确分工requests 负责发送 HTTP 请求把目标页面的 HTML 文本带回来BeautifulSoup 负责解析这段 HTML定位章节标题和正文所在的节点pymysql 是 Python 连接 MySQL 的驱动负责执行 SQLDBUtils 在 pymysql 之上封装了一层连接池避免每写一条 SQL 就新建一个数据库连接。为什么不用 urllib 和正则urllib 设置请求头、超时、重试的代码比较啰嗦requests 的 Session 对象还能自动维护 cookies面对需要登录态的站点会省事很多。正则在解析扁平结构时还可以但 HTML 标签嵌套一变正则就要跟着改BeautifulSoup 则能按标签 id、class 或属性定位容错性明显更好。DBUtils 也不是必须的但爬小说经常一跑就是几千章如果每个章节都新建连接MySQL 的max_connections很快会被打满表现就是pymysql.err.OperationalError报Too many connections连接池能让连接反复复用降低资源开销。安装命令如下建议先把 Python 和 MySQL 装到可用状态再在终端里执行# requests 负责 HTTP 请求bs4 负责 HTML 解析 pip install requests beautifulsoup4 pymysql dbutils四个库都是纯 Python 或带少量依赖安装后不需要额外配置环境变量。这里有一点要注意DBUtils 的导入路径在不同版本中不一致Day04 源码中出现的是from dbutils.pooled_db import PooledDB遇到老版本代码写from DBUtils.PooledDB import PooledDB时要按当前安装的版本来调整。2.2 用 dbMysqlConfig.cnf 分离数据库配置数据库连接信息写死在 .py 里是爬虫项目最常见的坏味道代码一分享密码就跟着泄露换一台机器跑还要停下来改好几处。dbMysqlConfig.cnf 把 host、port、user、password、database 和连接池参数统一抽出来所有模块都用 configparser 读它改配置不需要动代码。下面是这份配置文件的典型内容[mysql] host 127.0.0.1 port 3306 user root password your_password database novel_db charset utf8mb4 maxconnections 5 mincached 1 maxcached 3 blocking true各参数含义如下表所示参数作用建议值host数据库地址127.0.0.1优先于 localhostport端口3306 或数据库实际端口user连接用户名用专用账号而不是 root 更安全password密码放在 cnf 后建议设置文件权限charset客户端字符集utf8mb4兼容 emojimaxconnections连接池最大连接数510mincached启动时预创建的连接数1maxcached池中保留的最大空闲连接数3blocking连接耗尽时是否等待trueconfigparser 默认把#当作注释如果密码里恰好有#用引号把整个密码包起来。host 建议写127.0.0.1而不是localhost因为 localhost 在部分 MySQL 客户端里会默认走 unix socket而远程连接必须走 TCP/IP两种方式可能导致连接失败的原因不同。2.3 建库建表小说章节表应该有哪些字段爬取的最终目的是方便检索和增量更新所以表结构在设计阶段就要考虑去重和排序。小说表不需要做成多表关联一张chapters表足够承载书名、章节标题、章节内容、章节序号和来源 URL。下面这段 SQL 可以直接在 MySQL 中执行CREATE DATABASE IF NOT EXISTS novel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE novel_db; CREATE TABLE IF NOT EXISTS chapters ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, book_name VARCHAR(128) NOT NULL COMMENT 小说名, chapter_title VARCHAR(255) NOT NULL COMMENT 章节标题, content MEDIUMTEXT NOT NULL COMMENT 章节正文, chapter_order INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 章节序号, url VARCHAR(512) DEFAULT NULL COMMENT 来源URL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_book_order (book_name, chapter_order), KEY idx_title (chapter_title) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;执行方式是在 mysql 客户端里粘贴或者保存成 .sql 文件后source /path/to/init.sql用 MySQL Workbench 直接跑也可以。book_name chapter_order的唯一索引是关键它保证同一本书的同一章节不会出现两次即使爬虫重复运行第二次插入阶段也能在数据库层挡住。content用 MEDIUMTEXT 而不是 TEXT是因为一章小说正文少则五六千字多则两三万字TEXT 虽然也能撑住但接近边界时会有换行和排版问题MEDIUMTEXT 最大 16MB留足余量。created_at加上DEFAULT CURRENT_TIMESTAMP这样插入数据时不用在 Python 里手动填时间。2.4 封装 mysql_DBUtils.py 连接池连接池是对外提供统一接口的关键模块。Day04 里 mysql_DBUtils.py 做的事情很简单读取 cnf 配置初始化一个全局 PooledDB 对象再给上层提供查询数据库的方法。一个可用的实现如下import configparser import pymysql from dbutils.pooled_db import PooledDB def get_pool(cnf_pathdbMysqlConfig.cnf): cfg configparser.ConfigParser() # 用 utf-8 读取配置避免中文路径或注释乱码 cfg.read(cnf_path, encodingutf-8) mysql_cfg cfg[mysql] return PooledDB( creatorpymysql, # 驱动必须传 pymysql maxconnectionsint(mysql_cfg.get(maxconnections, 5)), mincachedint(mysql_cfg.get(mincached, 1)), maxcachedint(mysql_cfg.get(maxcached, 3)), blockingmysql_cfg.getboolean(blocking, True), hostmysql_cfg[host], portint(mysql_cfg[port]), usermysql_cfg[user], passwordmysql_cfg[password], databasemysql_cfg[database], charsetmysql_cfg.get(charset, utf8mb4), cursorclasspymysql.cursors.DictCursor ) pool get_pool() def fetch_one(sql, argsNone): conn pool.connection() try: # with 会自动关闭游标但不会返回连接 with conn.cursor() as cur: cur.execute(sql, args) return cur.fetchone() finally: # close 是归还连接池不是真正断开 conn.close()这里的PooledDB参数有几个值得注意。creator必须指定为pymysql如果传成MySQLdb就会在连接时找不到驱动。maxconnections、mincached、maxcached三个参数控制的是连接池的实际分配行为blockingTrue表示当连接全部被占用时新的请求会排队等待而不是直接抛异常对爬虫来说等待通常比报错后重试要更合理。fetch_one的finally里的conn.close()非常关键初学者容易在这里犯错误以为调用 close 会把连接真正关掉于是把资源耗尽实际上 PooledDB 的 close 是把连接归还到池里供下一次pool.connection()复用。后面的插入操作也要遵循同样的模式。如果你打算跑多线程爬虫最好再包一层 ThreadingLocal 或直接用连接池自带的线程安全机制这个后面再说。3. 爬虫主体requests 抓取、BeautifulSoup 解析、分页与异常处理3.1 构造请求头与网页抓取爬虫首先要过反爬这一关。很多小说站对 User-Agent 的判断很直接不带浏览器标识的访问会返回 403。因此在fetch_html里要给 requests 套上 Session并配置重试策略。requests 本身没有对 5xx 状态码做自动重试需要引入 urllib3 的 Retry。常见的做法是import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Referer: https://example.com/ } def fetch_html(url, timeout10, retries3): session requests.Session() # 对 500/502/503 自动重试失败间隔递增 retry Retry(totalretries, backoff_factor0.5, status_forcelist[500, 502, 503]) session.mount(https://, HTTPAdapter(max_retriesretry)) resp session.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() resp.encoding resp.apparent_encoding return resp.text这段代码里Retry(totalretries, backoff_factor0.5)表示最多重试 3 次每次重试的前置等待时间是 0.5 秒、1 秒、2 秒递增status_forcelist指定哪些状态码需要重试这里只处理 5xx因为 4xx 一般是客户端问题重试没有意义。resp.apparent_encoding是根据 HTML 内容自动猜测编码很多老小说站是 GBK 编码直接使用resp.text会把中文显示成乱码这一步能基本解决。还要强调timeout不能省略。不设 timeout 时如果目标服务器不响应单次请求可能挂几分钟甚至更久整个爬虫看起来就像卡死。这里设成 10 秒实际可以根据站点的响应速度调整。3.2 用 BeautifulSoup 定位章节标题和正文拿到 HTML 后解析的核心是精准选区。最常用的函数是find和find_all前者返回第一个匹配节点后者返回列表。章节页中标题通常在h1标签里正文在div或article节点里。可以这样写from bs4 import BeautifulSoup def parse_chapter(html): soup BeautifulSoup(html, lxml) title_node soup.find(h1, class_chapter-title) content_node soup.find(div, idchapter-content) if not title_node or not content_node: return None title title_node.get_text(stripTrue) content_html str(content_node) return title, content_htmlclass_后面的下划线是 Python 的语法要求因为class是关键字。如果目标站点用的不是 id 而是classcontent把参数改成class_content即可。标题节点用get_text(stripTrue)取出纯文本正文节点这里没有直接提取文本而是用str(content_node)保留原始 HTML 片段因为正文里可能有需要剔除的隐藏标签过早提取文本会丢失结构信息交给后面clean_content统一做文本提取和清洗更合适。有一点要注意lxml解析器需要单独安装如果不想装把参数改成html.parser也是合法的但解析速度明显慢。解析时如果节点没找到代码会返回 None上层必须对这种异常情况做判断否则直接把title拿去写入数据库容易出现插入报错。3.3 分页与章节列表遍历小说站的章节入口通常有两种结构。第一种是目录页一次性把所有章节链接放在一个列表里适合顺序遍历第二种是只有上一章和下一章的翻页结构需要顺着 next 链接走到头。Day04 源码里更可能遇到的是第一种这里给出一个目录页解析函数from urllib.parse import urljoin def get_chapter_links(catalog_url): html fetch_html(catalog_url) soup BeautifulSoup(html, lxml) links [] for a in soup.select(div.listmain a[href]): href a[href] if /read/ in href: # 根据实际目录链接格式过滤 title a.get_text(stripTrue) absolute_url urljoin(catalog_url, href) links.append((title, absolute_url)) return linksselect(div.listmain a[href])是 CSS 选择器写法含义是类名为 listmain 的 div 下所有带 href 的 a 标签。过滤条件if /read/ in href是为了剔掉目录页里可能混入的首页、简介等无关链接实际项目要根据站点的 URL 结构改成对应的关键字。urljoin(catalog_url, href)是最容易被忽略的一步因为很多章节链接是相对路径不拼前缀就变成了缺失 host 的无效 URL。如果是翻页结构思路类似只是在循环里每次都找下一章的链接直到没有下一页为止。两种方式对比如下结构类型特征遍历方式目录页列表所有章节链接集中在一个页面一次收集循环处理上一页/下一页每页只有一章和 next 链接while 循环取 next直到为空无论哪种方式都要在循环里控制频率。每抓完一章time.sleep(1)左右既是对目标站点负责也能避免触发反爬。3.4 异常处理与失败重试网络请求不可能百分之百成功。爬虫框架的核心能力之一就是失败重试而不是一遇到异常就退出。这里给出一个带退避的重试包装import time import requests def crawl_with_retry(url, max_retry3): for attempt in range(max_retry): try: html fetch_html(url) parsed parse_chapter(html) if parsed: return parsed print(f[{url}] 解析结果为空准备重试) except (requests.RequestException, AttributeError) as exc: print(f[{url}] 第 {attempt 1} 次失败: {exc}) time.sleep(attempt * 2) return Noneattempt * 2是线性退避第 1 次失败等 0 秒第 2 次等 2 秒第 3 次等 4 秒需要更平滑的重试间隔可以用(attempt 1) ** 2做指数退避。捕获AttributeError是因为parse_chapter中可能抛出属性异常比如某个节点意外为 None。这里应该把网络异常和解析异常分开处理网络异常重试有价值解析异常重试同一段 HTML 通常不会改变结果所以更好的做法是将异常分类在调用前打印 URL 和章节名方便人工排查。爬取任何小说站之前记得看下站点的 robots.txt尊重版权和网站意愿。技术上我们可以爬但内容是否允许使用是另一回事。4. 数据入库mysql_DBUtils 连接池、批量插入与日志记录4.1 用 executemany 批量写入章节把数据写进 MySQL 时一条一条 INSERT 会让爬虫慢一个数量级。更合理的做法是攒够一批再写入pymysql 的executemany正好支持这种批量执行。连接从之前封装的连接池获取用完归还def save_chapters(book_name, chapters, start_order1): sql INSERT INTO chapters (book_name, chapter_title, content, chapter_order, url) VALUES (%s, %s, %s, %s, %s) rows [ (book_name, title, content, order, url) for order, (title, content, url) in enumerate(chapters, startstart_order) ] conn pool.connection() try: with conn.cursor() as cur: # executemany 用元组列表批量绑定参数 cur.executemany(sql, rows) conn.commit() except Exception: conn.rollback() raise finally: conn.close()enumerate的start默认是 1表示第一次抓取时从第 1 章开始断点续爬时把last_order 1传进来作为这一批的起始序号。cur.executemany(sql, rows)内部会把列表里的每个元组当作一条 SQL 的参数避免自己拼 SQL 引起的注入和转义问题。注意rows是一次性构造的列表如果章节数特别多内存里会同时占有一批文本建议每 200 章为一批调用一次save_chapters而不是把整本书都攒进内存。conn.commit()不能省。pymysql 的事务默认是打开的不提交的话数据不会真正落库。异常发生时conn.rollback()保证这一批失败数据不会产生半截记录。最后finally里的conn.close()还是那句话归还连接给连接池。4.2 入库前的文本清洗正文里经常混着大量换行、空格和站方广告如果不过滤数据库里会存入一堆\n\n\n后面做全文检索时非常难用。清洗函数要针对常见的 HTML 文本问题处理统一换行符、压缩空行、去掉多余空格。还要先删掉不需要的标签import re from bs4 import BeautifulSoup def clean_content(html): soup BeautifulSoup(html, lxml) # 广告、脚本、样式不进入正文 for tag in soup([script, style, ins]): tag.decompose() text soup.get_text(\n, stripTrue) text re.sub(r\r\n?, \n, text) text re.sub(r\n{3,}, \n\n, text) text re.sub(r[ \t]{2,}, , text) return text.strip()decompose()会把节点从树中彻底移除这样后续的get_text不会提取到 script 或 style 里的内容。ins标签是很多小说站插广告的位置也一并删掉。正则里\r\n?匹配回车符和紧随其后的换行统一替换成单个\n\n{3,}是处理连续三个以上空行正文排版通常会保留一个空行作为分段所以替换成两个换行。若发现章节长度超过 50000 字符可以截断并记录 warning防止极端数据撑爆字段。注意如果正文里包含br标签get_text(\n)会在标签边界插入换行符但某些写法不严格建议清洗前先把br替换成\n再走统一的正则流程。4.3 日志记录让爬虫不再黑盒爬虫运行时间很长中途退出后只靠 print 的输出无法定位问题。logging 模块把日志写到文件和控制台方便事后复盘。一个最小配置是import logging logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(spider.log, encodingutf-8), logging.StreamHandler() ] ) logger logging.getLogger(novel_spider) logger.info(爬虫启动)FileHandler负责把日志写入 spider.logStreamHandler输出到控制台。两者可以共存避免了只能看文件或只能看终端的尴尬。日志级别建议设置为 INFO这样每完成一章可以记录一次不至于像 DEBUG 那样刷屏。日志的作用是抓错失败时的上下文最终要记录成功章节数、失败 URL、耗时、当前连接池使用情况。用分隔符记录失败链接后续恢复时直接读取这个文件重新执行比在日志里手工翻要高效得多。下面给出日志级别说明表格级别场景示例DEBUG调试期打印每章 URLINFO正常进度已抓取第 N 章WARNING可恢复问题重试某 URLERROR单条失败章节解析为空CRITICAL必须退出数据库连接失败4.4 去重与断点续爬前面表结构里设计了uk_book_order唯一索引它在去重上会有直接帮助。爬虫中断后重新启动不需要从头跑一遍只需要查出这本书已抓取到哪一章然后从下一个章节序号开始。查询已有进度的函数可以这样写def get_last_order(book_name): sql SELECT COALESCE(MAX(chapter_order), 0) AS last_order FROM chapters WHERE book_name%s row fetch_one(sql, (book_name,)) return row[last_order]如果插入时遇到重复章节pymysql 会抛IntegrityError处理方式不是让程序崩掉而是记录后继续from pymysql.err import IntegrityError try: save_chapters(book_name, batch, start_orderstart) except IntegrityError: logger.warning(第 %d 章之后存在重复跳过该批次, start)这里conn.rollback()在连接池封装内部已经调用外层再调用一次也没有副作用。断点续爬的完整逻辑是先查last_order遍历目录链接时跳过序号小于等于last_order的章节再以固定批次大小提交。这样即使爬虫因为断电、断网、数据库重启而中断重启后也能接着上次的进度继续。5. 实战调试从 book_db.py 看一个完整脚本的运行顺序与常见坑Day04 中的 book_db.py 可以把前面的模块串起来。主流程顺序是初始化连接池 → 拉取目录页 → 遍历章节链接 → 抓取正文 → 清洗 → 批量写入 → 更新日志。简化的运行框架如下def run(book_name, catalog_url): logger.info(开始爬取 %s, book_name) last_order get_last_order(book_name) links get_chapter_links(catalog_url) for start in range(last_order 1, len(links) 1, 100): batch [] for order in range(start, min(start 100, len(links) 1)): title, url links[order - 1] result crawl_with_retry(url) if not result: logger.error(章节 %s 抓取失败, title) continue batch.append((title, clean_content(result[1]), url)) if batch: save_chapters(book_name, batch, start_orderstart) logger.info(已写入第 %d 至 %d 章, start, start len(batch) - 1) logger.info(爬取完成)运行完成后用命令行验证数据库里的数据量mysql -u root -p -e SELECT book_name, COUNT(*), MIN(chapter_order), MAX(chapter_order) FROM novel_db.chapters GROUP BY book_name;如果章节数和预期一致说明流程完整。常遇到的问题列表如下值得在一开始就做好规避现象原因解决Too many connections连接未归还或池太小检查conn.close()调大 maxconnectionsIncorrect string value表或连接字符集不是 utf8mb4建表改 charset连接参数改 utf8mb4爬着爬着一直 503请求频率太高或 UA 被识别加 sleep换干净 UA降低并发中文乱码没有设置 resp.encoding用 apparent_encoding 或从 meta 取 charset章节标题能抓到正文为空正文节点选择器不对用浏览器开发者工具重新定位 id/class上面最后一个坑最容易踩。很多小说站的正文容器不是固定的chapter-content可能是content、read-content或article。抓陌生站点时先用浏览器开发者工具查看章节正文容器的真实 id 和 class再把这个字符串写进parse_chapter这一行改动比换框架和调并发都更有效。断点续爬时也要注意chapter_order的连续性如果中途跳过失败章节序号会有空洞后续想按顺序展示正文时会需要额外的排序条件。检查完数据后把配置文件权限收紧防止数据库密码被同目录其他脚本读到。一切正常后再考虑用 cron 或 schedule 定时爬取新章节定时任务重复执行时唯一索引uk_book_order会继续帮你挡住重复写入这比在代码里反复select判断更省心。本文还有配套的精品资源点击获取