news 2026/8/31 12:38:48

爬虫先探测再抓取:ProbeScraper工具解析与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
爬虫先探测再抓取:ProbeScraper工具解析与实战

最近在 Hacker News 上看到一个很有意思的项目:Show HN: Scraper that probes a site before promising it works。一句话概括设计思路:在承诺“我能抓这个站”之前,先对目标站点做一次探测。这个思路看起来简单,但对做爬虫工程的人来说,几乎打中了日常最大痛点——配置了一整套规则,真正跑起来才发现网站早就改版、加了登录墙、换了反爬策略,或者 robots.txt 已经明确禁止抓取。与其等到批量任务跑完再收拾一堆无效数据,不如在任务开始前就用一个 probe 阶段把所有潜在问题暴露出来。

为了便于叙述,下文用 ProbeScraper 作为这个项目的代称。这篇文章会围绕它的核心能力、适用边界、部署方式、探测流程、API 调用和批量任务展开,重点说明为什么“先探测、再抓取”能节约开发时间、减少无效请求,以及如何把它接到自己的数据采集链路里。

在往下读之前,先想三个问题:

  1. 你现在的爬虫任务,有没有一上来就盲目请求目标网站,直到反爬响应或结构变化后才发现问题?
  2. 你的站点解析规则,有没有因为目标网站改版而突然失效?
  3. 你的批量采集任务,有多少是跑了很久才发现整体失败?

如果对这三个问题有共鸣,这篇文章建议收藏。先看核心能力速览。

1. ProbeScraper 核心能力速览

能力项说明
项目类型站点探测 + 网页爬虫工具
核心卖点抓取前先 probe 目标站点,确认可抓取后再执行采集
探测对象robots.txt、HTTP 状态码、页面结构、反爬验证页、编码
主要功能站点可用性检查、解析规则校验、批量任务、API 服务
运行平台Windows / Linux / macOS
硬件要求极低,CPU 即可,无 GPU 依赖
推荐环境Python 3.9+,依赖 requests、lxml、BeautifulSoup4
启动方式CLI 命令、API 服务、Docker 容器
是否支持 API支持,可提供 JSON 接口
是否支持批量任务支持,按站点清单批量探测和抓取
适合场景数据采集工程、爬虫巡检、网站结构监控、自动化测试

从材料看,这个项目并不依赖特定显卡或深度学习框架,本质上是工程型工具。它不适合用来处理验证码绕过、JS 渲染破解等高对抗场景,核心价值是“把能爬的站快速爬好,把不能爬的站提前识别出来”。

2. 适用场景与使用边界

2.1 这个工具适合谁

先说适合的人:

  • 爬虫脚本经常跑挂的开发者。如果你维护过几十个站点采集任务,一定遇到过“某天某个站突然返回 403,但脚本还在跑,最后全量失败”的情况。ProbeScraper 的设计就是让这类问题在任务启动前暴露。
  • 做数据采集平台或采集服务的团队。在用户提交一个采集任务时,先执行 probe,如果探测不过,直接返回“该站点暂不支持采集”或“需要人工确认”,比任务跑到一半再失败要友好得多。
  • 做网站结构监控的人。网站改版后,解析规则会失效。ProbeScraper 的“选择器校验”功能可以在规则失效时及时告警。
  • 自动化测试工程师。把 probe 当成一个只读的 URL 健康检查工具,检查站点首页是否 200、robots.txt 是否限制、是否出现反爬验证页面。

2.2 什么场景不适合

  • 高并发高强度抓取。这类工具的定位是先探测、再小批量验证,不适合直接用几十个线程冲某个站点。
  • 需要登录才能访问的内容。如果目标页面在登录墙后面,probe 只能验证登录页是否能访问,无法真实评估登录后的数据解析情况。
  • 需要渲染 JS 的动态页面。如果目标依赖浏览器渲染,probe 阶段需要额外接入 Playwright 或 Selenium,否则只能探测到空壳 HTML。
  • 以绕过访问控制为目的的行为。这不是工具该干的事,也不应该在工程里出现。

