面向 403 与域名频繁变更的合规爬虫工程实践以 Libvio 系站点为例

1. 真实约束:域名会变,且某些入口会 403

从其发布页可以看到它明确给出了多个"目前可用地址/备用地址",并提示记住发布页与邮箱获取最新地址。(Libvio)

这意味着:把域名写死在代码里,几乎必挂。你的爬虫第一层能力应该是"域名可替换"。

同时,自动化访问某些入口会直接返回 403(我们工具抓取 https://www.libvio.link/ 就是 403)。

合规做法不是去"对抗/绕过",而是把它当作"不可抓取域名",通过域名池切换到可用入口,或降低频率并只抓允许路径。

2. 信息架构拆解:列表页与详情页的 URL 形态很固定

在可访问的同系域名上(示例域名仅用于说明结构),页面 URL 形态非常规律:

  • 分类/列表页形如:/show/1-----------.html(示例为"最新电影-推荐电影"列表页)(libvio.lat)
  • 详情页形如:/detail/5812705.html(示例详情页)(libvio.lat)

详情页里,公开元信息通常集中在标题与一段"类型/地区/年份/上映"行、主演导演行、评分链接、最后更新与简介等字段,例如:

  • 标题与基础信息:类型:... / 地区:... / 年份:... / 上映:...(libvio.lat)
  • 评分含豆瓣链接、最后更新字段、简介字段(libvio.lat)

这对爬虫工程的意义是:你可以用"列表发现详情 URL → 详情抓取元信息"的增量链路,而不用全站扫描。

3. 合规门禁:robots.txt 不是授权,但你必须尊重

robots 的标准在 RFC 9309 中被正式规范,明确指出它是"请求性约束而非访问授权"。(RFC 編輯器)

同时,搜索引擎对 robots 获取失败/状态码也有明确处理策略(例如 4xx 如何缓存/停抓一段时间等),这对你设计自己的抓取门禁与缓存非常有参考价值。(Google for Developers)

工程上建议你做一个 RobotsGate

  • 启动时拉取 /<domain>/robots.txt,缓存到本地(带 TTL)
  • 对每个 URL 判断是否允许抓取
  • 不允许则直接跳过并记录审计日志

注意:robots 解析要按"域名+协议"维度缓存(不同域名、不同协议是不同的 robots 作用域)。

4. 目标收敛:只抓公开元信息,坚决不碰播放/下载链路

以示例详情页为例,它包含"立即播放""视频下载(夸克/百度)"等区域。(libvio.lat)

这些链路往往牵涉版权、也更容易触发站点保护与法律风险。最稳妥的实践是:

只落库这些字段:

  • canonical_path(例如 /detail/5812705.html
  • title
  • year / region / genres / release_date
  • actors / director
  • rating(如果是公开显示的数字)
  • douban_url(公开链接)
  • last_update_at(站点自带的最后更新时间)
  • intro(短简介,必要时截断)

不要落库:

  • 播放源 URL、播放分集 URL、网盘链接、资源直链、解析接口参数等

5. 稳定性核心:限速、退避、熔断、缓存

这类站点最常见的"死法"不是解析错误,而是:

  • 触发限流/封禁 → 403/429 爆炸
  • 域名失效/跳转异常
  • 某个页面结构改了导致全量失败

因此你需要四个工程件:

5.1 全局限速(按域名维度)

建议从非常保守的频率开始,例如每域名 0.2~1 rps,并加随机抖动,避免形成机械节奏。

5.2 指数退避重试

对 429/5xx/网络超时使用指数退避是网络工程常见策略,用来逐步找到系统可接受的重试节奏。(The Web Scraping Club)

5.3 熔断与域名降级

  • 某域名连续 N 次 403/5xx → 标记为"冷却中",一段时间内不再用它
  • 自动切换到域名池的下一个可用域名

5.4 缓存与去重

  • 同一详情页短时间不重复抓(例如 24h 内只抓一次)
  • 内容 hash 不变不更新,减少写库与下游触发

6. 数据模型与增量链路:让"可持续"成为默认

推荐最小可用的两张表(SQLite/PG/Redis 都行):

  • items

    • id(主键,hash(canonical_path))
    • canonical_path
    • title, year, region, genres, release_date
    • director, actors
    • rating, douban_url
    • intro
    • last_update_at(站点页面显示的最后更新时间)
    • content_hash
    • last_fetched_at
  • crawl_queue

    • url(完整 URL 或 canonical_path)
    • type(list/detail)
    • status(pending/running/done/failed)
    • retry_count
    • next_run_at

增量流程:

  1. 定期抓列表页 → 抽取详情页 canonical_path → 入队(去重)
  2. 消费 detail 队列 → 抽取元信息 → 计算 content_hash → upsert
  3. 失败按退避策略回队列
  4. 只回爬"最近更新"或"上次抓取超过 TTL"的详情页

7. 可运行的最小实现(Python):域名池 + 限速退避 + 解析 + SQLite 落库

说明:下面代码演示"工程骨架",抓取字段仅限公开元信息;遇到 403 会降级域名或熔断,不做任何对抗。

python 复制代码
import asyncio
import hashlib
import random
import re
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Iterable, Optional, Tuple, Dict, List

import httpx
from bs4 import BeautifulSoup

UA = "MetaIndexerBot/0.1 (+contact: you@example.com)"  # 合规标识:写清用途与联系方式

DETAIL_RE = re.compile(r"^/detail/\d+\.html$")

@dataclass
class DomainState:
    base: str
    fail_403: int = 0
    fail_other: int = 0
    cooldown_until: Optional[datetime] = None

class DomainPool:
    def __init__(self, domains: List[str]):
        self.domains = [DomainState(d.rstrip("/")) for d in domains]

    def pick(self) -> Optional[DomainState]:
        now = datetime.now(timezone.utc)
        candidates = [d for d in self.domains if not d.cooldown_until or d.cooldown_until <= now]
        if not candidates:
            return None
        # 简单策略:优先失败少的
        candidates.sort(key=lambda x: (x.fail_403, x.fail_other))
        return candidates[0]

    def mark_403(self, d: DomainState):
        d.fail_403 += 1
        # 连续多次 403,冷却 30 分钟
        if d.fail_403 >= 3:
            d.cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=30)

    def mark_other_fail(self, d: DomainState):
        d.fail_other += 1
        if d.fail_other >= 5:
            d.cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=10)

