Python爬虫+MySQL数据存储:从CSV到数据库的完整实践方案
2026/9/20 12:13:35 网站建设 项目流程

简介:面向需要从网页抓取数据并写入 MySQL 的开发者,这份资料包整合了 Python 连接与操作 MySQL 的完整示例,覆盖 pymysql 安装配置、连接池、参数化查询、批量插入及 SQLite 工具类,同时演示如何将爬虫解析结果结构化落库,并兼顾安全与性能优化。包内共 17 个文件,以 Python 脚本、Markdown 说明文档为主,辅以 systemd 服务文件、Dockerfile、日志、SQLite 数据库、JSON 配置与 License,整体仅 76KB,结构清晰便于按模块调用;Python 脚本中保留了可直接修改复用的封装函数,适合在本地环境快速跑通。另外,资源中附带 luck-prometheus-exporter-mysql-develop 相关实现,可用于采集 MySQL 查询速率、内存使用等性能指标,帮助读者在实际项目中完成监控与调优。已有 306 人学习,适合熟悉 Python 基础、正在搭建数据采集或数据库写入管线的初中级工程师参考。 做了几年的爬虫和数据采集,我最深的体会是:爬虫本身并不难,难的是把抓下来的数据整理好、存好、用起来。早期我图省事,抓下来的数据直接存CSV文件,等数据量到了几万条,光是去重、筛选、更新就让人头大,更别说后续还要做关联查询和统计。后来老老实实把MySQL接进来,数据管道才算真正跑通。这篇博客就把一个完整的“爬虫技术 + MySQL存储”方案拆开讲,适合已经会用Python写简单爬虫、但还没系统做过数据落库的读者,也适合那些把数据存Excel存到想哭、准备换数据库的朋友。

我用的方案很朴素:Python + requests + BeautifulSoup 抓取解析网页,pymysql 负责和 MySQL 8.0 交互。整体链路是“网页请求 → HTML解析 → 数据清洗 → 批量入库”,每一环都有值得注意的细节。下面按实际开发顺序展开,尽量把每一步的“为什么这么做”说清楚。

1. 整体思路与方案选型:为什么是“爬虫 + MySQL”而不是“爬虫 + CSV”

1.1 从CSV到MySQL:爬虫项目接数据库到底解决了什么

很多刚接触爬虫的人习惯把结果写成CSV,因为代码最少、肉眼可见、Excel直接打开。但数据量一上来,CSV的问题就非常明显:去重得自己写逻辑,更新一条记录要遍历整个文件,并发写入直接乱套,更没有事务和索引的概念。说白了,CSV适合“一次性采集、人工查看”,不适合“持续采集、反复查询、增量更新”的场景。

而MySQL恰好把这几件事都做掉了:唯一索引帮你挡重复数据,INSERT ... ON DUPLICATE KEY UPDATE可以实现增量更新,WHERE条件加索引后查询毫秒级返回,事务机制保证批量写入不会写一半就崩。爬虫项目一旦有“长期维护、定期抓取、数据要给别人用”的苗头,就应该在第一天就接上数据库,而不是等CSV爆炸了再迁移。

1.2 技术栈选型与取舍

先说我最终用的组合:

  • Python 3.10:生态最全,写爬虫和数据处理都顺手。
  • requests + BeautifulSoup4:requests做HTTP请求,BeautifulSoup解析HTML,够用且容易调试。
  • pymysql:纯Python实现的MySQL客户端,pip装完直接用,不需要编译。
  • MySQL 8.0:稳定版,支持utf8mb4字符集、窗口函数、CTE,对爬虫数据的存储和后续分析都够用。

有朋友会问为什么不用Scrapy。Scrapy确实是重型爬虫框架,自带调度器、中间件、Item Pipeline,功能很强,但它的学习曲线和项目结构对一个中小型采集任务来说偏重。我的原则是:能用脚本解决的问题,不急着上框架。当你发现需要分布式采集、需要爬虫管理界面、需要和调度系统集成时,再迁移到Scrapy也不迟,数据管道本身是通用的。