2.3 合规边界

任何爬虫工具都要强调合规。使用 ProbeScraper 时,至少做到:

  • 遵守目标网站的 robots.txt 和使用条款。
  • 控制请求频率,设置 User-Agent 并确保可识别。
  • 只抓取公开、合法授权的数据。
  • 不以任何方式绕过登录、验证码或访问控制。
  • 涉及个人信息、版权内容时,先确认授权范围,再决定是否采集。

探测阶段本身就应该把“网站不允许抓取”当成一个正常结果返回,而不是把它当成要解决的问题。

3. 工作原理:从 probe 到 scraper

ProbeScraper 的核心设计可以拆成两个阶段:probe 阶段和 scrape 阶段。probe 阶段只做轻量检查和确认,scrape 阶段才真正执行数据抽取。

3.1 probe 阶段检查什么

一次典型的 probe 会按顺序执行以下检查:

  1. robots.txt 检查:拉取目标站点的 robots.txt,判断请求路径是否在 Disallow 列表中。如果明确禁止,直接返回不可抓取。
  2. HTTP 状态检查:发起一个带预设 User-Agent 的 GET 或 HEAD 请求,看返回状态码。200 正常,403/401 可能被拦截,404 可能是路径失效,301/302 需要确认重定向目标。
  3. 响应内容检查:拉取目标页面 HTML,检查是否存在反爬验证特征,比如“captcha”“verify you are human”“安全验证”“刷新页面”等关键词。
  4. 解析规则校验:如果用户配置了 XPath 或 CSS 选择器,probe 会在已拉取的 HTML 上执行这些选择器,确认能匹配到元素。匹配不到就直接报错。
  5. 内容编码检测:读取响应头中的 charset,并用实际内容做编码嗅探,避免后续解析出现乱码。
  6. 响应时间记录:记录 DNS 解析、TCP 连接、首字节耗时,方便判断站点是否因为限流或性能问题导致响应缓慢。

3.2 伪代码示意

下面这一段伪代码可以直观表达探测流程。注意这是通用逻辑,具体实现以实际项目为准。

def probe_site(site: dict) -> dict: result = {"site": site["name"], "status": "unknown", "checks": {}} # 1. 检查 robots.txt robots_allowed = check_robots_txt(site["url"], site.get("user_agent")) result["checks"]["robots"] = robots_allowed if not robots_allowed["allowed"]: result["status"] = "blocked_by_robots" return result # 2. 发送探测请求 try: resp = requests.get( site["url"], headers={"User-Agent": site.get("user_agent", "ProbeScraper/0.1")}, timeout=site.get("timeout", 15) ) except Exception as e: result["status"] = "request_failed" result["error"] = str(e) return result result["checks"]["http_status"] = resp.status_code if resp.status_code != 200: result["status"] = "http_error" return result # 3. 检测反爬验证页 if is_verification_page(resp.text): result["status"] = "captcha_or_banned" return result # 4. 校验解析选择器 if site.get("selectors"): matched = validate_selectors(resp.text, site["selectors"]) result["checks"]["selectors"] = matched if not matched["ok"]: result["status"] = "selector_mismatch" return result result["status"] = "ready" return result

判断成功的标准就是 result["status"] == "ready"。一旦走到这一步,工具才会进入真正的抓取流程。

3.3 为什么先探测能减少无效请求

从工程成本看,一个批量任务如果包含 100 个站点,直接抓取时可能中途挂了 50 个,你需要排查日志、定位失败原因、调整规则、重新跑任务。而先探测后抓取的流程是:

  • 100 个站点先全部进入 probe 队列。
  • probe 输出 80 个“ready”、10 个“selector_mismatch”、5 个“blocked_by_robots”、5 个“request_failed”。
  • 你只需要处理失败的 20 个,或者直接跳过它们。

