说明:本文讨论的是在商家主动完成抖店开放平台 OAuth2.0 授权后,对授权范围内的店铺公开信息进行增量同步、字段清洗与幂等导出。示例代码不包含网页爬虫、模拟登录、验证码识别或风控对抗逻辑,仅使用本地模拟的 API 响应演示工程实现。抖店开放平台的授权码 code 有效期 10 分钟,access_token 默认有效期 7 天,refresh_token 有效期 14 天,这些凭证的时序管理直接决定了同步代码的写法。数据使用需遵守《个人信息保护法》及平台规则。
“精选联盟商家实时采集”这个说法里,“采集”和“实时”都需要重新定义,否则方案从一开始就会偏离可落地的路径。
“采集”不是解析页面 DOM。 精选联盟商家的联系方式,要么是商家在授权后通过官方接口返回的,要么根本没有可获取的字段。用 requests 或浏览器自动化去抓页面,面对的是 JavaScript 动态渲染、登录态差异和随时变化的前端结构,产出的数据既不可复核,也不具备持续运行的稳定性。
“实时”不是请求频率越高越好。 在授权链路下,“实时”的工程含义是:店铺信息发生变化后,你能以可接受的延迟感知并写入本地。这通常靠两条通道配合——消息推送作为触发器,接口轮询作为补偿。抖店开放平台的授权消息 msg_type=2 会通知授权的打开、关闭、过期和重新获取码等状态变化,这是增量同步最重要的信号来源。没有推送能力的场景下,合理的轮询间隔就是“实时”的实际精度。
因此,本文的重点不在“怎么拿到数据”,而在数据已经在本地之后,如何让每次运行都不重复、不遗漏、可回溯。这才是“采集器”里真正属于工程的部分。
抖店开放平台的 OAuth2.0 授权码模式有几个硬性时间约束,代码必须把它们作为一等公民来处理,而不是当作异常分支:
凭证 | 有效期 | 关键约束 |
|---|---|---|
授权码 code | 10 分钟 | 换取 access_token 成功后立即失效,不能缓存等待 |
access_token | 默认 7 天(expires_in) | 过期前 1 小时内刷新才会返回新凭证 |
refresh_token | 14 天 | 过期后需要商家重新授权 |
更细的一点:在 access_token 过期前 1 小时之内用 refresh_token 刷新,会返回新的 access_token 和 refresh_token;而在 1 小时之前刷新,返回的还是原来那一组,有效期也不会延长。这意味着“提前很久刷新”不仅没有收益,还可能打乱凭证节奏。合理的做法是临近过期窗口再刷新,而不是每次启动都无脑刷新。
下面的代码把凭证状态检查做成一个门卫函数:只有凭证被判定为可用时,同步循环才继续;不可用时直接停止,而不是带着失效凭证继续请求。
一次可重复运行的同步任务,至少包含四个环节:
检查凭证态 → 拉取增量数据(模拟) → 字段清洗与校验 → 幂等落盘 → 导出 CSV其中幂等是增量同步的生命线。增量拉取不可避免地会出现时间窗口重叠,如果没有幂等机制,同一家店铺的同一个号码会被反复写入。幂等键的选择很关键:只用 shop_id 不够(一个店铺可能公示多个号码),只用 phone 也不够(不同店铺可能共用 400 号码)。shop_id + 规范化后的 phone 是一个合理的组合。
另一个容易被忽略的点是字段缺失的降级。商家不一定公示电话,接口返回的联系字段可能为空字符串、可能包含多个号码、也可能根本不存在。代码必须把“没有电话”当作正常结果来统计,而不是当作异常中断整个任务。
依赖只有标准库:
import csv
import json
import os
import re
from dataclasses import dataclass, field
from typing import List, Set
STATE_FILE = "sync_state.json"
SEEN_FILE = "seen_phones.json"
OUTPUT_CSV = "output/selected_league_contacts.csv"
PHONE_RE = 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})"
)
@dataclass
class ShopPhone:
shop_id: str
shop_name: str
raw_tel: str
normalized_phones: List[str] = field(default_factory=list)
def normalize(raw: str) -> str:
s = re.sub(r"[\s\-()]", "", raw)
for p in ("+86", "0086"):
if s.startswith(p):
return s[len(p):]
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 clean_tel(raw) -> List[str]:
if not raw or not isinstance(raw, str):
return []
candidates = PHONE_RE.findall(raw)
if not candidates:
n = normalize(raw)
if n:
candidates = [n]
result, seen = [], set()
for c in candidates:
n = normalize(c)
if n and is_plausible(n) and n not in seen:
seen.add(n)
result.append(n)
return result
def load_state() -> dict:
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r", encoding="utf-8") as f:
return json.load(f)
return {"cursor": 0, "token_valid": True}
def save_state(state: dict):
with open(STATE_FILE, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
def ensure_token(state: dict) -> bool:
"""
模拟凭证门卫。
真实场景中:判断 expires_in 是否进入刷新窗口,
调用 refresh_token 接口;刷新失败则标记需重新授权。
"""
if not state.get("token_valid", False):
print("[停止] 凭证态无效,需刷新或重新授权")
return False
return True
def fetch_increment(cursor: int):
"""
本地模拟增量响应,替代真实接口调用。
真实场景中携带 access_token + shop_id 请求官方接口。
"""
demo_pages = {
0: [
{"shop_id": "S1001", "shop_name": "示例店铺A", "tel": "13800138000"},
{"shop_id": "S1002", "shop_name": "示例店铺B", "tel": ""},
{"shop_id": "S1003", "shop_name": "示例店铺C",
"tel": "400-123-4567;021-87654321"},
],
1: [
{"shop_id": "S1001", "shop_name": "示例店铺A", "tel": "13800138000"},
{"shop_id": "S1004", "shop_name": "示例店铺D", "tel": "0571-88886666"},
],
}
data = demo_pages.get(cursor, [])
next_cursor = cursor + 1 if cursor + 1 in demo_pages else cursor
return data, next_cursor
def load_seen() -> Set[str]:
if os.path.exists(SEEN_FILE):
with open(SEEN_FILE, "r", encoding="utf-8") as f:
return set(json.load(f))
return set()
def save_seen(seen: Set[str]):
with open(SEEN_FILE, "w", encoding="utf-8") as f:
json.dump(sorted(seen), f, ensure_ascii=False, indent=2)
def idempotent_append(records: List[ShopPhone], seen: Set[str]) -> List[ShopPhone]:
new_records = []
for r in records:
for phone in r.normalized_phones:
key = f"{r.shop_id}:{phone}"
if key not in seen:
seen.add(key)
new_records.append(ShopPhone(
shop_id=r.shop_id,
shop_name=r.shop_name,
raw_tel=r.raw_tel,
normalized_phones=[phone],
))
return new_records
def export_csv(records: List[ShopPhone], path: str):
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["shop_id", "店铺名称", "原始tel字段", "规范化电话", "幂等键"])
for r in records:
for phone in r.normalized_phones:
writer.writerow([r.shop_id, r.shop_name, r.raw_tel, phone,
f"{r.shop_id}:{phone}"])
print(f"[导出] {len(records)} 条新增记录 → {path}")
def main():
state = load_state()
if not ensure_token(state):
return
seen = load_seen()
cursor = state.get("cursor", 0)
raw_batch, next_cursor = fetch_increment(cursor)
parsed = []
for item in raw_batch:
phones = clean_tel(item.get("tel", ""))
parsed.append(ShopPhone(
shop_id=str(item.get("shop_id", "")),
shop_name=item.get("shop_name", ""),
raw_tel=item.get("tel", ""),
normalized_phones=phones,
))
new_records = idempotent_append(parsed, seen)
save_seen(seen)
state["cursor"] = next_cursor
save_state(state)
export_csv(new_records, OUTPUT_CSV)
no_phone = sum(1 for r in parsed if not r.normalized_phones)
print(f"[统计] 本批 {len(parsed)} 个店铺,{no_phone} 个无有效公开电话")
if __name__ == "__main__":
main()运行两次的差异会直接展示幂等的作用:第二次因为重叠窗口中 S1001 的 13800138000 已经存在于 seen 集合,新增记录数量会减少,sync_state.json 中的游标也会推进。这就是增量 + 幂等的最小闭环。
1. tel 字段必须当作“可能为空、可能多值、可能格式混乱”来处理。 clean_tel 对空值、非字符串、无正则命中、多号码拼接都做了分支。没有电话不是异常,而是正常统计的一部分。
2. 幂等键要落盘并出现在导出结果里。 seen_phones.json 是幂等状态的持久化载体,幂等键 列则是人工排查的依据。当数据量上来后,只看 CSV 无法判断某条记录是新增的还是被幂等过滤掉的。
3. 游标推进必须在幂等落盘之后。 顺序反过来,一旦导出环节失败,游标已经前进,那批数据就永久丢失了。
4. 凭证失效要有明确的停止信号。 ensure_token 返回 False 时主流程直接退出。刷新失败往往意味着商家撤销了授权或 refresh_token 已过期,继续重试没有意义。抖店开放平台的授权消息中 action_type=3 表示授权过期,可以作为触发重新授权的信号。-8
5. 先小批量验证再扩大。 把模拟数据换成真实接口后,先只处理一个授权店铺、一个时间窗,确认凭证刷新、字段清洗、幂等落盘三个环节都正常,再扩大范围。
“精选联盟商家实时采集器”的工程本质,不是把请求发得多快,而是在商家授权的前提下,把官方接口返回的店铺数据做增量感知、字段清洗和幂等落盘。OAuth2.0 的凭证时序管理是前提,幂等键的设计决定数据是否可信,字段缺失的降级处理决定任务能否跑完,游标与落盘的顺序决定中断后能否续跑。把这四件事做扎实,导出的 CSV 才是一份可以重复运行、可以回溯、不会随时间膨胀的数据资产,而不是一次性的请求产物。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。