class RateLimiter:
    def __init__(self, rps: float):
        self.min_interval = 1.0 / max(rps, 0.01)
        self._lock = asyncio.Lock()
        self._last = 0.0

    async def wait(self):
        async with self._lock:
            now = asyncio.get_event_loop().time()
            sleep = self.min_interval - (now - self._last)
            # 抖动:避免节奏过于机械
            sleep += random.uniform(0.05, 0.25)
            if sleep > 0:
                await asyncio.sleep(sleep)
            self._last = asyncio.get_event_loop().time()

def sha1(s: str) -> str:
    return hashlib.sha1(s.encode("utf-8")).hexdigest()

def init_db(path="libvio_meta.db"):
    conn = sqlite3.connect(path)
    conn.execute("""
    CREATE TABLE IF NOT EXISTS items (
      id TEXT PRIMARY KEY,
      canonical_path TEXT UNIQUE,
      title TEXT,
      year TEXT,
      region TEXT,
      genres TEXT,
      release_date TEXT,
      director TEXT,
      actors TEXT,
      rating TEXT,
      douban_url TEXT,
      intro TEXT,
      last_update_at TEXT,
      content_hash TEXT,
      last_fetched_at TEXT
    )
    """)
    conn.execute("""
    CREATE TABLE IF NOT EXISTS crawl_queue (
      url TEXT PRIMARY KEY,
      type TEXT,
      status TEXT,
      retry_count INTEGER,
      next_run_at TEXT,
      last_error TEXT
    )
    """)
    conn.commit()
    return conn

def enqueue(conn, url: str, qtype: str):
    conn.execute("""
    INSERT OR IGNORE INTO crawl_queue(url, type, status, retry_count, next_run_at, last_error)
    VALUES(?, ?, 'pending', 0, ?, NULL)
    """, (url, qtype, datetime.now(timezone.utc).isoformat()))
    conn.commit()

def dequeue(conn) -> Optional[Tuple[str, str, int]]:
    row = conn.execute("""
    SELECT url, type, retry_count FROM crawl_queue
    WHERE status='pending' AND next_run_at <= ?
    ORDER BY type DESC, retry_count ASC
    LIMIT 1
    """, (datetime.now(timezone.utc).isoformat(),)).fetchone()
    if not row:
        return None
    conn.execute("UPDATE crawl_queue SET status='running' WHERE url=?", (row[0],))
    conn.commit()
    return row[0], row[1], row[2]

def mark_done(conn, url: str):
    conn.execute("UPDATE crawl_queue SET status='done', last_error=NULL WHERE url=?", (url,))
    conn.commit()

def mark_fail(conn, url: str, retry_count: int, err: str):
    # 指数退避:min(2^n, 600s) + 抖动
    delay = min(2 ** retry_count, 600) + random.uniform(0, 1)
    next_run = datetime.now(timezone.utc) + timedelta(seconds=delay)
    conn.execute("""
    UPDATE crawl_queue
    SET status='pending', retry_count=?, next_run_at=?, last_error=?
    WHERE url=?
    """, (retry_count + 1, next_run.isoformat(), err[:500], url))
    conn.commit()

def parse_list(html: str) -> List[str]:
    soup = BeautifulSoup(html, "html.parser")
    urls = set()
    for a in soup.select("a[href]"):
        href = a.get("href", "").strip()
        if DETAIL_RE.match(href):
            urls.add(href)
    return sorted(urls)