这里的核心收益是:失败被提前隔离,而不是等真正抓数据时再放大。探测请求和数据抓取请求的代价完全不一样,探测阶段可以只拉一次页面就完成判断,而错误的数据抓取可能要反复重试、多次请求,加重目标站点负担。

4. 环境准备与前置条件

4.1 系统环境

从项目类型看,ProbeScraper 适合在 Linux 服务器或本地开发机上运行。如果是 Windows,建议使用 WSL2 或 PowerShell 配合 Python 虚拟环境。macOS 直接跑 Python 3 即可。

建议环境:

  • Python 3.9 或更高版本。
  • pip 已配置可用源。
  • 联网正常,DNS 解析无异常。

4.2 安装基础依赖

不要直接在全局 Python 环境里装依赖,先隔离再安装。

python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate pip install --upgrade pip pip install requests beautifulsoup4 lxml pytest

如果项目自带 requirements.txt,直接使用:

pip install -r requirements.txt

4.3 确认 Python 路径与 site 配置

如果你需要排查依赖安装位置、确认 site-packages 路径,可以用 Python 的 site 模块:

python -m site

这个命令会输出 Python 的 site-packages 目录、USER_BASE、USER_SITE 等信息。在排查“依赖到底装到了哪里”时很实用,特别是当你同时存在多套 Python 环境时。

4.4 准备站点配置文件

ProbeScraper 的常见用法是维护一个站点清单,每个站点包含:名称、入口 URL、请求头、解析选择器、超时时间、是否允许跟随重定向。

一份 YAML 配置示例:

sites: - name: example_blog url: "https://example.com/posts" method: GET headers: User-Agent: "Mozilla/5.0 (compatible; ProbeScraper/0.1)" selectors: title: "h1.post-title" body: "div.post-content" list: "ul.post-list li" timeout: 15 follow_redirects: true - name: example_news url: "https://news.example.org" method: GET headers: User-Agent: "ProbeScraper/0.1" selectors: headline: "h2.headline" timeout: 10 follow_redirects: false

5. 安装部署与启动方式

5.1 CLI 启动

假设项目入口文件是main.py,命令行启动可以设计成两种模式:probescrape

# 只做探测 python main.py probe --config sites.yaml # 探测通过后直接抓取 python main.py scrape --config sites.yaml --output result.jsonl

probe模式只输出探测结果,不会产生实际抓取。scrape模式内部会先调用 probe,再对标记为 ready 的站点执行抓取。

如果要指定单个站点:

python main.py probe --config sites.yaml --site example_blog

5.2 API 服务启动

支持 API 是这个项目的加分项。API 服务可以让前端页面、定时任务、其他服务统一调用。

假设入口是api.py

python api.py --host 127.0.0.1 --port 8765

启动成功后:

  • 访问http://127.0.0.1:8765/docs可以查看接口文档。
  • 访问http://127.0.0.1:8765/health可以确认服务状态。
  • 调用POST /api/probe提交站点探测请求。

5.3 Docker 启动

如果你的采集环境统一用 Docker 管理,可以构建一个镜像。下面是一个通用 Dockerfile 示例,路径和入口需要按实际项目调整。

FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8765 CMD ["python", "api.py", "--host", "0.0.0.0", "--port", "8765"]

构建并启动:

docker build -t probe-scraper . docker run -d --name probe-scraper -p 8765:8765 probe-scraper

6. 功能测试与效果验证

部署完成之后,不要急着跑全量任务。先用一组小样本测试把 probe 阶段的行为摸清楚。

6.1 准备测试站点

建议准备四类测试站点:

类型预期行为
可正常抓取的公开博客页面probe 返回 ready
返回 404 或首页改版后的旧 URLprobe 返回 http_error 或 selector_mismatch
返回 403 或有验证码页面的站点probe 返回 captcha_or_banned
本地起一个 HTTP 服务做断网/慢响应测试probe 返回 request_failed

