ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Python批量提取网页电话号码:从正则匹配到CSV导出的完整指南

Python批量提取网页电话号码:从正则匹配到CSV导出的完整指南 谁想要本船长的电话号码这句话放在技术讨论区里通常不是在找真人联系方式而是在玩一个梗把网页、文档、日志里散落的电话号码批量提取出来整理成一张表。这个动作看起来小真做起来很容易翻车。编码、格式、去重、抓取超时、页面结构变化任何一个环节出错结果就是半小时只产出一堆乱码。所以这篇要写的不是一条“找电话”的捷径而是一套把“网页/文本里的联系电话”批量整理成结构化表格的 Python 脚本流程。适合想处理名单、做信息整合、做联系人清理的开发者也适合公司内部做资产信息巡检时顺手把公开联系方式一并归档的运维同学。项目本身不难难的是把“能跑”变成“能稳定跑”。下面按实际落地顺序拆一遍。先说明这脚本解决什么问题再给环境准备、单条任务、批量任务、参数调整和排查链路。1. 先搞清楚它解决的是联系方式整理问题不是“通讯录爆破”1.1 这脚本到底在做什么输入可以是一个网址列表也可以是一个纯文本文件。脚本要做的事分成四段读取网页正文或本地文本。用正则规则把疑似电话号码的内容挑出来。做清洗、格式统一、去重。输出成 CSV 或 Excel方便继续筛选、归档、核对。输出表格里的字段我一般至少保留这几列来源URL、原文片段、提取结果、格式化结果、提取时间。保留原文片段很重要。后面你会发现只看提取结果经常判断不了号码是否真的有效翻回原文才能确认上下文。1.2 适合的任务和不适合做的任务适合做的是这些整理自有网站或公司公开页面上的联系电话。把内部文档、运维手册、告警联系人表里的号码抽取出来。对一份纯文本名单做字段清洗把带分隔符、带括号、带分机号的联系方式统一成标准格式。定期巡检一批已知业务页面发现联系方式变更后及时提醒。不适合做的也要说清楚。不要拿这个脚本去收集未经授权的个人信息不要做批量外呼也不要在目标站点没有明确允许的情况下高频抓取。技术上能跑通不代表可以滥用尤其联系方式这种字段一旦用于骚扰或营销性质就变了。合规边界这关不过后面所有功能都没有意义。所以实际使用前先确认数据来源要么是你自己有权限处理的页面要么是站点明确公开且允许访问的联系方式要么是公司内部信息。即便如此请求频率也要控制不能给目标站点造成压力更不能碰任何绕过访问限制的操作。1.3 为什么不少人会在这个小项目上翻车因为“提取电话号码”看起来像一行正则的事但现实数据远比想象中乱。同一个号码在不同页面上可能写成138-1234-5678、138 1234 5678、1381234-5678、86 138-1234-5678甚至带一句“手机号请在工作时间拨打”。如果不先做样例验证就直接全量跑结果一定是一张看着挺多、实际用不了的脏表。我建议把项目预期定成“整理公开联系方式”而不是“找到所有号码”。能准确提取和格式化能去重能看出来源页面这已经是合格工具。追求 100% 覆盖率反而会把正则越写越复杂最后连自己都维护不了。2. 准备环境Python、依赖、一份安全的样例清单2.1 最小依赖这个项目不需要重型框架普通 Python 环境就够。我建议的最小依赖依赖用途版本建议Python运行环境3.8 或更高requests拉取网页内容2.x 常规版本即可beautifulsoup4解析 HTML、提取正文文本4.x 常规版本即可lxmlHTML 解析器与 beautifulsoup4 搭配pandas导出 Excel 时更省事可用可不用导出 CSV 的话不装也行pandas 不是必须的。如果你只用 CSV 输出标准库csv就够了。装 pandas 是为了后期过滤、排序、分组方便但对一个提取脚本来说属于增强项不是前置条件。2.2 准备一份不会出事的样例清单第一次跑通之前不要直接拿几十个真实站点做实验。我一般会准备三个级别的样例本地文本文件里面故意放几行乱格式号码用来验证正则和清洗逻辑。一个自己能控制的简单 HTML 页面或者技术圈常见文档页面用来验证网页解析。一份只有三五个 URL 的列表用来验证批量流程。样例清单的价值是让每次改动都可对比。你改一次正则跑同一份样例看结果差异比直接上全量数据靠谱得多。2.3 环境检查步骤建议先做这三个检查再进入代码环节。python --version pip --version python -m venv .venvWindows 下激活虚拟环境.venv\Scripts\activatemacOS / Linux 下激活source .venv/bin/activate激活之后安装依赖pip install requests beautifulsoup4 lxml pandas为什么要先建虚拟环境因为这个脚本依赖的库可能和你系统里的其他项目冲突。尤其在 macOS 和部分 Linux 发行版上系统自带的 Python 环境并不适合直接装包。虚拟环境之间互不影响这个项目出问题直接删掉重建就行。检查完环境之后确认当前目录有可写权限。很多人第一次跑脚本报错不是代码问题而是输出目录不存在或没有写入权限。建议在项目目录里建一个output目录所有结果统一放进去。3. 单条任务跑通从页面文本到号码表格3.1 读取页面和本地文本我习惯先用一个函数把“输入源”统一转换成纯文本。不管是 URL 还是本地文件最终都返回一段字符串。这样后面提取逻辑只需要处理字符串不用关心来源。import requests from bs4 import BeautifulSoup def load_text_from_url(url, timeout10, headersNone): if headers is None: headers { User-Agent: Mozilla/5.0 (compatible; ContactInfoCollector/1.0) } resp requests.get(url, timeouttimeout, headersheaders) resp.raise_for_status() resp.encoding resp.apparent_encoding soup BeautifulSoup(resp.text, lxml) return soup.get_text(separator\n, stripTrue) def load_text_from_file(filepath): for encoding in [utf-8, gbk, gb18030]: try: with open(filepath, r, encodingencoding) as f: return f.read() except UnicodeDecodeError: continue raise UnicodeDecodeError(f{filepath} 编码无法识别)这里最关键的一行是resp.encoding resp.apparent_encoding。不少网页返回的响应头里写的编码和实际内容不一致直接使用resp.text会乱码。apparent_encoding是 requests 根据内容推断出来的编码应对中文页面更稳。本地文件按utf-8、gbk、gb18030依次尝试。这是很多中文文本的常见编码。如果三种都不行再考虑文件本身是不是损坏了。3.2 正则提取电话的规则提取规则不要只写一条大正则。我会拆成三类手机号、座机号、分机号。import re PHONE_PATTERNS { mobile: re.compile(r(?!\d)1[3-9]\d{9}(?!\d)), landline: re.compile(r(?!\d)0\d{2,3}-?\d{7,8}(?!\d)), extension: re.compile(r(?!\d)\d{3,6}(?!\d)), } def extract_phones(text): results [] for kind, pattern in PHONE_PATTERNS.items(): for match in pattern.finditer(text): results.append({ type: kind, phone: match.group(), start: match.start(), end: match.end(), raw: text[max(0, match.start()-20):match.end()20] }) return results为什么手机号用1[3-9]\d{9}这是国内手机号的常见编号段。这样写会比1\d{10}严格一点能减少把一串数字误判成手机号的概率。座机号用0\d{2,3}-?\d{7,8}覆盖区号、横线分隔的情况。分机号单独提取时误报率会很高所以后面必须靠上下文判断。这里要明确正则只是把“长得像电话的内容”挑出来不代表每个都是可用号码。比如某个页面里的文章编号20240518001也可能被分机号规则命中。所以后面要加清洗和上下文校验。3.3 清洗、格式化、去重清洗规则我一般按顺序做去掉全角和半角空格。去掉括号、横线、点号等常见分隔符。判断类型11 位且以 1 开头按手机号处理以 0 开头且长度 10 到 12 位按座机处理。分机号只有在紧挨着座机号且有明显标记时才保留否则丢弃。结果存进 set 去重。def clean_phone(raw): s raw.replace( , ).replace(-, ).replace((, ) s s.replace(), ).replace(., ).replace( , ) return s def is_valid_phone(s): if len(s) 11 and s.startswith(1) and s.isdigit(): return True if len(s) in (10, 11, 12) and s.startswith(0) and s.isdigit(): return True return False去重有两个维度。第一层是格式化后字符串去重这能去掉明显重复。第二层是“同一来源页面里的相似号码去重”比如同一个号码在页脚出现两次格式化后一样直接去重即可。不同页面出现同一个号码我建议保留因为表格里需要体现这个联系信息出现在哪些页面。3.4 导出 CSV 和 ExcelCSV 是通用性最好的格式我用标准库写避免引入额外逻辑import csv from datetime import datetime def export_csv(results, filepath): with open(filepath, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([来源URL, 号码类型, 格式化号码, 原文片段, 提取时间]) for item in results: writer.writerow([ item[url], item[type], item[phone], item[raw], datetime.now().isoformat(timespecseconds) ])注意使用utf-8-sig而不是utf-8。Windows 下 Excel 打开 UTF-8 文件容易乱码utf-8-sig会在文件开头带 BOMExcel 识别更友好。如果确实需要多 Sheet 或者要做透视再用 pandas 导出 Excelimport pandas as pd df pd.DataFrame(results) df.to_excel(output/phones.xlsx, indexFalse)4. 关键参数和时间控制在哪儿调4.1 超时设置不是越小越好requests.get的timeout我建议传一个元组比如timeout(5, 10)。意思分别是连接超时 5 秒、读取超时 10 秒。只填一个数字时连接和读取都走这个阈值有些响应慢但正常的页面会被误杀。页面大、图片多、服务器响应慢都会影响抓取耗时。低配置机器或者网络波动时超时可以放宽到(10, 20)。但也不要无限放宽否则单个 URL 卡住整个批量任务都拖着不结束。4.2 请求频率不要一上来就开最大并发批量抓取和本地文件处理的区别就在这里。本地文件再快都不会封你但网页请求是外部服务必须控制频率。我建议先单线程跑每次请求之间至少间隔 1 秒。并发优化要放在后面而且只对同一站点的少量 URL 做尝试。如果目标是多个不同站点每站页面数量很少可以保持默认频率。如果目标是同一站点的几十个页面一定要加间隔并在日志里记录每次请求的时间。参数建议初始值什么时候调整timeout(5, 10)站点响应慢时改成 (10, 20)请求间隔1 秒页面数量多且允许时放宽到 0.5 秒重试次数2 次网络不稳定时改为 3 次但要有重试日志单次最大结果数不限制防止误提取过多垃圾数据时可以加阈值4.3 用什么标准判断“跑对了”我判断一次提取成功不是只看有没有输出文件而是看这几个指标单条 URL 从开始抓取到导出结果耗时是否稳定。已知号码是否被完整提取特别是带区号的座机和带分机的号码。提取结果里有多少明显误报误报率是否在可接受范围。乱码有没有出现。输出文件用 Excel 打开后中文是否正常。批量跑完后的日志里有没有大量超时或重试。如果一条页面提取耗时超过 15 秒先看是网络问题还是页面解析问题。如果大量结果都是同一类误报说明正则规则太宽需要收紧或者加上下文过滤。5. 批量任务输入清单、失败重试和增量日志5.1 输入清单用什么格式我建议用纯文本文件一行一个 URL支持#开头注释https://example.com/contact https://example.com/about # 下面这条是已知打不开的用于测试失败重试 https://example.com/404读取清单的逻辑很简单def load_url_list(filepath): urls [] with open(filepath, r, encodingutf-8) as f: for line in f: line line.strip() if not line or line.startswith(#): continue urls.append(line) return urls标记注释这个设计很实用。批量跑之前可以先在清单里注释掉不打算处理的 URL不用删文件。5.2 失败任务怎么处理批量任务里我从来不用“跑完就算成功”而是每个 URL 都记录状态。状态至少分四种success提取完成。empty页面打开成功但没有提取到任何号码。timeout请求超时。failed请求被拒绝、返回非 200 或其他异常。空结果也要单独标记。很多时候没有提取到号码不代表页面没有号码而是页面内容用了图片展示、JavaScript 动态渲染或者号码被刻意拆成了多段。失败任务的处理原则是先重试再跳过最后汇总。对 timeout 和 failed 的任务最多重试两次两次都失败就把错误信息写进output/failed_log.csv。import time from datetime import datetime def process_urls(url_list, output_csv, failed_csv): all_results [] failed_results [] for idx, url in enumerate(url_list, 1): print(f[{idx}/{len(url_list)}] 处理: {url}) try: text load_text_from_url(url) phones extract_phones(text) cleaned [clean_phone(p[phone]) for p in phones] seen set() for p, c in zip(phones, cleaned): if c in seen or not is_valid_phone(c): continue seen.add(c) all_results.append({ url: url, type: p[type], phone: c, raw: p[raw] }) if not phones: failed_results.append({url: url, status: empty}) except Exception as e: failed_results.append({url: url, status: failed, error: str(e)}) time.sleep(1) export_csv(all_results, output_csv) export_failed(failed_results, failed_csv)时间戳记录建议精确到秒。这样批量任务跑完后你能知道每个页面是几点抓的也方便对齐日志。5.3 增量更新避免每次重复抓全部页面如果这套脚本要每周跑一次每次全量抓所有页面其实很浪费。更稳妥的做法是保存页面内容的哈希值。页面没变就不重新提取页面变了才重新跑提取逻辑。import hashlib import os import json def hash_text(text): return hashlib.md5(text.encode(utf-8, ignore)).hexdigest() def load_hash_map(filepath): if not os.path.exists(filepath): return {} with open(filepath, r, encodingutf-8) as f: return json.load(f) def save_hash_map(hash_map, filepath): with open(filepath, w, encodingutf-8) as f: json.dump(hash_map, f, ensure_asciiFalse, indent2)每次处理 URL 后把 URL 和页面哈希存入output/hash_map.json。下次看到相同哈希直接跳过。这个方案对“联系方式变更上报”特别有用因为只有哈希变化时才需要重新提取和比对。增量日志也不需要复杂三列就够了URL、哈希、最后处理时间。简单、直观、好排查。5.4 批量场景下的资源占用批量任务看起来只是一条条请求实际资源占用仍然要关注。如果一次性读入几千个 URL然后每个 URL 的完整网页都存进内存内存占用会很夸张。我建议边读清单边处理或者批量读入但及时把结果写入文件。磁盘方面原文片段字段不要存太长。我截取号码前后各 20 个字符已经能提供足够上下文。如果把整个页面原文存进 CSV文件会膨胀得非常快。日志是批量任务最容易忽略的部分。没有日志任务跑挂了不知道停在哪一条。加一个简单的控制台输出和文件日志成本很低排查时能少花很多时间。6. 常见报错与排查顺序这套脚本最容易出现的报错其实不是正则写错而是环境、编码、请求策略和输入格式的问题。下面的排查顺序我自己踩过不少次按这个顺序查效率最高。6.1 结果为空不要先怀疑正则先确认页面内容是否真的能被 requests 正常读取。很多网页的号码不是写在 HTML 源文件里而是由 JavaScript 动态加载的。你直接用 requests 抓下来只会得到空壳 HTML。遇到这种情况先看抓取下来的文本前几百个字符。如果确认是正常内容再看电话号码在页面里的展示方式。如果号码被拆成多个 span 标签正则自然匹配不到。我验证一个新页面是否值得写提取规则时会先打印load_text_from_url的结果人工确认号码在纯文本里长什么样再决定正则怎么写。6.2 号码提取出来但不完整常见原因有三个号码中间有空格或标签正则匹配到一半就断了。区号和号码之间不是标准分隔符。分机号码和主号码之间没有明显标识。处理办法是先做文本预处理把号码区域内的空格、不可见字符、全角符号统一替换成半角。但要注意预处理会改动原文所以原文片段字段要保留处理前的内容。6.3 中文乱码抓取网页乱码优先检查编码。如果apparent_encoding推断错误可以手动指定编码。常见中文页面一般不是utf-8就是gbk。本地文件乱码则检查保存时的编码格式。导出 CSV 乱码优先看写入时是不是utf-8-sig。如果已经用了还乱码打开方式可能有问题建议用 Excel 的“数据-从文本/CSV 导入”方式打开并手动指定字符集。6.4 请求频繁失败先看报错类型。如果是连接超时排查网络和目标站点响应速度。如果是返回 403、429说明请求频率或请求头被识别了。这时候应该降低频率、设置合理的 User-Agent并检查目标站点是否允许这种访问方式。不要试图做任何绕过限制的操作。合规的做法是控制频率、只访问允许的公开页面、对不支持的站点停止抓取。这个项目是用来提高信息整理效率的不是用来对抗站点规则的。6.5 重复数据清理不到位重复数据分两种同一个页面内重复多个页面之间重复。页面内重复靠 set 去重。多页面重复要区分业务需求如果只是想建一张联系方式表可以按号码去重来源 URL 合并如果想追踪联系方式在哪些页面出现就不要去重保留全部来源。现象优先检查点常见原因结果为空抓取文本是否包含号码页面动态加载、号码被拆分号码不完整正则规则和文本预处理分隔符过多、原文格式特殊CSV 乱码写入编码和打开方式没有使用 utf-8-sig请求经常失败超时、请求头、请求频率频率过高、被限制访问重复量过大去重逻辑页面内展示多次、未合并多页面来源7. 最后留下几条实测建议第一先做样例矩阵不要直接全量跑。找几个格式明显不同的页面比如带区号座机的页面、带分机的页面、只有手机号的页面各放一个样本。跑完样例没问题再处理真实清单。第二批次结果要留底稿。我每次跑完整理任务都会把 CSV 复制一份到带日期的目录保留原始结果。后续哪怕改坏了逻辑也有旧数据可以回退。这个习惯在数据清理类脚本里特别重要。第三不要把“提取到号码”等同于“号码可用”。正则只能保证格式像电话不能保证号码还在用。上线前最好人工抽检几条尤其是高风险场景。公司内部巡检时我一般会在结果里加一列“归属页面标题”方便后续快速判断上下文。第四脚本改进要小步走。每次只改一个规则跑样例看差异。不要一次性加一堆正则、清洗规则、并发逻辑出了问题很难定位。这个项目最好的状态是你隔一个月回来还能看懂自己当时为什么这样写。最后说一句这类脚本真正落地时最该盯住的不是功能列表而是输入格式、资源占用和失败重试。把这些基础问题处理好“船长的电话号码”就能从一句玩笑话变成一张真正能用的联系方式清单。
返回列表