def parse_detail(html: str) -> Dict[str, Optional[str]]:
    soup = BeautifulSoup(html, "html.parser")

    title = None
    h1 = soup.find("h1")
    if h1:
        title = h1.get_text(strip=True)

    # 关键行示例:类型:剧情,动作 / 地区:台湾 / 年份:2025 / 上映:2025-06-20
    text = soup.get_text("\n", strip=True)
    # 粗暴但稳定:先定位包含"类型:"的行
    meta_line = None
    for line in text.split("\n"):
        if "类型:" in line and "地区:" in line and "年份:" in line:
            meta_line = line
            break

    year = region = genres = release_date = None
    if meta_line:
        parts = [p.strip() for p in meta_line.split("/")]

        def get_after(prefix: str) -> Optional[str]:
            for p in parts:
                if p.startswith(prefix):
                    return p.split(":", 1)[1].strip() if ":" in p else None
            return None

        genres = get_after("类型")
        region = get_after("地区")
        year = get_after("年份")
        release_date = get_after("上映")

    # 主演/导演行:主演:... / 导演:...
    director = actors = None
    for line in text.split("\n"):
        if line.startswith("主演:") and "导演:" in line:
            # 形式:主演:A,B / 导演:C
            try:
                left, right = line.split("/", 1)
                actors = left.split(":", 1)[1].strip()
                director = right.split(":", 1)[1].strip()
            except Exception:
                pass
            break

    # 豆瓣链接(公开链接)
    douban_url = None
    for a in soup.select("a[href]"):
        href = a.get("href", "")
        if "douban.com" in href:
            douban_url = href
            break

    # 评分数字:示例页面是"评分: 6.9"一类
    rating = None
    m = re.search(r"评分:\s*([0-9]+\.[0-9]+|[0-9]+)", text)
    if m:
        rating = m.group(1)

    # 最后更新:示例为"最后更新:2026-01-30 03:30:45"
    last_update_at = None
    m = re.search(r"最后更新:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})", text)
    if m:
        last_update_at = m.group(1)

    # 简介:用"简介:"后的一小段,截断避免落库过长
    intro = None
    m = re.search(r"简介:\s*(.+)", text)
    if m:
        intro = m.group(1).strip()
        intro = intro[:400]  # 控长度,避免抓一大坨

    return {
        "title": title,
        "year": year,
        "region": region,
        "genres": genres,
        "release_date": release_date,
        "director": director,
        "actors": actors,
        "rating": rating,
        "douban_url": douban_url,
        "intro": intro,
        "last_update_at": last_update_at,
    }

def upsert_item(conn, canonical_path: str, meta: Dict[str, Optional[str]]):
    item_id = sha1(canonical_path)
    # 内容 hash:用于"没变不更新"
    payload = "|".join([(meta.get(k) or "") for k in sorted(meta.keys())])
    content_hash = sha1(payload)

    row = conn.execute("SELECT content_hash FROM items WHERE canonical_path=?", (canonical_path,)).fetchone()
    if row and row[0] == content_hash:
        conn.execute("UPDATE items SET last_fetched_at=? WHERE canonical_path=?",
                     (datetime.now(timezone.utc).isoformat(), canonical_path))
        conn.commit()
        return False

    conn.execute("""
    INSERT INTO items(id, canonical_path, title, year, region, genres, release_date, director, actors, rating, douban_url,
                      intro, last_update_at, content_hash, last_fetched_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(canonical_path) DO UPDATE SET
      title=excluded.title,
      year=excluded.year,
      region=excluded.region,
      genres=excluded.genres,
      release_date=excluded.release_date,
      director=excluded.director,
      actors=excluded.actors,
      rating=excluded.rating,
      douban_url=excluded.douban_url,
      intro=excluded.intro,
      last_update_at=excluded.last_update_at,
      content_hash=excluded.content_hash,
      last_fetched_at=excluded.last_fetched_at
    """, (
        item_id, canonical_path,
        meta.get("title"), meta.get("year"), meta.get("region"), meta.get("genres"), meta.get("release_date"),
        meta.get("director"), meta.get("actors"), meta.get("rating"), meta.get("douban_url"),
        meta.get("intro"), meta.get("last_update_at"), content_hash,
        datetime.now(timezone.utc).isoformat()
    ))
    conn.commit()
    return True

async def fetch(client: httpx.AsyncClient, limiter: RateLimiter, url: str) -> Tuple[int, str]:
    await limiter.wait()
    r = await client.get(url)
    return r.status_code, r.text

