说明:本文讨论的是对已公开页面文本的本地解析与结构化整理。示例代码只读取本地保存的 HTML/TXT 文件,不包含网络请求、登录绕过、验证码识别或风控对抗逻辑。
提取公开页面中的联系方式,最常见的做法是直接写一个脚本去请求页面。但在实际工程中,这种方式会带来几个不好处理的问题:
第一,页面结构随时会变。今天能正常解析的 DOM 路径,明天可能就被前端重构掉了,脚本跟着一起失效。
第二,动态渲染的内容拿不到。很多现代电商页面关键信息由 JavaScript 异步加载,requests.get() 返回的 HTML 里根本没有电话字段。
第三,也是最容易被忽略的一点:直接请求的页面可能不是“公开可见”的那一份。登录态、地域、设备类型都可能影响返回内容。
相比之下,“手动保存公开页面 → 本地解析”的流程虽然多了一步操作,但换来的是数据来源确定、可复核、可回溯。保存下来的 HTML 就是那一刻的公开快照,出了问题可以回查,也不会对平台造成任何请求压力。本文的代码完全基于本地文件,不涉及任何网络请求模块。
在动手写正则之前,先要搞清楚联系方式可能出现在哪些位置。常见的至少有四类:
正文文本。最常见的形式,比如“联系电话:138xxxx”或“客服电话 010-xxxxxxxx”。
tel: 超链接。<a href="tel:13800138000"> 这种写法在移动端页面中非常普遍,号码可能不出现在链接文字里,只藏在 href 属性中。
JSON-LD 结构化数据。很多电商页面会在 <script type="application/ld+json"> 中嵌入 Schema.org 的 Organization 或 LocalBusiness 对象,其中 telephone 字段就是结构化的电话号码。
内联 JSON 状态。一些 SPA 页面会把初始状态序列化到 <script> 标签中,电话字段可能以 "tel" 或 "phone" 作为 key 出现。
只盯着正文文本做正则,会漏掉后三类。下面代码的核心改进就是多源召回。
依赖只有 beautifulsoup4:
pip install beautifulsoup4import csv
import json
import os
import re
from dataclasses import dataclass, field
from typing import List, Set
from bs4 import BeautifulSoup
INPUT_DIR = "input"
OUTPUT_CSV = "output/contacts.csv"
# 宽松召回:手机、400、常见座机
PHONE_CANDIDATE = re.compile(
r"(?:(?:\+|00)86[\s-]?)?"
r"(?:1[3-9]\d[\s-]?\d{4}[\s-]?\d{4}"
r"|400[\s-]?\d{3}[\s-]?\d{4}"
r"|0\d{2,3}[\s-]?\d{7,8})"
)
CONTEXT_WORDS = ["联系", "电话", "客服", "咨询", "contact", "tel", "phone"]
@dataclass
class Contact:
phone: str
source: str
channel: str # 来源类型:text / tel_link / json_ld / inline_json
context: str = ""
score: int = 0
def normalize(raw: str) -> str:
s = re.sub(r"[\s\-()]", "", raw)
for prefix in ("+86", "0086"):
if s.startswith(prefix):
s = s[len(prefix):]
break
return s
def is_plausible(phone: str) -> bool:
if re.fullmatch(r"1[3-9]\d{9}", phone):
return phone[1] not in "012"
if re.fullmatch(r"400\d{7}", phone):
return True
if re.fullmatch(r"0\d{9,11}", phone):
return True
return False
def extract_from_text(text: str) -> Set[str]:
return {m.group() for m in PHONE_CANDIDATE.finditer(text)}
def extract_from_tel_links(soup: BeautifulSoup) -> Set[str]:
result = set()
for a in soup.find_all("a", href=True):
href = a["href"].strip()
if href.lower().startswith("tel:"):
result.add(href[4:].strip())
return result
def extract_from_json_ld(soup: BeautifulSoup) -> Set[str]:
result = set()
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string or "")
except (json.JSONDecodeError, TypeError):
continue
stack = [data]
while stack:
node = stack.pop()
if isinstance(node, dict):
for key, value in node.items():
if key.lower() in ("telephone", "phone", "tel") and isinstance(value, str):
result.add(value)
if isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return result
def extract_from_inline_json(soup: BeautifulSoup) -> Set[str]:
"""从 script 标签中扫描内联 JSON 的 tel/phone 字段"""
result = set()
for script in soup.find_all("script"):
if script.get("type") in ("application/ld+json",):
continue
raw = script.string or ""
if not raw.strip().startswith(("{", "[")):
continue
try:
data = json.loads(raw)
except json.JSONDecodeError:
continue
stack = [data]
while stack:
node = stack.pop()
if isinstance(node, dict):
for key, value in node.items():
if key.lower() in ("tel", "phone", "telephone") and isinstance(value, str):
if PHONE_CANDIDATE.search(value):
result.add(value)
if isinstance(value, (dict, list)):
stack.append(value)
elif isinstance(node, list):
stack.extend(node)
return result
def build_context(text: str, phone: str, window: int = 40) -> str:
idx = text.find(phone)
if idx == -1:
return ""
return text[max(0, idx - window): idx + len(phone) + window]
def score_contact(phone: str, context: str) -> int:
score = 0
ctx_lower = context.lower()
score += sum(1 for w in CONTEXT_WORDS if w in ctx_lower)
if re.fullmatch(r"1[3-9]\d{9}", phone):
score += 2
if phone.startswith("400"):
score += 1
return score
def process_file(path: str, source_name: str) -> List[Contact]:
with open(path, "r", encoding="utf-8", errors="ignore") as f:
html = f.read()
soup = BeautifulSoup(html, "html.parser")
text = soup.get_text(separator=" ", strip=True)
raw_map = {}
def add(phones: Set[str], channel: str):
for p in phones:
n = normalize(p)
if n and is_plausible(n):
raw_map.setdefault(n, channel)
add(extract_from_text(text), "text")
add(extract_from_tel_links(soup), "tel_link")
add(extract_from_json_ld(soup), "json_ld")
add(extract_from_inline_json(soup), "inline_json")
contacts = []
for phone, channel in raw_map.items():
context = build_context(text, phone)
contacts.append(Contact(
phone=phone,
source=source_name,
channel=channel,
context=context,
score=score_contact(phone, context),
))
contacts.sort(key=lambda c: (-c.score, c.phone))
return contacts
def main():
if not os.path.isdir(INPUT_DIR):
print(f"[提示] 请创建 {INPUT_DIR}/ 并放入本地保存的公开页面 HTML 文件")
return
all_contacts = []
for root, _, files in os.walk(INPUT_DIR):
for name in files:
if not name.lower().endswith((".html", ".htm")):
continue
path = os.path.join(root, name)
try:
all_contacts.extend(process_file(path, source_name=name))
except Exception as e:
print(f"[跳过] {name}:{e}")
# 按“电话 + 来源”去重,保留首次出现
seen = set()
unique = []
for c in all_contacts:
key = (c.phone, c.source)
if key not in seen:
seen.add(key)
unique.append(c)
os.makedirs(os.path.dirname(OUTPUT_CSV), exist_ok=True)
with open(OUTPUT_CSV, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["电话", "来源文件", "来源通道", "上下文得分", "上下文片段"])
for c in unique:
writer.writerow([c.phone, c.source, c.channel, c.score, c.context])
print(f"[完成] 有效记录 {len(unique)} 条 → {OUTPUT_CSV}")
if __name__ == "__main__":
main()1. 多通道召回,单通道失效不影响整体。 extract_from_text、extract_from_tel_links、extract_from_json_ld、extract_from_inline_json 四个函数各自独立,结果取并集。如果某类页面把电话只放在 JSON-LD 里,正文正则什么都拿不到,但 JSON-LD 通道仍然有效。
2. 宽松召回 + 严格校验分开。 正则负责“宁可多捞”,is_plausible 负责“把明显不对的扔掉”。手机号第二位不能是 0/1/2 这条规则,能过滤掉大量像手机号的订单号、时间戳和编码。-11
3. 来源通道单独记录。 导出结果里有 来源通道 一列。如果一条记录来自 json_ld,说明它是页面结构化数据中明确声明的电话,可信度天然高于从正文里正则碰出来的号码。人工复核时按这一列排序,效率会高很多。
4. 上下文得分只做粗排序,不做自动决策。 score_contact 根据附近是否出现“联系/电话/客服”等词打分,手机号额外加权。它的作用是让更可能是目标号码的记录排在前面,而不是自动断定“这就是对的”。最终仍需人工判断。
input/ 目录下的本地文件,不包含任何 HTTP 请求、登录、验证码或风控绕过逻辑。从公开页面中解析联系方式,难点不在正则本身,而在于数据来源的确定性和多通道召回。把“保存公开页面”作为流程的第一步,把正文、tel: 链接、JSON-LD、内联 JSON 当作并列的召回来源,再配合清洗、校验和上下文排序,最终产出的结果会比单靠一条正则可靠得多。这套流程不依赖任何网络请求模块,适合在本地小规模、可复核的场景下使用。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。