数据库驱动方面,pymysql、mysql-connector-python、SQLAlchemy都试过。SQLAlchemy是ORM,写起来优雅,但多了一层抽象,调试批量插入时反而不直观;mysql-connector-python是官方驱动,性能不错,但遇到MySQL 8.0的caching_sha2_password认证插件时,老版本驱动会报错。pymysql兼容性最稳,而且API简单,适合直接在代码里控制SQL,所以我最终选了它。

1.3 数据链路的整体设计

整个采集任务我拆成了四个阶段,每个阶段只负责一件事:

  1. 请求阶段:构造HTTP请求,带上合理的headers,拿到HTML响应。
  2. 解析阶段:用BeautifulSoup定位目标数据所在的DOM节点,抽取字段。
  3. 清洗阶段:把字符串去空格、转换日期格式、处理缺失值,保证入库前数据类型一致。
  4. 入库阶段:拼接SQL(用参数化查询,不是字符串拼接),批量写入MySQL。

这个分层的意义在于:每一层出问题都能单独定位。比如抓到的HTML是乱码,问题在请求阶段的编码处理;解析出来是空列表,问题在解析阶段的选择器;入库报字段超长,那就是清洗阶段没做长度校验。各层职责清晰,调试起来非常快。

2. 环境准备与MySQL基础:先把“数据仓库”搭结实

2.1 Python环境与依赖安装

我用虚拟环境管理项目依赖,避免污染全局Python。命令很简单:

python3 -m venv venv source venv/bin/activate # Windows下是 venv\Scripts\activate pip install requests beautifulsoup4 pymysql

三个库各司其职:requests负责网络请求,beautifulsoup4负责HTML解析,pymysql负责数据库交互。如果后续要处理更复杂的反爬,可能还会加lxml(解析速度更快)和fake-useragent(随机UA),但初期这三个就够了。

2.2 MySQL 8.0安装要点:本地装还是Docker装

MySQL 8.0的安装是网上教程最多的部分之一,我自己踩过不少坑,这里只讲关键点。本地安装的话,官网下载MySQL Community Server安装包,一路Next即可,但有几个地方必须注意:

  • 选Server Only,别装那些用不上的组件。
  • 设置root密码时,认证方式选“Use Strong Password Encryption”,对应caching_sha2_password插件。
  • 安装完成后,MySQL服务默认开机自启,可以通过mysql -u root -p验证能否登录。

如果不想污染本机环境,Docker一条命令搞定,这是我最推荐的方式:

docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=spider_data \ mysql:8.0

这里给root用户设了密码root123,创建了名为spider_data的数据库。-p 3306:3306把容器的3306端口映射到宿主机,这样宿主机上的Python代码直接连localhost:3306就能访问到容器里的MySQL。

注意:Docker方式如果容器删了数据就没了,生产环境一定要挂载数据卷,比如加-v /my/own/datadir:/var/lib/mysql,把MySQL的数据文件持久化到宿主机。

2.3 库表设计与建表SQL:字符集和索引是重中之重

爬虫数据最大的特点是“不可控”:来源网站可能用各种奇怪的编码,字段长度可能超预期,同一个字段在不同页面可能格式不一致。所以建表时必须把所有隐患提前堵住。

第一,数据库和表必须用utf8mb4字符集。utf8mb4是utf8的超集,能存emoji和生僻字,而MySQL里的utf8实际上是utf8mb3,遇到4字节字符会报错。建库建表时显式指定:

CREATE DATABASE IF NOT EXISTS spider_data DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE spider_data; CREATE TABLE IF NOT EXISTS books ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, isbn VARCHAR(20) NOT NULL, title VARCHAR(200) NOT NULL, author VARCHAR(100) NOT NULL, price DECIMAL(10, 2) NOT NULL DEFAULT 0.00, detail_url VARCHAR(500) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_isbn (isbn), KEY idx_title (title) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

这张表有几个设计细节值得说:

  • isbn加了唯一索引,这是数据去重的第一道防线。爬虫重复抓取同一本书时,INSERT会因唯一键冲突被数据库挡住。
  • price用DECIMAL(10,2)而不是FLOAT,避免浮点数精度误差。网页上抓下来的价格是字符串,入库前要先转成Decimal。
  • detail_url长度给到500,因为生产环境里有些网站的URL很长,默认的255容易被截断。
  • created_atupdated_at用TIMESTAMP类型,自动维护创建和更新时间,省去在Python里手动写时间戳。