第四类可以用 Python 本地起一个临时服务来模拟,例如:

mkdir -p /tmp/test_html && echo "<html><body><h1 class='title'>Hello</h1></body></html>" > /tmp/test_html/index.html python -m http.server 8000 --directory /tmp/test_html

然后配置一个站点指向http://127.0.0.1:8000/,选择器写title: "h1.title",预期 probe 返回 ready。

6.2 探测通过的标准

执行探测后,读输出结果。判断字段如下:

  • status == "ready":站点可以抓取。
  • status == "blocked_by_robots":robots.txt 禁止,跳过。
  • status == "request_failed":网络、DNS、TLS 等问题。
  • status == "http_error":状态码非 200,需要检查 URL 是否正确。
  • status == "captcha_or_banned":可能被反爬策略拦截。
  • status == "selector_mismatch":页面能打开,但解析规则已失效。

6.3 测试解析规则校验

假设配置了选择器title: "h1.post-title",但页面实际结构是div.article header h1。probe 阶段应该返回selector_mismatch,并报告具体哪个选择器没有匹配到元素。

预期输出示例:

{ "site": "example_blog", "status": "selector_mismatch", "checks": { "robots": {"allowed": true}, "http_status": 200, "selectors": { "ok": false, "failed": ["title"], "message": "Selector 'h1.post-title' did not match any element" } } }

这个功能非常实用。它相当于给每个规则增加了一个“自检”,避免改版后静默产出空数据。

6.4 验证是否进入抓取流程

探测成功后,执行 scrape 模式,观察:

  • 输出文件是否包含结构化的条目。
  • 字段是否与配置的选择器一一对应。
  • 抓取过程中是否出现重复请求、超时、断连。
  • 日志中是否记录了每个站点的开始时间、结束时间、抓取条目数。

如果一切正常,说明从 probe 到 scrape 的主链路已经打通。

7. 接口 API 调用与批量任务

7.1 提交单个探测任务

API 模式下,第三方工具可以统一调用。下面是一个 Python 客户端示例:

import requests import json api_url = "http://127.0.0.1:8765" # 先探测 probe_payload = { "site": { "name": "example_blog", "url": "https://example.com/posts", "user_agent": "Mozilla/5.0 (compatible; ProbeScraper/0.1)", "timeout": 15, "selectors": { "title": "h1.post-title" } } } resp = requests.post(f"{api_url}/api/probe", json=probe_payload, timeout=30) print(resp.status_code) print(json.dumps(resp.json(), ensure_ascii=False, indent=2))

如果探测返回ready,再提交抓取任务。

7.2 提交批量任务

批量任务不能只依赖单个请求,需要走队列或清单。

一个简单的批量任务目录结构:

jobs/ ├── sites/ │ ├── blog.yml │ ├── news.yml │ └── docs.yml ├── output/ │ └── 2025-01-01/

批量提交流程:遍历sites/下的配置文件,逐一向 API 提交探测任务,拿到结果后汇总。

import glob import yaml import requests import json api_url = "http://127.0.0.1:8765" for config_path in glob.glob("jobs/sites/*.yml"): with open(config_path, "r", encoding="utf-8") as f: site = yaml.safe_load(f) probe_resp = requests.post(f"{api_url}/api/probe", json={"site": site}, timeout=30) probe_result = probe_resp.json() if probe_result.get("status") == "ready": scrape_resp = requests.post( f"{api_url}/api/scrape", json={"site": site}, timeout=120 ) print(site["name"], "scraped, items:", len(scrape_resp.json().get("items", []))) else: print(site["name"], "skipped, reason:", probe_result.get("status"))

7.3 任务结果与重试