async def worker(conn, pool: DomainPool, limiter: RateLimiter):
    async with httpx.AsyncClient(
        headers={"User-Agent": UA},
        timeout=httpx.Timeout(20.0),
        follow_redirects=True,
    ) as client:
        while True:
            job = dequeue(conn)
            if not job:
                await asyncio.sleep(1)
                continue
            url, qtype, retry = job

            d = pool.pick()
            if not d:
                mark_fail(conn, url, retry, "no available domains (all cooling down)")
                await asyncio.sleep(2)
                continue

            full = d.base + url if url.startswith("/") else url
            try:
                code, text = await fetch(client, limiter, full)
                if code == 403:
                    pool.mark_403(d)
                    mark_fail(conn, url, retry, f"403 forbidden from {d.base}")
                    continue
                if code >= 500 or code == 429:
                    pool.mark_other_fail(d)
                    mark_fail(conn, url, retry, f"server busy {code}")
                    continue
                if code != 200:
                    mark_fail(conn, url, retry, f"unexpected status {code}")
                    continue

                if qtype == "list":
                    for detail_path in parse_list(text):
                        enqueue(conn, detail_path, "detail")
                    mark_done(conn, url)

                elif qtype == "detail":
                    meta = parse_detail(text)
                    upsert_item(conn, url, meta)
                    mark_done(conn, url)

                else:
                    mark_done(conn, url)

            except Exception as e:
                pool.mark_other_fail(d)
                mark_fail(conn, url, retry, str(e))

async def main():
    # 域名池:建议从发布页维护的列表里手工挑选"自己能访问"的域名
    domains = [
        "https://www.libvio.lat",   # 仅示例:请替换为你当前可访问的入口
    ]
    pool = DomainPool(domains)
    limiter = RateLimiter(rps=0.5)  # 很保守:每 2 秒左右一次(含抖动)
    conn = init_db()

    # 种子:列表页(也可以加更多分类页)
    enqueue(conn, "/show/1-----------.html", "list")

    await worker(conn, pool, limiter)

if __name__ == "__main__":
    asyncio.run(main())

你把 domains 换成发布页列出的"你本机可正常打开"的入口就能跑起来(并且当某入口 403 会自动冷却、避免硬撞)。发布页与域名列表可参考其公开页面。(Libvio)

8. 可观测性:你要知道"挂在哪",而不是只看到"又失败了"

至少要打这些日志与指标(不需要一上来就上 Prometheus,先结构化日志也行):

  • 每个域名:请求数、2xx/403/429/5xx 比例、平均延迟
  • 队列:pending 数、running 数、失败重试分布、最老任务年龄
  • 解析:字段缺失率(title/year/region 等)、selector 失败次数
  • 变更:content_hash 变化比例(改版/内容更新的信号)

当 403 比例突然上升,第一反应应该是"降频 + 域名切换 + 暂停某些路径",而不是加大并发。

9. 为什么我建议你把"robots 门禁 + 域名池"放在第一层

因为这类站点最真实的运行环境是:

  • 域名会换(站点自己就在发布页强调这一点)(Libvio)
  • 某些入口会拒绝自动化访问(403)
  • robots 规则是请求性约束,合规与审计必须可解释(RFC 編輯器)

你只要把第一层做好,解析层反而是最简单的。

10. 你要的"更深一层":下一步优化点(不涉及对抗)

如果你准备把它做成长期服务,我建议按优先级做这几步:

  1. robots 缓存与 TTL(按域名维度缓存,失败按策略降级)
  2. 任务队列迁移到 Redis(支持多进程/多机),并加"按域名分桶限速"
  3. 增量策略升级:只回爬"最近更新"或"疑似变更"的详情页(用 last_update_at/content_hash)
  4. 解析器单元测试:把真实页面 HTML 保存为 fixtures,改版时快速定位是哪里坏了
  5. 写一层数据清洗:年份/日期/地区归一、演员导演分隔、简介截断、空值处理

如果你把"你现在能访问的入口域名"和"你最想抓的 1 个列表页 URL"(比如电影/电视剧某分类)贴出来,我可以把上面的代码进一步升级成:

  • 多 worker 并发但严格按域名限速
  • robots 门禁(可配置缓存与白名单)
  • SQLite/PG 二选一的生产级落库
  • 带 fixtures 的解析单测(改版一眼能看出哪里变了)

下面我把文章写成"能落地、能长期跑、也更合规"的深度版本:不仅讲思路,还会把站点结构、工程架构、增量策略、容错、可观测性、以及一份可运行的最小代码骨架都给出来(只抓公开元信息,不碰播放/下载链路)。

面向 403 与域名频繁变更的合规爬虫工程实践:以 Libvio 系站点为例

影视聚合站的爬虫最难的从来不是解析 HTML,而是"长期稳定运行"。这类站点经常出现:

  1. 域名轮换、备用域名一堆
  2. 对自动化请求敏感(常见现象是直接 403)
  3. 页面结构改版、广告位插入、字段不稳定
  4. 内容存在明显版权与合规风险(尤其是播放/下载链路)

本文用 Libvio 系站点的真实页面形态举例,讲一套工程化方案:域名池 + robots 门禁 + 低频抓取 + 增量去重 + 观测与熔断。目标仅限"公开元信息索引"(片名/年份/地区/类型/简介/豆瓣链接/最后更新时间等)。

