先划边界
爬虫逆向最先要做的不是打开开发者工具,而是划清边界。哪些数据是公开可访问的?哪些内容需要登录、付费或授权?有没有 robots、平台规则或接口使用条款?这些问题如果不先想清楚,后面写得越顺,风险越大。
我更愿意把这类工具定位成个人学习、公开信息整理和自己授权内容的解析。不要做登录绕过、验证码绕过、批量压测,也不要把别人的私有数据当成练手素材。
从浏览器行为开始
真正的请求通常藏在页面加载、跳转、异步接口和内联状态里。先观察浏览器怎么打开页面,再看网络面板里的请求顺序、状态码、重定向、响应类型和关键字段。
很多时候,不需要一上来就模拟复杂环境。先把 URL 归一化,跟随短链跳转,拿到最终页面,再判断页面里有没有结构化 JSON、meta 信息或可直接抽取的文本。
在 Shark-Tools 里我后来会把「请求」和「解析」拆开。下面这段是整理后的安全骨架,保留了超时、域名白名单和可解释错误,去掉了真实站点、cookie 和私有参数:
from urllib.parse import urlparse, urljoin
import requests
ALLOWED_HOSTS = {"example.com", "www.example.com"}
def assert_public_url(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in {"http", "https"}:
raise ValueError("只接受 http/https 链接")
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("该域名不在允许列表里")
return url
def fetch_page(session: requests.Session, url: str) -> str:
safe_url = assert_public_url(url)
response = session.get(
safe_url,
headers={"User-Agent": "SharkTools-Lab/1.0"},
timeout=15,
)
response.raise_for_status()
response.encoding = response.apparent_encoding or "utf-8"
return response.text
def resolve_asset(base_url: str, maybe_relative: str) -> str:
# 页面里拿到的封面、m3u8、下一页链接经常是相对路径。
return maybe_relative if maybe_relative.startswith("http") else urljoin(base_url, maybe_relative)
这个骨架的重点不是“模拟得多像浏览器”,而是先把信任边界写进代码。真实项目里不要把用户输入的任意 URL 直接交给请求库,否则 SSRF、内网探测和奇怪协议都会变成隐患。
抽取逻辑要可降级
爬虫最怕把所有希望压在一个选择器上。页面改一个 class,工具就全部失效。更稳的方式是准备多层解析:优先读结构化数据,其次读页面状态,再退回 meta 标签或正文抽取。
每一层失败都应该返回清楚的错误码和原因,而不是只抛一个模糊异常。这样 Agent 调用工具时,才能知道是链接无效、平台改版、网络失败,还是内容确实不可访问。
我会尽量让解析函数返回稳定结构,而不是直接把页面里的原始字段向上传。比如搜索类结果可以先落成一个数据类:
from dataclasses import dataclass
@dataclass
class VideoItem:
title: str
cover_url: str
video_url: str
author: str
duration: str
publish_time: str
play_count: str
description: str = ""
source_id: str = ""
这样上层界面、Agent 工具或素材库都只依赖 VideoItem,底层平台字段怎么变,影响范围会小很多。
速率和缓存
稳定不等于高频请求。个人工具也应该有速率限制、超时、重试上限和缓存。尤其是热榜、网页摘要、视频元信息这类内容,不需要每次打开都重新请求。
如果工具要接进 Agent,就更需要防止模型反复调用同一个接口。后端应该能记录工具调用、限制频率,并把相同输入的结果复用起来。
Shark-Tools 里有些早期脚本把重试次数写得很激进,后来复盘我觉得应该收敛成「少量重试 + 明确失败」:
import time
def retry_get(session, url, *, attempts=3, delay=1.2):
last_error = None
for index in range(attempts):
try:
response = session.get(url, timeout=15)
if response.status_code in {401, 403}:
raise PermissionError("访问被拒绝,停止重试")
response.raise_for_status()
return response
except Exception as exc:
last_error = exc
if index < attempts - 1:
time.sleep(delay * (index + 1))
raise RuntimeError(f"请求失败:{last_error}")
失败并不可怕,真正可怕的是无限重试、无提示重试和把访问被拒绝误判成网络抖动。
最后是可维护
逆向代码最容易变成一团临时补丁,所以我会尽量把「请求」「解析」「规范化输出」「错误处理」拆开。外部平台会变,代码也会坏;能快速定位坏在哪里,比一次写得很巧更重要。
一套好的爬虫逆向方法论,不是追求绕得多深,而是追求边界清楚、失败可见、维护成本低。