批量任务要考虑失败重试。建议对以下两类错误设置不同的重试策略:

  • request_failed、http_error:可能是临时网络问题或目标站抖动,适合退避重试,比如 3 次指数退避。
  • blocked_by_robots、captcha_or_banned、selector_mismatch:重试意义不大,应该直接落库并标记为“需要人工确认”。

重试示例:

import time def run_with_retry(func, max_retries=3): for attempt in range(max_retries): try: return func() except requests.RequestException as e: if attempt == max_retries - 1: raise time.sleep(2 ** attempt) return None

7.4 API 返回格式建议

为了让前端和其他服务好对接,API 统一返回一种格式会方便很多:

{ "success": true, "data": { "task_id": "task_20250101_001", "status": "ready", "message": "OK" }, "error": null }

这样调用方不用解析复杂嵌套结构,直接看successdata.status就行。

8. 资源占用与性能观察

8.1 资源占用特点

ProbeScraper 是纯 CPU 和网络 I/O 工具,不依赖 GPU,所以资源占用主要体现在:

  • 网络请求带来的等待时间。
  • Python 进程本身的内存占用,通常在几十 MB 到几百 MB,取决于并发数和页面大小。
  • 大批量任务时,日志和输出文件会持续增长,磁盘空间需要考虑。

8.2 观察指标

运行任务时,建议观察以下几个指标:

  • CPU 占用:一般不会持续满载,只有解析大 HTML 时会短暂升高。
  • 内存占用:如果并发过高,或者单页面返回几十 MB HTML,内存会显著上升。
  • 网络连接数:大量并发请求时,可能会触发目标站限流。
  • 任务耗时分布:probe 阶段很快,scrape 阶段慢,这是正常现象。

8.3 控制并发与限速

不要以最高速度跑批量任务。合理做法是:

  • 单站点并发设为 1 到 2。
  • 跨站点并发设为 5 到 10。
  • 每次请求之间增加随机延迟,例如 1 到 3 秒。
import random import time def polite_sleep(min_seconds=1, max_seconds=3): time.sleep(random.uniform(min_seconds, max_seconds))

8.4 显存占用说明

这类工具不涉及模型推理,不需要显卡,不存在显存占用问题。如果你的采集任务后续接入本地 NLP 模型做内容抽取,那是另外一回事,需要单独评估模型显存需求。

9. 常见问题与排查方法

问题现象可能原因排查方式解决方案
probe 提示 request_failedDNS 解析失败、目标站不可达、TLS 校验失败检查 ping/curl 访问情况更换 DNS,关闭非必要证书校验,确认网络出口
返回 403 或验证页被反爬拦截,User-Agent 异常,IP 被限流打开页面手动查看是否出现验证码调整 User-Agent,降低频率,配置代理出口
robots.txt 直接禁止抓取目标站点明确声明不允许查看响应里的 robots 字段停止抓取该站点,遵守规则
selector_mismatch 频繁出现目标网站改版、选择器写错抓取 HTML 样本手动验证选择器更新选择器,建立站点巡检任务
页面内容乱码编码检测失败,charset 与真实编码不一致查看响应头和页面 meta charset在配置中显式指定 encoding
API 服务启动后访问不到端口被占用,监听地址错误,防火墙查看启动日志,检查 netstat更换端口,监听 0.0.0.0,放行防火墙
批量任务中途卡住某个站点响应超时,没有设置 timeout查看日志定位卡住站点增加全局超时和单请求超时
输出文件为空探测阶段实际失败但任务未跳过查看 probe 结果修正站点配置,或强制跳过不可抓取站点

排查顺序建议:先看日志,再对比 probe 输出,最后手动用 curl 验证目标站。不要一上来就改代码。

10. 最佳实践与使用建议

10.1 第一次先小批量探测

不要一上来就提交 100 个站点。先取 3 到 5 个不同结构的站点跑一遍,确认:

  • 配置解析正确。
  • probe 阶段能区分不同失败类型。
  • scrape 输出内容符合预期。

10.2 维护一套最小站点配置

