不少爬虫工程师在写数据入库模块时,习惯用字符串拼接的方式构造INSERT语句,把抓到的标题、正文、评论直接塞进SQL字符串里。这种写法在抓取正常网页时看不出问题,可一旦目标页面本身被植入了恶意内容,或者采集到的数据里含有单引号、注释符等特殊字符,轻则数据插入报错导致任务中断,重则采集内容被构造成攻击载荷,直接威胁数据库安全。本文结合实际场景,聊聊爬虫入库环节的SQL注入风险该怎么排查和修复。

为什么字符串拼接SQL是爬虫最大的安全隐患
先看一段典型的错误代码。很多初学者写爬虫存储时是这样的:
import pymysql
conn = pymysql.connect(host='127.0.0.1', user='root', password='123456', database='spider')
cursor = conn.cursor()
title = " iPhone 15 保护壳(防摔款" # 抓取到的标题
content = "商品描述:'优质材质',详见说明" # 抓取到的正文含单引号
sql = "INSERT INTO goods (title, content) VALUES ('%s', '%s')" % (title, content)
cursor.execute(sql)
conn.commit()
这段代码有两个层面的风险。第一层是稳定性问题:正文里的单引号会破坏SQL语法结构,MySQL收到这条语句后会把'优质材质'当成一段完整的字符串边界,后面的内容全部变成非法语法,直接抛出1064语法错误,爬虫任务随之挂掉。这在采集UGC内容、商品评论、论坛帖子时尤其常见,用户随手打一个引号就能让你的程序崩掉。
第二层才是真正的注入风险。假设某个恶意页面被你抓到了,它的评论内容是whatever', 'x', 'y'); DROP TABLE goods;--,拼接后SQL语句的语义就被完全改变,攻击者可以借此执行任意语句,包括读取其他表的数据、篡改记录甚至删除整张表。爬虫采集的数据来源是不可控的第三方网页,本质上等同于把外部输入直接写进SQL,这正是SQL注入产生的经典条件。所以要明确一个原则:凡是外部采集的内容进入SQL,都必须走参数化,绝不能拼接。
参数化查询:最根本的修复手段
修复SQL注入的标准答案是参数化查询,也就是把SQL语句的骨架和具体数据分开传给数据库驱动,由驱动负责安全处理。不同库的占位符写法略有差别:pymysql和sqlite3用%s,而SQLite原生模块也接受问号?。改写后的正确代码如下:
import pymysql conn = pymysql.connect(host='127.0.0.1', user='root', password='123456', database='spider') cursor = conn.cursor() title = " iPhone 15 保护壳(防摔款" content = "商品描述:'优质材质',详见说明" sql = "INSERT INTO goods (title, content) VALUES (%s, %s)" cursor.execute(sql, (title, content)) conn.commit() conn.close()
注意这里的%s不再是Python的字符串格式化符,而是数据库驱动的占位符,数据和语句是作为两个独立参数传给execute的。驱动会把数据单独发送给数据库服务器,数据库解析完语句骨架后再绑定参数值,无论数据里包含引号、注释符还是分号,都只会被当作普通字符串内容处理,不可能改变语句语义。
有两个细节容易踩坑。一是参数必须放在元组或列表里,即使只有一个值也要写成(title,)而不是(title),后者只是一个字符串。二是不同驱动的占位符风格不同:MySQL的驱动用%s,PostgreSQL的psycopg2也用%s,但原生sqlite3模块同时支持?和%s,如果项目中途换数据库,记得检查占位符写法是否兼容。
如果项目用的是SQLite,比如轻量级的分布式爬虫本地缓存,写法几乎一样:
import sqlite3
conn = sqlite3.connect('spider_data.db')
cursor = conn.cursor()
sql = "INSERT INTO goods (title, content) VALUES (?, ?)"
cursor.execute(sql, (title, content))
conn.commit()
conn.close()
批量入库与数据预处理:让存储又快又稳
爬虫数据量通常很大,一条一条execute再commit效率很低。参数化查询天然支持批量操作,用executemany一次提交整批数据,配合事务控制可以大幅提升写入速度:
import pymysql
items = [
("苹果手机", " descriptions with 'quote' "),
("华为耳机", "正常描述"),
("小米电视", "包含 -- 注释符的内容"),
]
sql = "INSERT INTO goods (title, content) VALUES (%s, %s)"
try:
cursor.executemany(sql, items)
conn.commit()
except Exception as e:
conn.rollback()
print(f"批量入库失败,已回滚: {e}")
这里同时演示了异常回滚。批量写入时只要有一条数据触发唯一键冲突或字段超长,整批都会失败,所以建议用try块包裹并rollback,或者改用INSERT IGNORE、ON DUPLICATE KEY UPDATE语句配合参数化来容忍重复数据,这在爬虫去重场景非常实用。
除了SQL层面的防护,入库前对采集内容做一轮清洗同样重要。网页抓下来的字符串经常带着HTML标签、超长空白、不可见字符,直接入库既浪费存储也容易出显示问题。常见的预处理包括用正则去掉HTML标签、截断超长字段、规范化空白字符、过滤掉包含明显攻击特征的极端内容:
import re
def clean_text(text, max_len=500):
if text is None:
return ''
# 去掉HTML标签
text = re.sub(r'<[^>]+>', '', text)
# 规范化空白字符(含换行、制表符)
text = re.sub(r'\s+', ' ', text).strip()
# 截断到字段允许的长度
return text[:max_len]
# 入库前统一清洗
items = [(clean_text(t), clean_text(c)) for t, c in raw_items]
需要强调的是,清洗是为了数据质量和长度安全,它不能替代参数化查询。有些教程教你手动给单引号加反斜杠来防注入,这种做法防御不彻底,遇到GBK等多字节编码场景还可能被绕过,务必以参数化为主、清洗为辅。
用ORM进一步降低出错概率
如果项目规模变大,字段和表越来越多,手写SQL的维护成本会上升。这时可以引入SQLAlchemy这类ORM框架,它默认所有查询都是参数化的,从机制上杜绝了拼接SQL的可能:
from sqlalchemy import create_engine, Column, Integer, String, Text
from sqlalchemy.orm import declarative_base, sessionmaker
engine = create_engine('mysql+pymysql://root:123456@127.0.0.1/spider?charset=utf8mb4')
Base = declarative_base()
class Goods(Base):
__tablename__ = 'goods'
id = Column(Integer, primary_key=True, autoincrement=True)
title = Column(String(200))
content = Column(Text)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
# 直接传入任意特殊字符的字符串都是安全的
session.add(Goods(title="含'引号'的标题", content="任意内容 -- 都安全"))
session.commit()
session.close()
ORM的好处是把表结构映射成Python类,代码可读性和可维护性都更好,而且天然带连接池,对高并发爬虫很友好。缺点是学习曲线和性能开销,对于超大规模的批量写入,原生executemany或者直接用LOAD DATA方案仍然更快。可以根据项目阶段选择:小脚本用pymysql加参数化就够了,中大型爬虫框架建议上ORM。
最后再总结几条实践建议:所有入库数据一律走占位符,代码里全局搜索execute后跟字符串拼接的写法,基本能揪出全部隐患;数据库账号只授予INSERT和SELECT权限,不給DROP和DELETE,即使被注入也把损失控制在最小;采集字段长度在清洗阶段就截断,避免依赖数据库报错;养成使用try块加rollback的习惯,保证批量任务失败后数据一致。做到这几点,爬虫的存储模块就算经得起恶意内容的考验了。