1. 从日志和聊天记录里批量捞 URL,为什么正则总漏抓
先说场景。你手上有一份 20MB 的客服工单导出文件,或者一段几千行的 Nginx access log,里面混着中文、英文、JSON、HTML 片段,URL 有的带 query 参数,有的被括号包着,有的末尾跟着中文标点。你想把所有 URL 抽出来做去重、统计域名分布、或者喂给下游做可用性探测。这时候最直觉的做法是写个正则,但跑完一看:漏了一批带&转义的,误抓了一批example.com/path这种没协议的,还有把https://a.com/x).里的右括号和句号一起吞进去的。
我试过在工单系统里直接上https?://\S+,结果惨不忍睹。\S+会把 URL 后面紧跟的中文、引号、右括号全部吃进来,因为中文和标点在正则的\S眼里都算非空白字符。更麻烦的是混合文本里 URL 的边界根本不统一:Markdown 里是[文字](https://x.com/a),HTML 里是href="https://x.com/a",纯文本里可能是访问 https://x.com/a 查看,日志里可能是GET https://x.com/a?b=1&c=2 HTTP/1.1。
所以「从文本中获取 url 地址」这件事,核心不是写一个万能正则,而是分三层处理:先做文本归一化(处理 HTML 实体、全角符号),再用一个边界收敛的正则做初筛,最后用urllib.parse做结构校验和清洗。这套链路我把它叫做「归一化 → 正则初筛 → 解析校验」,比单条正则稳得多。
那 TaoToken 在这里扮演什么角色?当你需要把提取出来的 URL 批量做语义判断(比如判断这个链接是文档、是下载、还是无关广告),或者让模型帮你从非结构化文本里直接输出结构化 URL 列表时,你需要一个稳定的模型调用入口。TaoToken 提供统一 Key,一个 Key 就能调不同模型,省得在脚本里维护多套鉴权。下面我会先给提取脚本,再给 TaoToken 的配置片段,最后用样例文本验证漏抓和误抓。
这一节先把问题拆清楚:漏抓主要来自三个地方——协议缺失(www.x.com没有http)、HTML 实体转义(&而不是&)、以及 URL 被包裹在 Markdown 或 HTML 标签里。误抓主要来自两个地方——把邮箱a@b.com当成 URL、把文件路径/usr/local/bin当成 URL。搞清楚这些,后面的代码才有针对性。
2. TaoToken 统一 Key 的前置准备与配置片段
在写提取脚本之前,先把模型调用这条链路配好。因为实际工程里,纯正则搞不定的边界情况(比如判断一段文本里哪些是「有效可访问的 URL」),交给模型做二次筛选会省很多事。TaoToken 的接入方式很直接:拿一个统一 Key,改 Base URL,指定 Model ID,三件套齐活。
先到官网注册并创建 Key,地址是https://taotoken.net/api-keys(deep link 带归因参数:?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite)。创建完你会拿到一串sk-开头的 Key。注意这个 Key 是统一入口,不管你后面调哪个模型,鉴权都用它。
接下来是配置。如果你用 Python 的openaiSDK,配置片段长这样:
from openai import OpenAI client = OpenAI( api_key="sk-你的TaoTokenKey", base_url="https://taotoken.net/api" ) resp = client.chat.completions.create( model="claude-sonnet-4-5", messages=[ {"role": "user", "content": "从下面文本里提取所有URL,每行一个,不要解释:\n" + sample_text} ] ) print(resp.choices[0].message.content)如果你用 Claude Code 这类命令行工具,配置走的是环境变量或 settings 文件。Claude Code 的 settings 一般放在~/.claude/settings.json,写入:
{ "env": { "ANTHROPIC_BASE_URL": "https://taotoken.net/api", "ANTHROPIC_API_KEY": "sk-你的TaoTokenKey", "ANTHROPIC_MODEL": "claude-sonnet-4-5" } }这里三件套必须齐全:Base URL 指向https://taotoken.net/api,Key 用你创建的,Model ID 写具体模型名。少任何一个都会报鉴权或模型不存在的错。如果你用 Cline 或 Roo Code 这类 VS Code 插件,在插件设置里填的也是这三项,Base URL 填https://taotoken.net/api,API Key 填sk-那串,Model 选对应模型。
对于 Codex 用户,配置在~/.codex/auth.json和~/.codex/config.toml。auth.json里放 Key:
{ "OPENAI_API_KEY": "sk-你的TaoTokenKey" }config.toml里指定 Base URL 和模型:
model = "claude-sonnet-4-5" model_provider = "taotoken" [model_providers.taotoken] name = "TaoToken" base_url = "https://taotoken.net/api" wire_api = "chat"配好之后,你可以先用一个最小请求验证链路通不通。跑上面那段 Python,如果返回了模型输出,说明 Key、Base URL、Model 三项都对。如果报 401,多半是 Key 写错或没带sk-前缀;如果报 model not found,检查 Model ID 拼写。这一步验证通过,再往下做 URL 提取的完整链路。
3. 可复制的正则与 Python 提取脚本(含边界处理)
现在进入正题。我给的脚本分四步:读文本、归一化、正则初筛、解析校验。先看完整代码,再逐段解释。
import re import html from urllib.parse import urlparse, urlunparse, parse_qsl, urlencode # 第一步:归一化,处理 HTML 实体和全角符号 def normalize_text(text: str) -> str: text = html.unescape(text) # & -> &,< -> < text = text.replace(":", ":") # 全角冒号 text = text.replace("/", "/") # 全角斜杠 text = text.replace("?", "?") # 全角问号 text = text.replace("=", "=") # 全角等号 text = text.replace("&", "&") # 全角 and return text # 第二步:正则初筛,边界收敛 URL_PATTERN = re.compile( r'(?<![\w@.])' # 前面不能是字母数字@或点,避免匹配邮箱和路径 r'((?:https?://|www\.)[^\s<>"\'\)\]\},。;、]+)' # 主体,排除空白和常见包裹符 r'(?<![.,;:!?])' # 末尾不能是标点 ) def extract_urls(text: str): text = normalize_text(text) candidates = [] for m in URL_PATTERN.finditer(text): raw = m.group(1) # 补全协议 if raw.startswith("www."): raw = "http://" + raw candidates.append(raw) return candidates # 第三步:解析校验与清洗 def clean_urls(urls): seen = set() result = [] for u in urls: try: p = urlparse(u) except ValueError: continue if not p.netloc or "." not in p.netloc: continue # 去掉 fragment 里的追踪参数可选,这里保留原样 # 统一小写 scheme 和 host scheme = p.scheme.lower() netloc = p.netloc.lower() # query 参数排序,便于去重 q = urlencode(sorted(parse_qsl(p.query, keep_blank_values=True))) cleaned = urlunparse((scheme, netloc, p.path, p.params, q, "")) if cleaned not in seen: seen.add(cleaned) result.append(cleaned) return result # 第四步:串起来 def get_urls_from_text(text: str): return clean_urls(extract_urls(text)) if __name__ == "__main__": sample = """ 工单内容:用户反馈访问 https://taotoken.net/api?b=2&a=1 时超时, 另一个链接是 www.example.com/docs,还有邮箱 test@example.com 不要抓。 HTML 片段:<a href="https://taotoken.net/doc&x=1">文档</a> 日志:GET /usr/local/bin HTTP/1.1 这是路径不是URL。 末尾带标点:请看 https://taotoken.net/console。 """ for u in get_urls_from_text(sample): print(u)跑这段代码,输出会是:
https://taotoken.net/api?a=1&b=2 http://www.example.com/docs https://taotoken.net/doc&x=1 https://taotoken.net/console注意几个细节。第一,(?<![\w@.])这个负向后顾,保证 URL 前面不是字母、数字、@或点,这样test@example.com里的example.com不会被单独抓出来,/usr/local/bin也不会因为前面是/而被误判(因为/不在排除集里,但bin前面是/,整体不匹配https?://或www.,所以安全)。第二,末尾的(?<![.,;:!?])负向后顾,防止把句号、逗号吞进 URL。第三,html.unescape处理了&,所以 HTML 片段里的链接能正确还原。第四,urlparse校验netloc必须含点,过滤掉http://localhost这种内网地址(如果你需要保留内网,把"." not in p.netloc这个判断去掉)。
query 参数排序是为了去重。?b=2&a=1和?a=1&b=2在语义上一样,排序后能合并成一条。这个在处理日志时特别有用,因为不同请求的参数顺序可能不同。
如果你要处理超大文件,别一次性read()进来,用逐行读加缓冲:
def extract_from_file(path, chunk_size=1024*1024): buf = "" with open(path, "r", encoding="utf-8", errors="ignore") as f: while True: chunk = f.read(chunk_size) if not chunk: break buf += chunk # 按行切,最后一行可能不完整,留到下一轮 lines = buf.split("\n") buf = lines.pop() for line in lines: yield from get_urls_from_text(line) if buf: yield from get_urls_from_text(buf)这样内存占用稳定,20MB 的工单文件也能跑。errors="ignore"防止编码问题中断。
4. 用样例文本验证漏抓与误抓,并接入模型做二次筛选
光跑脚本还不够,得用「刁钻」样例验证。我准备了三类文本:正常文本、HTML 转义文本、边界混乱文本。下面是对照结果。
| 样例文本 | 期望结果 | 脚本实际输出 | 是否通过 |
|---|---|---|---|
访问 https://a.com/x 查看 | https://a.com/x | https://a.com/x | 通过 |
href="https://b.com/y&z=1" | https://b.com/y&z=1 | https://b.com/y&z=1 | 通过 |
邮箱 a@b.com 不是URL | 空 | 空 | 通过 |
路径 /usr/local/bin | 空 | 空 | 通过 |
https://c.com/p). | https://c.com/p | https://c.com/p | 通过 |
www.d.com/e | http://www.d.com/e | http://www.d.com/e | 通过 |
漏抓的典型场景是 URL 里带中文参数,比如https://a.com/搜索?q=测试。我的正则里[^\s<>"\'\)\]\},。;、]+排除了中文逗号句号,但没排除中文字符本身,所以中文参数能保留。但如果你把中文逗号也当分隔符,就会截断。这个取舍看你的数据源:如果 URL 里可能出现中文,就别排除中文标点;如果中文标点只出现在 URL 后面,就排除。我建议先不排除中文,跑一遍看误抓率,再决定。
误抓的典型场景是http://后面跟了空格再跟内容,比如http:// a.com。这种正则不会匹配,因为https?://后面要求非空白字符。还有https://单独出现,后面没内容,也不会匹配。
现在说模型二次筛选。有些场景正则搞不定,比如判断「这个 URL 是不是广告追踪链接」。你可以把初筛结果丢给模型:
def filter_by_model(urls, client): prompt = "下面是一批URL,请只保留看起来是正常文档或官网的,去掉明显的广告追踪和短链,每行一个:\n" + "\n".join(urls) resp = client.chat.completions.create( model="claude-sonnet-4-5", messages=[{"role": "user", "content": prompt}] ) return resp.choices[0].message.content.strip().split("\n")这里用的就是第 2 节配好的 TaoToken 客户端。一个 Key 搞定,不用为不同模型改鉴权。如果你要长期跑批量任务,可以考虑 Coding Plan,额度更划算,适合这种反复调用的场景。
验证请求是否成功,看返回的choices[0].message.content有没有内容。如果报reading choices相关错误,多半是返回体结构不对,检查 SDK 版本和 Base URL 是否匹配。如果报local proxy failed,检查你的网络环境是否能直连taotoken.net,以及有没有配错代理。如果报 OAuth 相关错误,说明你用的是需要 OAuth 的工具但没走对鉴权流程,回到第 2 节检查三件套。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节把实际会撞到的报错列清楚,每个给排查路径。
401 Unauthorized。最常见。原因有三个:Key 没带sk-前缀、Key 复制时多了空格、Key 已失效。排查方法:把 Key 打印出来看长度和前缀,用curl直接测:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}'如果 curl 通而 Python 不通,检查 SDK 的base_url是不是写成了https://taotoken.net/api/v1(有些 SDK 会自动补/v1,有些不会,写https://taotoken.net/api即可)。
local proxy failed。这个报错通常出现在你本地配了代理但代理没启动,或者代理规则把taotoken.net也拦了。排查:先ping taotoken.net看通不通,再检查环境变量HTTP_PROXY、HTTPS_PROXY有没有设成失效地址。如果你在 CI 环境跑,检查 runner 的网络策略。
reading choices 相关错误。典型报错是KeyError: 'choices'或AttributeError: 'NoneType' object has no attribute 'choices'。原因是返回体不是预期的 chat completion 结构。可能你调的是流式接口但没处理 SSE,或者 Base URL 指向了错误的端点。排查:打印完整resp看结构,确认model参数拼写正确,确认没有把base_url写成https://taotoken.net/api/v1/chat/completions(应该只写到/api)。
OAuth 相关错误。如果你用 Claude Code 或 Codex 这类工具,它们可能默认走 OAuth 登录流程。报错通常是OAuth token expired或invalid_grant。解决:不要走 OAuth,改用 API Key 模式。Claude Code 在settings.json里设ANTHROPIC_API_KEY,Codex 在auth.json里设OPENAI_API_KEY,都填 TaoToken 的 Key。这样就走 Key 鉴权,不碰 OAuth。
还有一个容易忽略的:模型 ID 写错。比如写成claude-sonnet而不是claude-sonnet-4-5,会报 model not found。排查时把 Model ID 单独打印出来核对。三件套里 Model ID 是最容易拼错的,建议从文档里复制。
6. 把提取链路接进你的工程:从脚本到可复用模块
最后说怎么把这套东西工程化。别把提取逻辑散在业务代码里,封装成一个模块,输入文本输出 URL 列表,内部做归一化、初筛、校验、可选模型筛选。
class UrlExtractor: def __init__(self, use_model=False, client=None): self.use_model = use_model self.client = client def extract(self, text): urls = get_urls_from_text(text) if self.use_model and self.client: urls = filter_by_model(urls, self.client) return urls用的时候:
extractor = UrlExtractor(use_model=True, client=client) urls = extractor.extract(open("tickets.txt").read())如果你要处理的是 HTML 文件,别用正则硬解,用BeautifulSoup或lxml先抽href和src属性,再对属性值跑上面的清洗逻辑。正则适合纯文本和日志,HTML 解析适合结构化文档,两者结合最稳。
对于日志场景,Nginx 的$request字段里 URL 是带 query 的,直接按空格切分取第二个字段就行,比正则快。但如果是应用日志里混着 JSON,就得先json.loads再取字段。所以「从文本中获取 url 地址」没有银弹,先看数据源结构,再选解析策略。
TaoToken 在这条链路里的价值是统一了模型调用的鉴权。你可以在提取模块里加一个可选的「语义去重」步骤:把 URL 列表丢给模型,让它合并指向同一资源的链接(比如https://a.com/doc和https://a.com/doc/)。这个用规则也能做,但模型处理边界更省心。需要长期跑这类任务的,可以看下 Coding Plan,按量用比单次调用划算。
最后给个实用技巧:提取出来的 URL 先别急着请求,先做一轮urlparse校验和去重,再按域名分组。很多时候你真正关心的是域名分布,而不是每个完整 URL。分组之后,对每个域名抽一两个样本做可用性探测就够了,省得把整个列表都打一遍。这套流程我在工单分析里跑过,20MB 文本提取加清洗大概几秒,比人工翻快太多。