把测试站点、样例站点单独放在一个examples/目录,作为回归测试集。以后改动了解析逻辑、探测逻辑,先跑这个目录,能快速发现功能退化。

10.3 规范目录管理

建议固定目录结构:

probe-scraper/ ├── configs/ # 正式站点配置 ├── examples/ # 样例测试配置 ├── jobs/ # 批量任务输入 ├── output/ # 抓取结果 ├── logs/ # 运行日志 └── scripts/ # 辅助脚本

输入、输出、日志分开,后续写定时任务和故障排查都会轻松很多。

10.4 批量任务加日志和重试

批量任务必须加日志。至少记录:

  • 每个站点的 start_time、end_time。
  • probe 状态。
  • 抓取条目数。
  • 失败原因和重试次数。

没有日志的批量任务,一旦失败排查成本会非常高。

10.5 接口服务限制访问范围

如果 API 服务部署在公网,必须限制访问。至少做到:

  • 绑定非公网地址,必要时用内网访问。
  • 增加 API Token 或简单鉴权。
  • 对单 IP 限制调用频率。
  • 日志记录请求来源。

10.6 合规与版权

任何采集任务在你动手之前,都要确认:

  • 目标站点是否允许爬虫。
  • 数据是否涉及个人隐私。
  • 内容是否受版权保护。
  • 你的使用方式是否在授权范围内。

ProbeScraper 的价值恰恰在于:它把“能不能抓”这个判断放到任务最前面,让你在启动抓取之前就先遵守规则,而不是等被抓取后再考虑合规问题。

10.7 对站点结构变化保持敏感

网站改版后,解析规则失效是常态。建议定期对已配置站点执行一次 probe-only 巡检,把 selector_mismatch 的任务列为“需要更新配置”,而不要等到用户反馈后才处理。

11. 总结与下一步

这个项目最值得尝试的点,是它把“站点是否可抓取”变成了一次显式的、可观测的 probe 步骤,而不是藏在错误日志里。读完这篇文章,你最先应该验证的是:拿一个已知能正常抓取的博客页面和一个已经失效的旧 URL 分别跑一次 probe,观察输出结果是否能区分readyhttp_errorselector_mismatch

最容易踩的坑有两个:一是给 probe 阶段设置的超时过短,导致正常站点被误判;二是把 probe 结果等同于抓取成功,忽略了后续页面深度、内容格式等问题。建议把 probe 当作“入口检查”,把 scrape 后的数据校验当成另一个独立检查环节。

后续可以扩展的方向包括:把探测结果写入时序数据库做站点健康看板;接入 Playwright 处理 JS 渲染页面;给 API 服务增加任务队列,让批量任务通过消息中间件异步执行;以及在 scrape 阶段增加数据质量校验,比如字段缺失率、重复率、内容相似度。

如果你正在维护一组容易“悄悄失效”的采集任务,不妨先给它们加一层 probe 前置检查。这可能是投入产出比最高的一次小改动。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 12:34:11

YOLOv8果园成熟果实自动计数系统:从数据集到部署全流程解析

简介&#xff1a;本资源是一套基于YOLOv8的果园成熟果实自动计数完整实践项目&#xff0c;面向计算机科学、人工智能、自动化等专业的在校学生及初学者&#xff0c;解决农业场景中果实目标检测与精准计数的实际问题&#xff0c;适用于毕业设计、课程设计、大作业及项目立项演示…

作者头像 李华
网站建设 2026/8/31 12:33:11

纯惯导解算Matlab源码解析:从原理到工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:24:08

VMware 25H2汉化全攻略:从安装到界面切换一篇搞定

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:21:47

SpringBoot3+Vue3智慧管理平台开发指南:从架构设计到AI集成实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:18:49

从零搭建五角色多智能体团队:一人公司架构实践指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/8/31 12:18:41

NanoRL极简实现:用1800行代码读懂LLM强化学习训练核心原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华