3. 爬虫抓取与数据清洗:入库之前的所有处理

3.1 requests请求的细节:别让你的爬虫一眼被看穿

requests写起来很简单,但直接裸请求很容易被网站拦截。我总结了几个必须注意的细节:

首先是请求头。浏览器的请求头里会有User-Agent、Accept、Accept-Language、Referer等字段,其中User-Agent最重要。伪造一个常见的浏览器UA:

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", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", }

其次是超时和重试。网络请求永远是爬虫项目里最不可控的环节,不设置timeout,requests会一直等下去,整个程序就卡死了。我的做法是设10秒超时,捕获requests.exceptions.RequestException,连续失败3次就跳过当前页面:

try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() except requests.exceptions.RequestException as e: print(f"请求失败: {url}, 错误: {e}") continue

最后是请求频率。控制请求间隔不仅是为了避免被封IP,也是基本的网络礼仪。我的经验是动态间隔,比如在2到5秒之间随机取一个值:

import time import random time.sleep(random.uniform(2, 5))

3.2 解析HTML与字段抽取:选择器怎么写最稳

解析HTML我优先用BeautifulSoup加lxml解析器。lxml解析速度快,对格式不规范的HTML容错能力也强。代码结构如下:

from bs4 import BeautifulSoup soup = BeautifulSoup(resp.text, "lxml") items = soup.select("div.book-item") for item in items: title = item.select_one("h2.book-title a") author = item.select_one("span.author") price = item.select_one("span.price") detail_url = item.select_one("h2.book-title a") data = { "title": title.text.strip() if title else "", "author": author.text.strip() if author else "", "price_str": price.text.strip() if price else "0", "detail_url": detail_url["href"] if detail_url else "", } # 后续清洗和入库

这里有个实操经验:用select_one时先判断是否为None再取.text或属性值,因为目标节点一旦不存在,直接访问属性就会抛AttributeError。用三元表达式写成一行,既简洁又安全。

3.3 数据清洗的常规操作:乱数据不进库

网页上抓下来的数据几乎是“脏”的,直接入库不仅浪费存储空间,还会让后续查询结果不可信。我通常做四件事:

  1. 去空白:用.strip()去掉字符串首尾空格,并把内部的连续空白替换成单个空格。
  2. 类型转换:价格字段一般是"¥59.00"这种带符号的字符串,用正则提取数字再转成Decimal:
import re from decimal import Decimal price_str = "¥59.00" price = Decimal(re.sub(r"[^\d.]", "", price_str))
  1. 日期标准化:不同网站日期格式五花八门,统一转成YYYY-MM-DD
from datetime import datetime date_str = "2024年12月18日" dt = datetime.strptime(date_str, "%Y年%m月%d日") formatted = dt.strftime("%Y-%m-%d")
  1. 字段长度校验:数据库里title字段是VARCHAR(200),如果抓到一个500字的标题,直接入库会报Data too long。入库前做个len()检查,超长就截断或丢弃,我自己习惯截断并记录日志,方便后续排查。

4. 数据入库:从Python到MySQL的最后一公里

4.1 连接MySQL的正确姿势

pymysql连接MySQL 8.0,必须注意charset参数。不写charset的话,默认是latin1,中文入库就是乱码:

import pymysql conn = pymysql.connect( host="localhost", port=3306, user="root", password="root123", database="spider_data", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, )

cursorclass设为DictCursor后,查询结果返回的是字典,字段名可以直接当key用,比默认的元组可读性强很多。

4.2 批量插入:executemany比逐条insert快得多

爬虫抓到几百条数据后,如果逐条执行INSERT,每条都要走一次网络往返,性能极差。实测下来,用executemany批量插入1000条数据,比逐条插入快3到5倍。写法也很简单:

