ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python爬虫数据脱敏实战:手机号、身份证等四类敏感字段处理方案

Python爬虫数据脱敏实战:手机号、身份证等四类敏感字段处理方案 做爬虫的兄弟恐怕都遇到过这种场景辛辛苦苦把一个网站的数据抓下来落地成表正想跑个数据分析结果合规那边一句话把你的脚本打回去重写说表里有手机号、身份证号不合规。我早期做爬虫的时候也觉得这是小题大做直到有一次自己导出的数据文件在内部流转时被点名才真正意识到数据脱敏不是可选优化项而是整个爬虫工程里必须内建的一道防线。这篇文章就围绕Python爬虫的数据脱敏展开讲清楚手机号、身份证号、IP、邮箱这四类最常见敏感字段怎么脱敏为什么要放在入库前处理以及怎么做到既满足合规要求又不影响下游的数据分析。无论你是刚入门爬虫的新手还是已经在跑分布式采集的老手这套方案都可以直接抄作业代码部分我已经整理成独立函数复制改改就能用。1. 为什么爬虫数据必须做脱敏1.1 爬虫抓到的哪些字段属于敏感个人信息爬虫抓回来的数据往往是完整的原始字段这是最危险的地方。比如招聘网站的个人简历、电商平台的订单信息、社交媒体的用户主页里面通常包含手机号、身份证号、IP归属地、邮箱地址。按照个人信息保护的相关要求手机号和身份证号属于敏感个人信息中的核心类别邮箱和IP虽然敏感程度略低但结合其他字段同样能定位到具体个人。我见过不少团队把爬虫数据直接导入MySQL或者导出Excel然后通过网盘、聊天工具转发给同事。数据一旦出了生产环境谁点了转发、谁能看到完整字段就完全失控了。脱敏的核心逻辑很简单让不该看到完整数据的人只能看到加工后的版本从源头降低泄露风险。所以爬虫工程师一定要建立这个意识——抓到的数据不等于可以直接落库的数据中间必须过一道脱敏加工层。1.2 脱敏在这条数据链路中的位置爬虫的数据链路通常是请求采集、解析提取、清洗转换、入库存储、下游消费。脱敏应该放在哪一步很多人会搞错。有人放在入库后靠数据库触发器或者定时任务去刷这样原始数据已经在库里待过一段时间属于事实上的留存有人放在展示层只在接口返回时打码但库里存的还是明文一旦库被拖走或者备份泄露照样出事。正确的做法是把脱敏放到清洗转换之后、入库存储之前。也就是解析出字段后先做格式校验和清洗再调用脱敏函数把敏感字段替换掉最后才写进数据库。这样做的好处是数据库里压根没有明文备份、导出、查询都是安全的。分布式爬虫场景下每个采集节点都执行这一套流程数据汇聚到中心库时已经是干净的后续任何环节都不需要担心泄露。2. 四种核心脱敏场景与算法设计2.1 手机号脱敏前3后4还是中间4星手机号脱敏是最常见也最容易被写错的。标准的做法是保留前3位和后4位中间4位用星号代替比如138****5678。前3位是运营商号段保留它不影响地区维度的统计后4位用于区分同一个号段下的不同用户在业务上有时需要做模糊匹配。我之前见过有人把中间4位替换成xxxx这样当然也行但如果你的数据下游需要按号段做用户画像xxxx无法还原号段分布就只能保留前3位了。另一种错误做法是只保留后4位前7位全打码这会导致同一个运营商号段的数据无法聚合。实际项目中我通常保留前3后4中间4位统一用4个星号。这里有个细节虚拟号段如170、171、166正则的匹配范围要覆盖到位不能漏掉否则脱敏函数会直接跳过这些号码明文就穿帮了。2.2 身份证号脱敏15位与18位的细节身份证号脱敏比手机号复杂因为存在15位旧版和18位新版。18位身份证的组成是6位地址码、8位出生日期、3位顺序码、1位校验码通用脱敏策略是保留前6位和后4位中间8位打码这样既能保留地区信息又隐藏了出生日期和顺序码。但注意很多人以为后4位包括校验位实际上顺序码是倒数第4到倒数第2位最后1位是校验码保留后4位等于把最后一个顺序码也留下了这在实际使用中可接受。15位身份证没有校验码组成是6位地址码、6位出生日期、3位顺序码脱敏时保留前6位和后3位中间6位打码。写脱敏函数时必须同时处理两种长度并且要先做正则校验再脱敏不能盲目截取否则把非身份证的字符串也打乱了。我在代码里还做过一层保护如果一个字符串既不像15位也不像18位身份证就原样返回避免误伤订单号、学号等其他字段。2.3 IP脱敏IPv4/IPv6与代理链日志IP脱敏在爬虫场景中有两层含义。第一层是你抓到的业务数据里包含用户的IP地址比如评论区的IP属地、登录日志里的来源IP第二层是你自己爬虫程序的日志里记录的代理IP、目标服务器IP。这两层都要脱敏因为你自己的日志如果泄露了代理池IP相当于把整个采集基础设施暴露了。IPv4地址的常用脱敏策略是保留前2个八位组后2个用星号代替比如192.168.*.*这样能保留B段粒度的地域信息足够做城市级别的统计又无法定位到具体主机。IPv6地址长度更长推荐保留前2组十六进制段其余全部打码。这里有个坑IPv6有压缩写法比如2001:db8::1直接按冒号分割会得到空段要先通过ipaddress库解析成标准格式再脱敏否则不同格式的同一地址脱敏结果不一致下游做去重统计时会出错。2.4 邮箱脱敏用户名与域名的平衡邮箱脱敏相对简单但细节也不少。常见做法是保留用户名首字母和后面的域名中间打码比如z****qq.com。如果用户名很短只有1个字符那就退化成首字符加星号比如a*163.com。还有一种做法是保留前2个字符和后1个字符更激进但用户体验稍差适合数据导出场景。邮箱脱敏要注意的是大小写。邮箱地址本身不区分大小写但正则匹配时如果从原始文本里直接抓可能匹配到全大写或混合大小写的写法统一转成小写再脱敏会显得更规整。另外对于包含加号后缀的邮箱比如usertagdomain.com如果直接按分割加号会被保留在用户名里我通常会先把后面的内容去掉再脱敏这样能减少同一邮箱的不同变体干扰统计。3. 可落地的实现代码3.1 脱敏函数库实现这一节直接上代码我整理了一套独立的脱敏工具模块包含手机号、身份证、IP、邮箱四个函数外加一个批量处理入口。所有函数都遵循一个原则输入非法格式时原样返回宁可漏过也不误伤。import re import ipaddress _MOBILE_RE re.compile(r1[3-9]\d{9}) _ID_CARD_18_RE re.compile(r\d{17}[\dX]) _ID_CARD_15_RE re.compile(r\d{15}) _IPV4_RE re.compile(r(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})) _EMAIL_RE re.compile(r[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}) def mask_mobile(mobile: str) - str: if not _MOBILE_RE.fullmatch(mobile): return mobile return mobile[:3] **** mobile[-4:] def mask_id_card(id_no: str) - str: id_no id_no.upper() if _ID_CARD_18_RE.fullmatch(id_no): return id_no[:6] ******** id_no[-4:] if _ID_CARD_15_RE.fullmatch(id_no): return id_no[:6] ****** id_no[-3:] return id_no def mask_ip(ip: str) - str: try: parsed ipaddress.ip_address(ip) except ValueError: return ip if parsed.version 4: parts str(parsed).split(.) return parts[0] . parts[1] .*.* else: parts str(parsed).split(:) return parts[0] : parts[1] :****:**** def mask_email(email: str) - str: if not in email: return email name, domain email.split(, 1) if len(name) 1: return name * domain return name[0] * * (len(name) - 2) name[-1] domain def mask_personal_info(text: str) - str: text _MOBILE_RE.sub(lambda m: mask_mobile(m.group()), text) text _ID_CARD_18_RE.sub(lambda m: mask_id_card(m.group()), text) text _ID_CARD_15_RE.sub(lambda m: mask_id_card(m.group()), text) text _IPV4_RE.sub(lambda m: mask_ip(m.group()), text) text _EMAIL_RE.sub(lambda m: mask_email(m.group()), text) return text这里要特别说明为什么手机号正则用1[3-9]\d{9}而不是更宽的\d{11}。虽然国内手机号目前都是11位但并不是所有11位数字都是手机号如果匹配\d{11}身份证里的连续数字片段、订单号、座机号都可能被误伤。1[3-9]覆盖了现网所有主号段和虚拟号段未来即便新增号段只要还是1开头、第二位落在3到9这个正则就不会失效。身份证脱敏函数里我用了fullmatch和上面文本替换用的re.sub不同sub匹配的是文本中的片段fullmatch判断的是整个字段。这样区分是有意的当处理单字段时用fullmatch严格校验避免把非法字符串打码当处理日志文本时用sub灵活替换保证手机号和身份证出现在长文本里也能被找到。很多新手拿着同一个正则到处用结果在场景切换时出问题这套区分逻辑可以帮你少踩坑。3.2 SQLAlchemy入库前自动脱敏如果你的爬虫项目用了SQLAlchemy存数据我推荐用validates装饰器在模型层做脱敏这样不管爬虫主流程还是手动补数据的脚本只要写进这个模型字段都会被强制处理脱敏逻辑收敛在一个地方不会因为新增采集入口而漏掉。from sqlalchemy import Column, String, Integer from sqlalchemy.orm import validates from sqlalchemy.ext.declarative import declarative_base Base declarative_base() class User(Base): __tablename__ user id Column(Integer, primary_keyTrue) mobile Column(String(11)) id_card Column(String(18)) ip Column(String(64)) email Column(String(128)) validates(mobile, id_card, ip, email) def auto_mask(self, key, value): if not value: return value if key mobile: return mask_mobile(value) if key id_card: return mask_id_card(value) if key ip: return mask_ip(value) if key email: return mask_email(value) return value用validates有一个好处它在赋值阶段就会执行也就是说你在代码里user.mobile 13812345678的那一刻内存里的值已经被改成脱敏后的138****5678了。这比写before_insert事件更早触发能防止有些业务逻辑在插入前又把字段打到日志里。不过要注意validates只拦截ORM对象的属性赋值如果你用Table.insert()或者裸SQL批量写入它是拦不住的。所以我的习惯是把上面5个函数独立成mask_utils.py然后在模型层调用在爬虫管道里也调用宁可重复调用也不能漏掉任何一条写入路径。3.3 爬虫日志脱敏日志脱敏容易被忽略但实际泄露风险比数据库还高。很多爬虫框架打印请求日志时会把完整的URL、请求头、响应片段都打出来URL的query参数里可能带手机号响应片段里可能带身份证号。我见过一个线上事故运维排查采集任务失败时把日志文件发给了外部技术支持里面正好有爬虫抓回来的完整用户手机号。日志脱敏我推荐在logging的Formatter层做这样所有走logging输出的文本都会被统一过滤。import logging class MaskingFormatter(logging.Formatter): def format(self, record): result super().format(record) return mask_personal_info(result)然后在使用时指定这个Formatterhandler logging.StreamHandler() handler.setFormatter(MaskingFormatter(%(asctime)s - %(levelname)s - %(message)s)) logger logging.getLogger(__name__) logger.addHandler(handler)这种做法的好处是侵入性最低代码里现有的所有logger.info(...)都不用改只要把Formatter替换掉即可。但有个性能方面的坑mask_personal_info会对每条日志做5轮正则扫描在高并发采集时可能拖慢日志写入。我的优化方案是先加一个快速判断比如日志文本里不包含数字就不执行正则替换这样至少能过滤掉一半以上的纯文本日志。另外如果你在日志中打印请求头记得把X-Forwarded-For、X-Real-IP这些头也纳入脱敏范围它们本质上就是IP字段。3.4 敏感字段探测与批量清洗脚本除了在写入链路里脱敏还会遇到一种情况以前的老库或者上游给的源文件里已经有明文数据了这时候需要批量清洗。我写过一个简单的探测加清洗脚本可以扫一遍文本文件或者Excel找出包含敏感模式的单元格并做替换。def sanitize_file_row(row: dict, sensitive_keys: list None) - dict: if sensitive_keys is None: sensitive_keys [] for key, value in row.items(): if not isinstance(value, str): continue if key in sensitive_keys: row[key] mask_personal_info(value) elif _MOBILE_RE.search(value) or _ID_CARD_18_RE.search(value): row[key] mask_personal_info(value) return row这个脚本的妙处在于对于已经明确命名的敏感字段直接处理对于没有命名的字段只要内容里出现了手机号或身份证模式也会顺手处理。第一种适用于结构清晰的业务表第二种适用于从网上批量下载的csv、json文件字段名乱七八糟靠值特征来兜底。批量清洗要比写入链路脱敏慢很多尤其是在百万行数据上跑正则性能问题会很明显。我的建议是先用pandas读入只对包含数字的列调用sanitize_file_row不要对全表逐格跑正则。曾经处理过一个50万行的Excel优化前跑了40多分钟优化后只跑了不到5分钟差别就在减少不必要的正则扫描上。4. 常见问题与排查经验4.1 正则匹配的边界为什么要先验证再脱敏脱敏正则最大的风险是误伤正常数据而不是漏掉敏感数据。一个典型的例子是身份证正则\d{17}[\dX]如果检测到一段20位的物流单号中间的18位可能被sub截出来打码这就把业务主键破坏了。所以我在所有脱敏函数里都先用fullmatch确认整个字符串就是目标格式再执行打码。对于文本替换场景可以接受误伤率稍微高一点但也要用\b边界约束防止长数字串中夹带的片段被处理。另一个边界问题是大小写。身份证号里的X必须统一转成大写再匹配否则小写x会漏过去整个正则就失效了。邮箱的正则我用了比较宽松的邮箱格式能识别user.nametagdomain.com这种形式但无法覆盖所有国际化域名比如中文域名。如果业务确实牵涉到国际化邮箱建议做一层DNS校验后再决定是否脱敏或者直接把未识别出的复杂邮箱交给人工审核。4.2 脱敏后数据还能用吗哈希关联与泛化很多人担心字段脱敏后就做不了数据分析、做不了用户关联了这个顾虑确实存在但有解法。如果只做内部统计分析可以用不可逆的哈希值替代纯打码。比如手机号的哈希值在同一个项目内部是稳定的两张表想关联手机号维度的数据用哈希值join就行了但外部拿到这个哈希也无法反推出原始号码。不可逆哈希和打码的区别在于打码后的字符串还可能被撞库尝试哈希值如果把盐加得足够随机反推难度会大得多。如果要做用户画像建议在脱敏前先抽取特征再把特征落库。比如IP地址脱敏成192.168.*.*之前先把城市和运营商信息提取出来存成city和isp字段手机号脱敏前提取前3位作为号段字段。这样即使敏感字段被处理业务分析需要的维度信息已经独立保存互不冲突。我见过很多团队为了保留统计能力不脱敏结果出了事故才追悔莫及其实用特征提取的方案完全可以兼顾。更进一步的方案是K匿名。如果你的数据要提供给外部做研究或者测试不能只靠单字段脱敏因为多个字段组合起来仍然可能定位到个人。比如一个表里同时有生日、性别、职业三个字段组合可能唯一确定一个人。K匿名的思路是把这些准标识符做泛化让每组至少K个人具有相同的属性组合。这个思路在爬虫数据集中应用较少主要因为泛化会明显损失信息量但对于高风险的数据交付场景值得引入。4.3 性能与并发场景下的脱敏优化脱敏函数的单条执行很快但在高并发爬虫场景下每天可能处理几百万条数据累积耗时不可忽视。我测试过mask_personal_info在普通文本上大约耗时几十微秒但对于单条长度长、数字多的数据正则的复杂度会上来。一个常用的优化手段是预编译正则我已经在代码里这么做了另一个手段是控制调用频率在爬虫管道里先做一次字段级判断只有出现手机号、身份证、邮箱特征的字段才进入脱敏逻辑其余字段直接放行。还有一个细节容易被忽视脱敏操作要在采集线程里做还是在下游清洗线程里做。我推荐在采集线程第一时间做越早越好因为Requests、Selenium返回的数据在内存中流转的每一秒都是明文一旦内存在该阶段被dump明文就泄露了。曾经有一个项目为了压缩采集耗时让爬虫线程只抓取不处理集中到批处理任务里做清洗脱敏结果抓取的原始数据全在消息队列里堆积某个队列磁盘写满了运维一查发现里面全是完整手机号场面非常被动。4.4 分布式爬虫中的IP脱敏陷阱分布式爬虫场景下每个采集节点的出口IP不同日志里会同时出现大量IP地址脱敏的统一性更容易出问题。比如一台机器日志里记录的是10.0.0.8:8080这种带端口的内网IP另一台机器记录的是2001:db8:85a3::8a2e:370:7334这种压缩IPv6地址直接套用上面的mask_ip前者会因为带端口而无法被ipaddress解析后者压缩格式不同导致脱敏结果不一致。我的处理方案是先剥离端口再脱敏IPv6在脱敏前统一用ipaddress解析成完整格式再用哈希值作为节点关联字段。这样既能确认哪条日志来自哪个节点又不会在日志里暴露完整的出口IP。另外代理IP池的管理台和采集日志要分开存储管理台里保持完整的IP列表是运营需要采集日志里只保留脱敏后的IP避免两套系统同时泄露后交叉定位。还有一点值得注意X-Forwarded-For头是可以伪造的你从请求头里解析出来的所谓用户IP可能根本不是真实IP。脱敏不是信任验证的前提如果你打算按IP维度做风控或者归属分析不要只依赖请求头至少要结合TCP层连接IP做交叉校验否则你脱敏的只是一个客户端随手填写的字符串真实的源IP仍留在链路中未被处理。5. 脱敏方案的选型对照与最终经验5.1 常见脱敏方案对比我整理了一个选型对照表方便你在项目启动前快速定方案。每种方案都有自己的适用边界没有银弹关键是匹配业务场景和风险等级。方案可逆性信息保留度适用场景风险点固定掩码打码不可逆低展示、导出、日志无法关联统计撞库风险哈希加盐不可逆中用于关联内部关联分析盐泄露后需重算可逆加密可逆高需要回查明文的生产系统密钥管理复杂泄露即全量泄露泛化处理不可逆中高对外交付数据集信息损失大维度难把控K匿名不可逆中研究性数据交付实现成本高维度组合需设计我早期做爬虫应用时贪图省事直接全字段可逆加密以为只要密钥不泄露就安全结果忽略了密钥轮换时所有历史数据都要回算泄密。后来更加倾向于把不可逆打码作为默认策略只有极少数严格受控的场景允许用可逆加密而且密钥必须独立存放、定期轮换。5.2 多环境一致性开发和测试环境要不要脱敏很多项目只在生产环境做了脱敏开发环境和测试环境反而直接用明文理由是开发要调试功能方便。这其实是很大的隐患我见过测试环境的数据库和研发本地文件里出现大量真实手机号最后往外泄露时根本追不到源头。我的建议是脱敏规则在代码仓库里统一管理所有环境共用同一套函数只是测试环境的脱敏可以更激进比如把手机号统一替换成13800000000开头的虚拟号码保留格式但不保留真实性。这样做还有个额外好处测试环境的数据看起来和生产环境一样规整不会因为打码符号不一致影响前端页面的样式调试。比如138****5678和13800000000在展示宽度上完全一致但如果测试环境用的是随机生成的号段可能长度不固定页面布局就出问题。脱敏的统一性本身也是工程质量的一部分。5.3 我的最后一点建议脱敏这件事说到底是把数据的安全边界前移。爬虫工程师不能只把目光放在采集效率、反爬对策和解析成功率上数据拿到手之后的处理链路同样重要。我踩过几次坑之后最大的体会是脱敏一定要内建在管道里让非法数据进不了库而不是等出了问题再补脚本清洗。最后分享一个小技巧如果你现有的爬虫项目已经积累了大量代码没有精力立刻全部接入脱敏函数可以先加一个监控脚本每天扫描新入库的数据发现手机号、身份证号等明文模式就报警至少能先知道问题在哪里发生再逐步把脱敏逻辑补到对应管道里。等所有入口都覆盖了这个监控脚本还能留下当合规审计的辅助工具一举两得。
返回列表