首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >抖音抖店卖家采集器 抖音精选联盟商家电话提取工具教程详解

抖音抖店卖家采集器 抖音精选联盟商家电话提取工具教程详解

原创
作者头像
vx-taobao01221
发布于 2026-10-26 10:24:21
发布于 2026-10-26 10:24:21
2030
举报

从公开页面文本中解析商家联系方式的工程实践

说明:本文讨论的是对已公开页面文本的本地解析与结构化整理。示例代码只读取本地保存的 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:

代码语言:javascript
复制
pip install beautifulsoup4
代码语言:javascript
复制
import 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 请求、登录、验证码或风控绕过逻辑。
  • 如果页面本身需要登录才能看到联系方式,说明它不属于公开数据,不应通过技术手段获取。
  • 即使联系方式在页面上公开,它仍然属于个人信息。导出的 CSV 应控制访问权限。
  • 使用场景应限于市场调研、信息核对等正当用途,不得用于电话营销骚扰。

六、小结

从公开页面中解析联系方式,难点不在正则本身,而在于数据来源的确定性和多通道召回。把“保存公开页面”作为流程的第一步,把正文、tel: 链接、JSON-LD、内联 JSON 当作并列的召回来源,再配合清洗、校验和上下文排序,最终产出的结果会比单靠一条正则可靠得多。这套流程不依赖任何网络请求模块,适合在本地小规模、可复核的场景下使用。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 从公开页面文本中解析商家联系方式的工程实践
    • 一、为什么建议“先保存、再解析”
    • 二、页面里的电话可能藏在哪里
    • 三、代码实现:多源召回 + 清洗 + 导出
    • 四、几个关键设计点
    • 五、合规边界
    • 六、小结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档