
简介这是一份Python3网络爬虫实操示例资源演示如何通过requests获取网页、用正则表达式提取数据并将结果写入MySQL数据库适合有一定Python基础、希望快速掌握数据采集入库流程的开发者参考。资源包仅含1个PDF文档大小214KB便于在电脑或手机上随时查阅。目前已有10497人学习是爬虫与MySQL结合场景中较受欢迎的样例。文档以电商平台订单接口的真实抓取为背景不仅给出完整可运行的爬虫代码还介绍了代理IP配置、分页遍历、异常重试、请求头分析等关键处理方式同时讲解如何用正则匹配订单编号、区服、价格、时间等多类字段并利用pymysql执行建表、唯一索引、插入与事务提交等操作避免重复入库。文中还提到使用HttpAnalyzer等抓包工具辅助定位接口与排查问题可帮助读者建立从URL请求、HTML解析到数据落库的整体思路。1. Python3 爬虫 MySQL先弄清数据从网页到库表要过几道关从“Python3 爬虫脚本能抓到页面”到“爬虫数据真正落进 MySQL”中间隔着一道很多人低估的坎数据库侧的表结构、字符集、连接方式、事务边界每一个环节都可能让爬虫白跑一整夜。下面这套流程是采集链路的完整骨架——requests 负责取 HTMLBeautifulSoup 负责解析字段PyMySQL 负责写库数据量在每天几万行以内的场景足够用再往上扩展 Scrapy、消息队列或分布式爬虫数据库侧的很多设计依然可以直接复用。相比抓取MySQL 侧才是排错的密集区字段类型对不上、URL 重复入库、中文乱码、连接没关闭都是真实的在线事故。所以这里会把重心放在入库前后而不是一味堆解析代码。2. MySQL 侧的准备字符集、授权账号与可防重复的表结构2.1 MySQL 8 安装后的两个必改项utf8mb4 与认证插件本机 MySQL 的安装方式并不缺教程Windows 下走 mysql-installer-communityLinux 上可以解压官方 tar 包初始化也可以用 Docker 快速起一个实例。安装完成后有两件事要先确认否则后面 Python3 连库大概率会出问题。第一是服务端字符集第二是连接认证插件。先检查字符集SHOW VARIABLES LIKE character_set_server; SHOW VARIABLES LIKE collation_server;预期结果是character_set_server为utf8mb4collation_server为utf8mb4_unicode_ci或utf8mb4_0900_ai_ci。MySQL 8 默认已经是 utf8mb4如果你用的是 5.7 或从旧配置迁移过来经常会是 latin1。修改方式是在 my.cnf 或 my.ini 的[mysqld]段写入[mysqld] character-set-server utf8mb4 collation-server utf8mb4_unicode_ci注意utf8mb3和utf8mb4在 Python3 驱动里的表现差别很大。Python 侧的charsetutf8通常会被 PyMySQL 映射到 utf8mb4但如果在连接串里显式写utf8mb3遇到 emoji 或生僻字就会报Incorrect string value。统一走 utf8mb4 是爬虫数据最稳的选择。第二个必改项是认证插件。PyMySQL 在较新版本已支持 MySQL 8 默认的caching_sha2_password但如果你的 pymysql 版本偏旧连接时会报Authentication plugin caching_sha2_password cannot be loaded。我的处理顺序是先执行pip install -U pymysql升级驱动再考虑改账号认证方式。反过来先改认证只是给自己留了一个旧配置的坑。检查项命令期望结果服务端字符集SHOW VARIABLES LIKE character_set_server;utf8mb4排序规则SHOW VARIABLES LIKE collation_server;utf8mb4_unicode_ci 或 utf8mb4_0900_ai_ciPython 驱动版本python3 -c import pymysql; print(pymysql.__version__)1.0 以上2.2 为爬虫设计表结构唯一索引是“增量抓取”的地基爬虫数据和业务数据有个显著区别同一 URL 可能被反复抓到。如果表结构上没有唯一约束第一次爬完入库第二天再跑一遍就会插进重复行。所以在建表时要把业务天然键——这里就是 URL——做成唯一索引。下面是文章类爬虫常用的建表语句CREATE DATABASE spider_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE spider_db; CREATE TABLE news_article ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, url VARCHAR(512) NOT NULL COMMENT 原文链接作为业务唯一键, title VARCHAR(512) NOT NULL, author VARCHAR(64) DEFAULT NULL, publish_time DATETIME DEFAULT NULL, summary TEXT, content MEDIUMTEXT, source VARCHAR(64) NOT NULL DEFAULT manual, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_url (url(255)), KEY idx_publish_time (publish_time), KEY idx_source (source) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文章类爬虫数据表;几个参数说明url(255)是前缀索引。MySQL 5.7 及更早版本 InnoDB 索引长度上限为 767 字节utf8mb4 一个字符占 4 字节255 字符刚好压线。如果只跑 MySQL 8.0url全字段建唯一索引也能通过512×42048 字节小于 3072 字节但为了兼容旧库迁移前缀写法更稳。publish_time单独建索引因为增量抓取最常见的查询是“今天发布了哪些文章”时间字段参与WHERE和ORDER BY没有索引会走全表扫描。created_at和updated_at用数据库默认值维护Python 侧不用传。ON UPDATE CURRENT_TIMESTAMP会在行数据被更新时自动改写时间这个特性配合后面要讲的ON DUPLICATE KEY UPDATE非常顺手。content用MEDIUMTEXT上限 16MB普通文章足够。如果抓取内容是纯文本TEXT也可不要为了“省空间”把大字段塞进 VARCHAR。2.3 单独建一个账号跑爬虫权限最小化爬虫脚本里必须保存数据库密码。如果直接用 root 的密码写在 Python 文件里一旦脚本被分享出去或者仓库泄露等于把整个数据库交出去了。我会为每个爬虫项目单独建一个账号并只授 DML 权限CREATE USER spider127.0.0.1 IDENTIFIED BY spider123; GRANT SELECT, INSERT, UPDATE, DELETE ON spider_db.* TO spider127.0.0.1; FLUSH PRIVILEGES;spider账号只对spider_db库拥有增删改查权限没有CREATE、ALTER、DROP。这对爬虫入库已经足够而且即使脚本存在 SQL 注入风险攻击者也无法删表或改结构。host 限定为127.0.0.1等同于只允许本机访问MySQL 服务不会对局域网开放。后面 Python3 连接时使用的就是这组账号密码。注意如果使用 MySQL 8 较新版本默认可能是caching_sha2_password插件此时应确保 pymysql 已升级到支持该插件的版本而不是急于改回旧的mysql_native_password。3. Python3 抓取与解析requests 拿 HTMLBeautifulSoup 提字段3.1 用 requests 拉页面要先解决编码、UA 与超时抓取这一步看似简单但编码和 UA 处理不好后面的入库全是脏数据。下面是一个最小可用的抓取函数import requests 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 fetch_html(url, timeout10): resp requests.get(url, headersHEADERS, timeouttimeout) resp.raise_for_status() if resp.encoding is None or resp.encoding.lower() iso-8859-1: resp.encoding resp.apparent_encoding return resp.text这里的逻辑说明User-Agent是爬虫最容易忽略的头字段很多服务端会直接拒绝无 UA 请求timeout必须显式设置否则 requests 默认会一直等下去单页卡住整个任务就挂了raise_for_status()会在 4xx、5xx 时抛异常避免把错误页当正常内容解析最后的编码判断是个常见坑——requests 从响应头取不到 charset 时会默认给ISO-8859-1而很多中文站的 HTML 实际是 utf8 或 gbk不纠正就会乱码。用apparent_encoding让 requests 从页面内容里猜编码能覆盖大部分场景。如果想在本地完整跑通流程有一个零风险的演示环境自己造一个静态页面再用 Python3 自带的 HTTP 服务发布mkdir -p pages cat pages/list.html EOF htmlbody ul classnews-list lia classtitle hrefdetail/1.htmlPython3爬虫基础教程/aspan classdate2025-01-09/span/li lia classtitle hrefdetail/2.htmlMySQL索引优化实践/aspan classdate2025-01-08/span/li /ul /body/html EOF python3 -m http.server 8000 --directory pages后面所有抓取和入库演示都针对http://localhost:8000/list.html不需要访问外部站点。3.2 用 CSS 选择器从 HTML 里稳定提取字段HTML 拿到手后接解析。BeautifulSoup 配合lxml解析器是 Python3 爬虫新手到中级最顺手的组合。解析函数这样写from bs4 import BeautifulSoup from urllib.parse import urljoin BASE_URL http://localhost:8000 def parse_news_list(html): soup BeautifulSoup(html, lxml) items [] for li in soup.select(ul.news-list li): a li.select_one(a.title) date_el li.select_one(span.date) if not a or not date_el: continue href a.get(href) items.append({ title: a.get_text(stripTrue), url: urljoin(BASE_URL, href), published: date_el.get_text(stripTrue), }) return items说明几个关键点ul.news-list li是子选择器只取直接子节点不会误中嵌套列表。select_one返回第一个匹配节点。如果页面里后续出现了多个a.title这里会静默取第一个这可能造成字段错位。因此我通常先人工看一眼目标页面的结构再写选择器。get_text(stripTrue)会把\n、空格等多余空白去掉比text属性更干净。href往往写的是相对路径必须用urljoin拼全站点的绝对地址否则入库的 URL 无法回源。如果页面不是纯静态 HTML而是由 JavaScript 动态渲染requests 拿到的源码里根本不会有这些节点。此时再切到 playwright 这类真实浏览器驱动方案。日常大多数列表页、详情页仍以静态输出为主requests 这条链路先用好。3.3 把页面文本清洗成数据库可用的类型从页面提取的字段全是字符串而数据库字段有DATETIME、INT、DECIMAL等类型。入库前不做类型转换MySQL 要么告警要么存入错误的值。常见的字段清洗映射如下页面里的原始值目标字段类型处理方式2025-01-09DATETIMEdatetime.strptime(s, %Y-%m-%d)2025-01-09T08:30:0008:00DATETIMEdatetime.fromisoformat后去掉时区1,299 元DECIMAL去逗号和货币符号再转 float09:00 开售VARCHAR保留原串不做数值化日期是重灾区。有的站给2025-01-09有的给2025/01/09 08:30还有给时间戳的。一个健壮一点的解析函数from datetime import datetime def parse_date(value): if not value or not str(value).strip(): return None value str(value).strip() if value.isdigit(): return datetime.fromtimestamp(int(value)) for fmt in (%Y-%m-%d, %Y-%m-%d %H:%M, %Y/%m/%d, %Y年%m月%d日): try: return datetime.strptime(value, fmt) except ValueError: continue return Noneparse_date的返回值要么是datetime对象要么是None。数据库表的publish_time字段允许 NULL所以解析失败时存 NULL 比存一个错误的默认值更诚实。PyMySQL 在传参时能正确序列化datetime对象到 DATETIME 列如果你把原始字符串塞进 DATETIME 字段MySQL 的严格模式会直接报Incorrect datetime value非严格模式则写入全零值两者都会污染数据。4. Python3 写入 MySQL从单条插入到批量 upsert4.1 PyMySQL 单条插入参数化 SQL 与事务边界先写最直白的单条插入把连接和事务的完整生命周期打出来import pymysql conn pymysql.connect( host127.0.0.1, port3306, userspider, passwordspider123, databasespider_db, charsetutf8mb4, ) item { title: Python3爬虫基础教程, url: http://localhost:8000/detail/1.html, author: None, publish_time: parse_date(2025-01-09), summary: 爬虫入门示例, } try: with conn.cursor() as cursor: cursor.execute( INSERT INTO news_article (url, title, author, publish_time, summary, source) VALUES (%s, %s, %s, %s, %s, %s), (item[url], item[title], item[author], item[publish_time], item[summary], localhost_demo) ) conn.commit() except Exception: conn.rollback() raise finally: conn.close()逻辑说明%s占位符是 PyMySQL 的参数化写法值由第二个参数传入驱动会负责转义。不要在 SQL 里用 f-string 或 format 拼值——第一是防注入第二是字符串里的引号容易把 SQL 搞坏。commit放在with conn.cursor()结束后执行确保游标操作完成后一次性提交事务rollback只在异常时触发。finally里的conn.close()是很多人会漏掉的一步写爬虫长任务时不关连接最后 MySQL 会报Too many connections。4.2 executemany 批量写入批量大小与 max_allowed_packet单条 execute 循环几千次每次都要走一次网络往返效率很低。用executemany可以把多条记录合并成一条多值 INSERT 发送SQL_INSERT INSERT INTO news_article (url, title, author, publish_time, summary, source) VALUES (%s, %s, %s, %s, %s, %s) def batch_insert(items, conn, batch_size200): rows [ (it[title], it[url], it.get(author), it.get(publish_time), it.get(summary), localhost_demo) for it in items ] for start in range(0, len(rows), batch_size): batch rows[start:start batch_size] with conn.cursor() as cursor: cursor.executemany(SQL_INSERT, batch) conn.commit()批量大小不是越大越好。batch_size200适合字段不含大文本的场景如果summary或content很长单条 SQL 的字节数会快速膨胀一旦超过 MySQL 的max_allowed_packet连接会被直接断开。查这个变量用SHOW VARIABLES LIKE max_allowed_packet;默认通常是 64MB听起来很大但 200 条带MEDIUMTEXT的数据可能每条几十 KB累计起来就非常可观。我的经验是普通文章类数据用 200带正文用 50抓取图片链接或 HTML 快照时降到 20。另一个好处是分批 commit 意味着失败时只回滚当前批次不至于全部推倒重来。4.3 ON DUPLICATE KEY UPDATE 做增量更新跳过重复 URL建表时给 URL 加的uk_url唯一索引现在派上用场。最常见的增量抓取需求是同一条新闻今天抓到明天又出现在列表页顶部需要跳过重复而不是报错退出。SQL_UPSERT INSERT INTO news_article (url, title, author, publish_time, summary, source) VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE title VALUES(title), publish_time VALUES(publish_time), updated_at CURRENT_TIMESTAMP 这段 SQL 的行为插入时如果uk_url冲突则不再报错而是更新title和publish_time顺便把updated_at刷成当前时间。VALUES(column)是在 MySQL 中引用待插入值的老写法8.0.20 之后开始标记为 deprecated功能仍然可用如果你用的 MySQL 8.0.19 及以上也可以换成AS new别名语法效果一致。这个写法的意义在于它把“先 SELECT 判断存在再 INSERT”两步合并成了一步避免了并发下两条请求同时判断“不存在”然后重复插入的竞态问题。判断和写入由数据库的索引约束原子完成比应用程序加锁可靠得多。如果只是想丢弃重复的旧记录而不更新可以用INSERT IGNORE。但要注意它会忽略所有类型的错误包括字段超长、非法日期等并不只处理唯一键冲突所以我默认不用它。4.4 多线程爬虫与连接池的线程模型Python3 的多线程适合 I/O 密集型任务爬虫正好属于这一类。多线程抓取时MySQL 连接不能在线程间共享——PyMySQL 的 Connection 对象不是线程安全的常见做法是每个线程各自建立独立连接。用ThreadPoolExecutor写一下from concurrent.futures import ThreadPoolExecutor def crawl_and_save(url): html fetch_html(url) items parse_news_list(html) conn pymysql.connect( host127.0.0.1, port3306, userspider, passwordspider123, databasespider_db, charsetutf8mb4, ) try: batch_insert(items, conn) finally: conn.close() with ThreadPoolExecutor(max_workers4) as pool: pool.map(crawl_and_save, start_urls)每个 worker 打开自己的连接任务结束关闭。线程数一般控制在 48不要盲目开几十个因为目标站点和 MySQL 都扛不住高频请求。任务量再大、站点更多时可以考虑 DBUtils 的PooledDB维护一个连接池让每个线程从池中借用连接用完归还from dbutils.pooled_db import PooledDB pool PooledDB( creatorpymysql, maxconnections8, mincached2, host127.0.0.1, userspider, passwordspider123, databasespider_db, charsetutf8mb4, ) conn pool.connection()连接池的关键参数maxconnections我一般设为“爬虫 worker 数 × 1.5”比如 4 个线程就开 6。爬虫不是高并发写库场景池开太大只会让 MySQL 的线程调度变慢。mincached2表示启动时预建 2 条连接减少首波请求的连接建立时间。5. 入库后的验证与排错乱码、重复、类型错乱一次看清5.1 入库后三种隐蔽错误乱码、重复、类型错乱脚本跑完没有报错不代表数据是好的。下面三条 SQL 是每批入库后我必跑的检查-- 检查重复 URL SELECT url, COUNT(*) AS c FROM news_article GROUP BY url HAVING c 1 ORDER BY c DESC LIMIT 10; -- 检查没有解析出时间的记录 SELECT id, title, publish_time FROM news_article WHERE publish_time IS NULL ORDER BY id DESC LIMIT 10; -- 用 HEX 判断乱码 SELECT id, title, HEX(title) FROM news_article LIMIT 5;第一条用来验证唯一索引是否真的生效。如果c 1的结果不为空说明建表时的uk_url没起作用或者 URL 在入库前没有做拼接统一。第二条检查日期解析publish_time IS NULL数量过多时要回看parse_date是否漏掉了某种日期格式。第三条HEX的结果中如果大量出现EF BF BD这是 UTF-8 替换字符的十六进制表示网页原始编码在抓取阶段已经被破坏此时查库是查不回原文的只能回到fetch_html的编码判断去修。乱码的排查要按链路走网页响应头 charset →apparent_encoding猜测结果 → Python 字符串 → PyMySQL 连接 charset → MySQL 表字段 collation。哪一层断了前面就白干。用 Workbench 或 Navicat 可视化看到的是方便之处但那只是展示层真正的编码问题要用HEX()才能定位。5.2 一个可直接改用的抓取入库函数雏形把本文各节串成一个可独立运行的文件我称之为spider.py由四个函数组成def run_pipeline(start_urls, batch_size200): for url in start_urls: html fetch_html(url) # requests items parse_news_list(html) # BeautifulSoup conn pymysql.connect( host127.0.0.1, port3306, userspider, passwordspider123, databasespider_db, charsetutf8mb4, ) try: batch_insert(items, conn, batch_size) finally: conn.close() if __name__ __main__: run_pipeline([http://localhost:8000/list.html])运行命令python3 spider.py这个函数的特点是每个环节都能单独替换parse_news_list换正则或 XPath 也行pymysql.connect换成连接池也行只要items保持list[dict]的形状入库层不需要改动。调试时我会先跑一次抓取打印len(items)确认解析出了多少条再跑一次入库最后执行mysql -h127.0.0.1 -uspider -p spider_db -e SELECT COUNT(*) FROM news_article;核对总数。通过这种方式把“页面解析”和“数据库写入”两个步骤的报错彻底隔离开来。本文还有配套的精品资源点击获取