1. 真实约束:域名会变,且某些入口会 403

从其发布页可以看到它明确给出了多个"目前可用地址/备用地址",并提示记住发布页与邮箱获取最新地址。(Libvio)

这意味着:把域名写死在代码里,几乎必挂。你的爬虫第一层能力应该是"域名可替换"。

同时,自动化访问某些入口会直接返回 403(我们工具抓取 https://www.libvio.link/ 就是 403)。

合规做法不是去"对抗/绕过",而是把它当作"不可抓取域名",通过域名池切换到可用入口,或降低频率并只抓允许路径。

2. 信息架构拆解:列表页与详情页的 URL 形态很固定

在可访问的同系域名上(示例域名仅用于说明结构),页面 URL 形态非常规律:

  • 分类/列表页形如:/show/1-----------.html(示例为"最新电影-推荐电影"列表页)(libvio.lat)
  • 详情页形如:/detail/5812705.html(示例详情页)(libvio.lat)

详情页里,公开元信息通常集中在标题与一段"类型/地区/年份/上映"行、主演导演行、评分链接、最后更新与简介等字段,例如:

  • 标题与基础信息:类型:... / 地区:... / 年份:... / 上映:...(libvio.lat)
  • 评分含豆瓣链接、最后更新字段、简介字段(libvio.lat)

这对爬虫工程的意义是:你可以用"列表发现详情 URL → 详情抓取元信息"的增量链路,而不用全站扫描。

3. 合规门禁:robots.txt 不是授权,但你必须尊重

robots 的标准在 RFC 9309 中被正式规范,明确指出它是"请求性约束而非访问授权"。(RFC 編輯器)

同时,搜索引擎对 robots 获取失败/状态码也有明确处理策略(例如 4xx 如何缓存/停抓一段时间等),这对你设计自己的抓取门禁与缓存非常有参考价值。(Google for Developers)

工程上建议你做一个 RobotsGate

  • 启动时拉取 /<domain>/robots.txt,缓存到本地(带 TTL)
  • 对每个 URL 判断是否允许抓取
  • 不允许则直接跳过并记录审计日志

注意:robots 解析要按"域名+协议"维度缓存(不同域名、不同协议是不同的 robots 作用域)。

4. 目标收敛:只抓公开元信息,坚决不碰播放/下载链路

以示例详情页为例,它包含"立即播放""视频下载(夸克/百度)"等区域。(libvio.lat)

这些链路往往牵涉版权、也更容易触发站点保护与法律风险。最稳妥的实践是:

只落库这些字段:

  • canonical_path(例如 /detail/5812705.html
  • title
  • year / region / genres / release_date
  • actors / director
  • rating(如果是公开显示的数字)
  • douban_url(公开链接)
  • last_update_at(站点自带的最后更新时间)
  • intro(短简介,必要时截断)

不要落库:

  • 播放源 URL、播放分集 URL、网盘链接、资源直链、解析接口参数等

5. 稳定性核心:限速、退避、熔断、缓存

这类站点最常见的"死法"不是解析错误,而是:

  • 触发限流/封禁 → 403/429 爆炸
  • 域名失效/跳转异常
  • 某个页面结构改了导致全量失败

因此你需要四个工程件:

5.1 全局限速(按域名维度)

建议从非常保守的频率开始,例如每域名 0.2~1 rps,并加随机抖动,避免形成机械节奏。

5.2 指数退避重试

对 429/5xx/网络超时使用指数退避是网络工程常见策略,用来逐步找到系统可接受的重试节奏。(The Web Scraping Club)

5.3 熔断与域名降级

  • 某域名连续 N 次 403/5xx → 标记为"冷却中",一段时间内不再用它
  • 自动切换到域名池的下一个可用域名

5.4 缓存与去重

  • 同一详情页短时间不重复抓(例如 24h 内只抓一次)
  • 内容 hash 不变不更新,减少写库与下游触发

6. 数据模型与增量链路:让"可持续"成为默认

推荐最小可用的两张表(SQLite/PG/Redis 都行):

  • items

    • id(主键,hash(canonical_path))
    • canonical_path
    • title, year, region, genres, release_date
    • director, actors
    • rating, douban_url
    • intro
    • last_update_at(站点页面显示的最后更新时间)
    • content_hash
    • last_fetched_at
  • crawl_queue

    • url(完整 URL 或 canonical_path)
    • type(list/detail)
    • status(pending/running/done/failed)
    • retry_count
    • next_run_at

增量流程:

  1. 定期抓列表页 → 抽取详情页 canonical_path → 入队(去重)
  2. 消费 detail 队列 → 抽取元信息 → 计算 content_hash → upsert
  3. 失败按退避策略回队列
  4. 只回爬"最近更新"或"上次抓取超过 TTL"的详情页

7. 可运行的最小实现(Python):域名池 + 限速退避 + 解析 + SQLite 落库

说明:下面代码演示"工程骨架",抓取字段仅限公开元信息;遇到 403 会降级域名或熔断,不做任何对抗。

python 复制代码
import asyncio
import hashlib
import random
import re
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Iterable, Optional, Tuple, Dict, List

import httpx
from bs4 import BeautifulSoup

UA = "MetaIndexerBot/0.1 (+contact: you@example.com)"  # 合规标识:写清用途与联系方式

DETAIL_RE = re.compile(r"^/detail/\d+\.html$")

@dataclass
class DomainState:
    base: str
    fail_403: int = 0
    fail_other: int = 0
    cooldown_until: Optional[datetime] = None

class DomainPool:
    def __init__(self, domains: List[str]):
        self.domains = [DomainState(d.rstrip("/")) for d in domains]

    def pick(self) -> Optional[DomainState]:
        now = datetime.now(timezone.utc)
        candidates = [d for d in self.domains if not d.cooldown_until or d.cooldown_until <= now]
        if not candidates:
            return None
        # 简单策略:优先失败少的
        candidates.sort(key=lambda x: (x.fail_403, x.fail_other))
        return candidates[0]

    def mark_403(self, d: DomainState):
        d.fail_403 += 1
        # 连续多次 403,冷却 30 分钟
        if d.fail_403 >= 3:
            d.cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=30)

    def mark_other_fail(self, d: DomainState):
        d.fail_other += 1
        if d.fail_other >= 5:
            d.cooldown_until = datetime.now(timezone.utc) + timedelta(minutes=10)

class RateLimiter:
    def __init__(self, rps: float):
        self.min_interval = 1.0 / max(rps, 0.01)
        self._lock = asyncio.Lock()
        self._last = 0.0

    async def wait(self):
        async with self._lock:
            now = asyncio.get_event_loop().time()
            sleep = self.min_interval - (now - self._last)
            # 抖动:避免节奏过于机械
            sleep += random.uniform(0.05, 0.25)
            if sleep > 0:
                await asyncio.sleep(sleep)
            self._last = asyncio.get_event_loop().time()

def sha1(s: str) -> str:
    return hashlib.sha1(s.encode("utf-8")).hexdigest()

def init_db(path="libvio_meta.db"):
    conn = sqlite3.connect(path)
    conn.execute("""
    CREATE TABLE IF NOT EXISTS items (
      id TEXT PRIMARY KEY,
      canonical_path TEXT UNIQUE,
      title TEXT,
      year TEXT,
      region TEXT,
      genres TEXT,
      release_date TEXT,
      director TEXT,
      actors TEXT,
      rating TEXT,
      douban_url TEXT,
      intro TEXT,
      last_update_at TEXT,
      content_hash TEXT,
      last_fetched_at TEXT
    )
    """)
    conn.execute("""
    CREATE TABLE IF NOT EXISTS crawl_queue (
      url TEXT PRIMARY KEY,
      type TEXT,
      status TEXT,
      retry_count INTEGER,
      next_run_at TEXT,
      last_error TEXT
    )
    """)
    conn.commit()
    return conn

def enqueue(conn, url: str, qtype: str):
    conn.execute("""
    INSERT OR IGNORE INTO crawl_queue(url, type, status, retry_count, next_run_at, last_error)
    VALUES(?, ?, 'pending', 0, ?, NULL)
    """, (url, qtype, datetime.now(timezone.utc).isoformat()))
    conn.commit()

def dequeue(conn) -> Optional[Tuple[str, str, int]]:
    row = conn.execute("""
    SELECT url, type, retry_count FROM crawl_queue
    WHERE status='pending' AND next_run_at <= ?
    ORDER BY type DESC, retry_count ASC
    LIMIT 1
    """, (datetime.now(timezone.utc).isoformat(),)).fetchone()
    if not row:
        return None
    conn.execute("UPDATE crawl_queue SET status='running' WHERE url=?", (row[0],))
    conn.commit()
    return row[0], row[1], row[2]

def mark_done(conn, url: str):
    conn.execute("UPDATE crawl_queue SET status='done', last_error=NULL WHERE url=?", (url,))
    conn.commit()

def mark_fail(conn, url: str, retry_count: int, err: str):
    # 指数退避:min(2^n, 600s) + 抖动
    delay = min(2 ** retry_count, 600) + random.uniform(0, 1)
    next_run = datetime.now(timezone.utc) + timedelta(seconds=delay)
    conn.execute("""
    UPDATE crawl_queue
    SET status='pending', retry_count=?, next_run_at=?, last_error=?
    WHERE url=?
    """, (retry_count + 1, next_run.isoformat(), err[:500], url))
    conn.commit()

def parse_list(html: str) -> List[str]:
    soup = BeautifulSoup(html, "html.parser")
    urls = set()
    for a in soup.select("a[href]"):
        href = a.get("href", "").strip()
        if DETAIL_RE.match(href):
            urls.add(href)
    return sorted(urls)

def parse_detail(html: str) -> Dict[str, Optional[str]]:
    soup = BeautifulSoup(html, "html.parser")

    title = None
    h1 = soup.find("h1")
    if h1:
        title = h1.get_text(strip=True)

    # 关键行示例:类型:剧情,动作 / 地区:台湾 / 年份:2025 / 上映:2025-06-20
    text = soup.get_text("\n", strip=True)
    # 粗暴但稳定:先定位包含"类型:"的行
    meta_line = None
    for line in text.split("\n"):
        if "类型:" in line and "地区:" in line and "年份:" in line:
            meta_line = line
            break

    year = region = genres = release_date = None
    if meta_line:
        parts = [p.strip() for p in meta_line.split("/")]

        def get_after(prefix: str) -> Optional[str]:
            for p in parts:
                if p.startswith(prefix):
                    return p.split(":", 1)[1].strip() if ":" in p else None
            return None

        genres = get_after("类型")
        region = get_after("地区")
        year = get_after("年份")
        release_date = get_after("上映")

    # 主演/导演行:主演:... / 导演:...
    director = actors = None
    for line in text.split("\n"):
        if line.startswith("主演:") and "导演:" in line:
            # 形式:主演:A,B / 导演:C
            try:
                left, right = line.split("/", 1)
                actors = left.split(":", 1)[1].strip()
                director = right.split(":", 1)[1].strip()
            except Exception:
                pass
            break

    # 豆瓣链接(公开链接)
    douban_url = None
    for a in soup.select("a[href]"):
        href = a.get("href", "")
        if "douban.com" in href:
            douban_url = href
            break

    # 评分数字:示例页面是"评分: 6.9"一类
    rating = None
    m = re.search(r"评分:\s*([0-9]+\.[0-9]+|[0-9]+)", text)
    if m:
        rating = m.group(1)

    # 最后更新:示例为"最后更新:2026-01-30 03:30:45"
    last_update_at = None
    m = re.search(r"最后更新:\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})", text)
    if m:
        last_update_at = m.group(1)

    # 简介:用"简介:"后的一小段,截断避免落库过长
    intro = None
    m = re.search(r"简介:\s*(.+)", text)
    if m:
        intro = m.group(1).strip()
        intro = intro[:400]  # 控长度,避免抓一大坨

    return {
        "title": title,
        "year": year,
        "region": region,
        "genres": genres,
        "release_date": release_date,
        "director": director,
        "actors": actors,
        "rating": rating,
        "douban_url": douban_url,
        "intro": intro,
        "last_update_at": last_update_at,
    }

def upsert_item(conn, canonical_path: str, meta: Dict[str, Optional[str]]):
    item_id = sha1(canonical_path)
    # 内容 hash:用于"没变不更新"
    payload = "|".join([(meta.get(k) or "") for k in sorted(meta.keys())])
    content_hash = sha1(payload)

    row = conn.execute("SELECT content_hash FROM items WHERE canonical_path=?", (canonical_path,)).fetchone()
    if row and row[0] == content_hash:
        conn.execute("UPDATE items SET last_fetched_at=? WHERE canonical_path=?",
                     (datetime.now(timezone.utc).isoformat(), canonical_path))
        conn.commit()
        return False

    conn.execute("""
    INSERT INTO items(id, canonical_path, title, year, region, genres, release_date, director, actors, rating, douban_url,
                      intro, last_update_at, content_hash, last_fetched_at)
    VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
    ON CONFLICT(canonical_path) DO UPDATE SET
      title=excluded.title,
      year=excluded.year,
      region=excluded.region,
      genres=excluded.genres,
      release_date=excluded.release_date,
      director=excluded.director,
      actors=excluded.actors,
      rating=excluded.rating,
      douban_url=excluded.douban_url,
      intro=excluded.intro,
      last_update_at=excluded.last_update_at,
      content_hash=excluded.content_hash,
      last_fetched_at=excluded.last_fetched_at
    """, (
        item_id, canonical_path,
        meta.get("title"), meta.get("year"), meta.get("region"), meta.get("genres"), meta.get("release_date"),
        meta.get("director"), meta.get("actors"), meta.get("rating"), meta.get("douban_url"),
        meta.get("intro"), meta.get("last_update_at"), content_hash,
        datetime.now(timezone.utc).isoformat()
    ))
    conn.commit()
    return True

async def fetch(client: httpx.AsyncClient, limiter: RateLimiter, url: str) -> Tuple[int, str]:
    await limiter.wait()
    r = await client.get(url)
    return r.status_code, r.text

async def worker(conn, pool: DomainPool, limiter: RateLimiter):
    async with httpx.AsyncClient(
        headers={"User-Agent": UA},
        timeout=httpx.Timeout(20.0),
        follow_redirects=True,
    ) as client:
        while True:
            job = dequeue(conn)
            if not job:
                await asyncio.sleep(1)
                continue
            url, qtype, retry = job

            d = pool.pick()
            if not d:
                mark_fail(conn, url, retry, "no available domains (all cooling down)")
                await asyncio.sleep(2)
                continue

            full = d.base + url if url.startswith("/") else url
            try:
                code, text = await fetch(client, limiter, full)
                if code == 403:
                    pool.mark_403(d)
                    mark_fail(conn, url, retry, f"403 forbidden from {d.base}")
                    continue
                if code >= 500 or code == 429:
                    pool.mark_other_fail(d)
                    mark_fail(conn, url, retry, f"server busy {code}")
                    continue
                if code != 200:
                    mark_fail(conn, url, retry, f"unexpected status {code}")
                    continue

                if qtype == "list":
                    for detail_path in parse_list(text):
                        enqueue(conn, detail_path, "detail")
                    mark_done(conn, url)

                elif qtype == "detail":
                    meta = parse_detail(text)
                    upsert_item(conn, url, meta)
                    mark_done(conn, url)

                else:
                    mark_done(conn, url)

            except Exception as e:
                pool.mark_other_fail(d)
                mark_fail(conn, url, retry, str(e))

async def main():
    # 域名池:建议从发布页维护的列表里手工挑选"自己能访问"的域名
    domains = [
        "https://www.libvio.lat",   # 仅示例:请替换为你当前可访问的入口
    ]
    pool = DomainPool(domains)
    limiter = RateLimiter(rps=0.5)  # 很保守:每 2 秒左右一次(含抖动)
    conn = init_db()

    # 种子:列表页(也可以加更多分类页)
    enqueue(conn, "/show/1-----------.html", "list")

    await worker(conn, pool, limiter)

if __name__ == "__main__":
    asyncio.run(main())

你把 domains 换成发布页列出的"你本机可正常打开"的入口就能跑起来(并且当某入口 403 会自动冷却、避免硬撞)。发布页与域名列表可参考其公开页面。(Libvio)

8. 可观测性:你要知道"挂在哪",而不是只看到"又失败了"

至少要打这些日志与指标(不需要一上来就上 Prometheus,先结构化日志也行):

  • 每个域名:请求数、2xx/403/429/5xx 比例、平均延迟
  • 队列:pending 数、running 数、失败重试分布、最老任务年龄
  • 解析:字段缺失率(title/year/region 等)、selector 失败次数
  • 变更:content_hash 变化比例(改版/内容更新的信号)

当 403 比例突然上升,第一反应应该是"降频 + 域名切换 + 暂停某些路径",而不是加大并发。

9. 为什么我建议你把"robots 门禁 + 域名池"放在第一层

因为这类站点最真实的运行环境是:

  • 域名会换(站点自己就在发布页强调这一点)(Libvio)
  • 某些入口会拒绝自动化访问(403)
  • robots 规则是请求性约束,合规与审计必须可解释(RFC 編輯器)

你只要把第一层做好,解析层反而是最简单的。

10. 你要的"更深一层":下一步优化点(不涉及对抗)

如果你准备把它做成长期服务,我建议按优先级做这几步:

  1. robots 缓存与 TTL(按域名维度缓存,失败按策略降级)
  2. 任务队列迁移到 Redis(支持多进程/多机),并加"按域名分桶限速"
  3. 增量策略升级:只回爬"最近更新"或"疑似变更"的详情页(用 last_update_at/content_hash)
  4. 解析器单元测试:把真实页面 HTML 保存为 fixtures,改版时快速定位是哪里坏了
  5. 写一层数据清洗:年份/日期/地区归一、演员导演分隔、简介截断、空值处理
相关推荐
m0_663234012 小时前
Libvio.link爬虫技术与反爬攻防解析
爬虫
深蓝海拓2 小时前
PySide6从0开始学习的笔记(二十五) Qt窗口对象的生命周期和及时销毁
笔记·python·qt·学习·pyqt
Dfreedom.2 小时前
开运算与闭运算:图像形态学中的“清道夫”与“修复匠”
图像处理·python·opencv·开运算·闭运算
2301_790300962 小时前
用Python读取和处理NASA公开API数据
jvm·数据库·python
葱明撅腚2 小时前
利用Python挖掘城市数据
python·算法·gis·聚类
Serendipity_Carl2 小时前
1637加盟网数据实战(数分可视化)
爬虫·python·pycharm·数据可视化·数据清洗
流㶡2 小时前
网络爬虫之requests.get() 之爬取网页内容
python·数据爬虫
yuankoudaodaokou2 小时前
高校科研新利器:思看科技三维扫描仪助力精密研究
人工智能·python·科技
言無咎3 小时前
从规则引擎到任务规划:AI Agent 重构跨境财税复杂账务处理体系
大数据·人工智能·python·重构