data_list = [ ("9787115428028", "Python编程:从入门到实践", "埃里克·马瑟斯", Decimal("89.00"), "http://example.com/book/1"), # ... 更多数据 ] sql = """ INSERT INTO books (isbn, title, author, price, detail_url) VALUES (%s, %s, %s, %s, %s) """ with conn.cursor() as cursor: cursor.executemany(sql, data_list) conn.commit()

注意两点:第一,SQL里用%s占位符,参数通过第二个参数传进去,这是参数化查询,能有效防止SQL注入;第二,executemany之后必须调用conn.commit(),否则事务没提交,数据不会真正写入。

4.3 去重与增量更新:唯一索引加ON DUPLICATE KEY UPDATE

同一批数据可能会被爬虫反复抓到,如果每次都是直接INSERT,表里全是重复数据。我的方案是依赖之前建表时设的唯一索引,配合INSERT ... ON DUPLICATE KEY UPDATE实现“有则更新,无则插入”:

sql = """ INSERT INTO books (isbn, title, author, price, detail_url) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title = VALUES(title), author = VALUES(author), price = VALUES(price), detail_url = VALUES(detail_url) """

这样同一本ISBN的书被抓到第二次时,不会新增记录,而是把价格、标题等信息更新到最新。这个特性在爬取价格、库存这类经常变化的字段时特别有用。

4.4 连接池与断线重连:爬虫跑几天不挂的秘诀

爬虫任务经常是长跑型,脚本可能连续跑几个小时。MySQL默认的wait_timeout是8小时,连接超过8小时没活动就会被服务端断开。等脚本再次执行INSERT时,就会抛出“MySQL server has gone away”。

解决思路有两个:一是每次批量插入前检测连接是否可用,不可用就重连;二是用连接池。我的做法是写一个简单的重连包装:

def get_connection(): return pymysql.connect( host="localhost", port=3306, user="root", password="root123", database="spider_data", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) try: with conn.cursor() as cursor: cursor.executemany(sql, data_list) conn.commit() except pymysql.err.OperationalError as e: if "MySQL server has gone away" in str(e): conn = get_connection() with conn.cursor() as cursor: cursor.executemany(sql, data_list) conn.commit()

在for循环的每一批处理前,也可以用conn.ping(reconnect=True)自动重连,这个方法更省事,原理是如果连接断开就重新建立。

5. 实战案例:抓取图书信息存入MySQL并验证数据

5.1 完整代码实现

下面的代码是一个最小可运行的完整案例,抓取一个示例网站的图书列表,清洗后批量写入MySQL。我把前面讲的所有关键点都浓缩进来:

import random import re import time from decimal import Decimal import pymysql import requests from bs4 import BeautifulSoup 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", } def get_connection(): return pymysql.connect( host="localhost", port=3306, user="root", password="root123", database="spider_data", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) def fetch_and_parse(url): resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "lxml") items = soup.select("div.book-item") results = [] for item in items: title_node = item.select_one("h2.book-title a") author_node = item.select_one("span.author") price_node = item.select_one("span.price") if not title_node or not author_node or not price_node: continue price_match = re.search(r"[\d.]+", price_node.text.strip()) results.append({ "isbn": re.sub(r"\D", "", title_node["href"]), "title": title_node.text.strip(), "author": author_node.text.strip(), "price": Decimal(price_match.group()) if price_match else Decimal("0"), "detail_url": title_node["href"], }) return results def save_to_mysql(conn, data_list): if not data_list: return 0 sql = """ INSERT INTO books (isbn, title, author, price, detail_url) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title = VALUES(title), author = VALUES(author), price = VALUES(price), detail_url = VALUES(detail_url) """ rows = [(d["isbn"], d["title"], d["author"], d["price"], d["detail_url"]) for d in data_list] with conn.cursor() as cursor: cursor.executemany(sql, rows) conn.commit() return len(data_list) if __name__ == "__main__": conn = get_connection() total = 0 for page in range(1, 6): url = f"http://example.com/books?page={page}" try: data = fetch_and_parse(url) count = save_to_mysql(conn, data) total += count print(f"第{page}页入库{count}条,累计{total}条") except Exception as e: print(f"第{page}页失败: {e}") time.sleep(random.uniform(2, 5)) conn.close()

5.2 运行结果示例与验证

跑完脚本后,登录MySQL验证数据。命令行输入:

mysql -u root -p spider_data

然后执行:

SELECT isbn, title, author, price, created_at FROM books LIMIT 10;

正常会看到类似下面的输出:

+---------------+--------------------------------------+----------------+-------+---------------------+ | isbn | title | author | price | created_at | +---------------+--------------------------------------+----------------+-------+---------------------+ | 9787115428028 | Python编程:从入门到实践 | 埃里克·马瑟斯 | 89.00 | 2025-01-12 10:23:45 | | 9787111213826 | 利用Python进行数据分析 | 韦斯·麦金尼 | 79.00 | 2025-01-12 10:23:45 | +---------------+--------------------------------------+----------------+-------+---------------------+

再验证去重效果,把同一个页面重新抓一遍,然后统计总行数,会发现行数没变,但updated_at时间更新了。这证明唯一索引和ON DUPLICATE KEY UPDATE机制生效了。

6. 常见问题与排查技巧实录

6.1 error 2002 (HY000):连不上本地MySQL怎么办

这个报错在MySQL使用中出现的频率最高,完整提示一般是Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)。原因有两个大类:MySQL服务没启动,或者客户端默认用了socket协议去连。

排查步骤是按照下面的顺序来的:

  1. 检查服务状态。Linux下执行systemctl status mysql,如果显示inactive就启动:systemctl start mysql
  2. 检查端口监听。执行netstat -tlnp | grep 3306,确认3306端口在监听。如果没有输出,说明mysqld没起来。
  3. Python连接时如果报这个错,很多时候是host写成了localhost。localhost在Unix系统里默认走socket而不是TCP,改成host="127.0.0.1"强制走TCP就能解决。

我自己常用Docker部署MySQL,容器里的socket路径和宿主机不一样,所以连接时一律用127.0.0.1加端口,可以绕开socket相关的所有问题。

6.2 中文乱码:四个地方必须统一

爬虫一遇到中文乱码,先别急着改代码,按“响应编码 → 数据库字符集 → 表字符集 → 连接字符集”逐层排查。页面响应编码可以在requests里通过resp.encoding判断,如果发现是gbk或gb2312,就手动重置:

resp.encoding = resp.apparent_encoding

数据库和表的字符集可以通过SQL查询确认:

SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = 'spider_data';

最关键的是连接字符串里的charset必须写utf8mb4。早期我漏掉这个参数,解析出来的中文在Python里正常,一入库就变成问号,排查了半小时才发现是连接层字符集没指定。

6.3 插入性能太慢:先检查事务和批量大小

如果1000条数据插入要好几秒,首先看是不是在循环里反复execute然后又commit。事务要尽量大,批次尽量批量,我习惯每500到1000条提交一次,既不会让事务过大,又能充分利用MySQL的批处理能力。

其次检查表上索引是否过多。索引不是越多越好,特别是唯一索引和普通索引叠加后,每次INSERT都要更新所有索引,会拖慢写入速度。只给真正需要查询和去重的字段加索引,其他字段保持普通列就好。

6.4 锁表问题:长事务是元凶

爬虫脚本在写入时,如果开启了事务但没及时commit,或者中途抛异常没回滚,会一直持有行锁甚至表锁,导致其他查询全部阻塞。排查方法是用下面这个SQL查看当前有哪些事务在跑:

SELECT * FROM information_schema.INNODB_TRX\G

找到长时间未提交的事务,用KILL <trx_mysql_thread_id>把它干掉。平时写代码时,务必把commit放在finally里,或者用上下文管理器确保事务一定能关闭。

6.5 “数据抓下来但库里没有”:先看commit再查异常

新手最容易遇到的问题:脚本运行完没报错,但数据库里一条数据都没有。这种90%是忘了commit。pymysql默认autocommit是False,所有的INSERT、UPDATE都要显式调用conn.commit()才会真正落盘。我现在的习惯是封装一个save函数,commit放在批量写入之后,并且用try/except包住,任何异常都要打印堆栈,避免“看似成功实则失败”的假象。


爬虫接MySQL这套组合,我实际用了两年多,踩过上面这些坑之后,最大的收获是:数据管道越早设计好,后面越省心。抓数据只是第一步,让数据变得可靠、可查、可更新,才是爬虫项目真正产生价值的地方。最后再分享一个实用小技巧:每次入库后,在程序里顺手统计一下本次新增行数和更新行数,打印到日志里,时间长了你就知道哪些网站的数据在持续变化,哪些已经不再更新,对调度策略的调整非常有帮助。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询