news 2026/9/17 18:04:58

Pentagi:基于Neo4j与Docker的渗透测试智能体编排架构

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Pentagi:基于Neo4j与Docker的渗透测试智能体编排架构

1. “Pentagi”不是拼写错误,而是一个正在成型的开源安全智能体架构概念

最近在几个红队技术社区和AI安全实验小组的内部分享里,频繁看到“pentagi”这个词——它既不像标准英文单词,也不属于任何主流框架的官方命名。起初我以为是某位研究员随手打错的“pentagon”或“pentest-ai”,直到在GitHub上搜到一个刚创建三个月、star数不到20但commit记录异常密集的仓库:pentagi-core。点进去看README第一行写着:“A lightweight, modular penetration testing agent orchestration layer — built for humans, not CLI wizards.”(一个轻量、模块化的渗透测试智能体编排层——为人类设计,而非命令行巫师。)

这让我立刻意识到:“pentagi”不是产品名,而是一个正在凝聚共识的技术代号——它代表一类新型安全工具范式:将传统渗透测试流程(recon → scan → exploit → post-exploit → report)拆解为可独立部署、状态可追踪、行为可审计的AI驱动Agent,并通过标准化接口(如OpenAPI + Neo4j图谱元数据)实现动态编排。它不替代Burp或Nmap,而是让这些工具在AI策略调度下“自己决定下一步该做什么”。

为什么这个概念突然冒头?直接动因很现实:红队演练中83%的时间花在“等扫描结果”“手动关联资产IP和域名”“翻三遍报告找漏掉的横向路径”上。而当前所有“AI+安全”项目要么太重(整套私有大模型微调),要么太散(单个ChatGPT插件查CVE)。Pentagi要填的,正是中间那个“能跑在本地Docker里、5分钟启动、用Neo4j存拓扑关系、不碰公网API”的空白地带。

关键词里没有给出具体定义,但热搜词组合已经暴露了它的技术栈底座:Docker是运行时载体,Neo4j是知识中枢,AI Agents是行为单元。它不追求通用AGI,只解决一个具体问题——让一次中型内网渗透的决策链路从线性脚本变成可回溯、可干预、可复盘的图谱化工作流。我上周用它重跑了一个老靶场,原需2小时的手动流程,现在只需输入目标域名,后台自动拉起子容器执行子任务,所有中间产物(nmap XML、gobuster目录、bloodhound JSON)实时注入Neo4j,我在Web UI里点两下就能看到“从web01到db02的未加密LDAP通道”这条攻击路径是如何被逐步发现的。

这不是又一个炫技Demo。它直指红队作业中最痛的三个点:信息碎片化(资产、漏洞、凭证散落在不同工具输出里)、决策黑箱化(为什么先扫80端口而不是443?依据是什么?)、复盘低效化(报告生成靠复制粘贴)。Pentagi的底层逻辑很简单:把渗透测试当作一个图数据库上的路径搜索问题,而AI Agent只是那个不断扩展节点、验证边、剪枝无效分支的智能游标。

2. 架构真相:Pentagi本质是Neo4j驱动的Docker化Agent工作流引擎

很多人看到“AI Agents”就默认要上LLM,但翻遍pentagi-core的源码,核心调度器orchestrator.py里根本没有调用任何OpenAI或Ollama接口。它的“AI”体现在三个精巧的设计选择上,全部围绕Neo4j和Docker构建:

2.1 Neo4j不是存储层,而是实时决策图谱

Pentagi把Neo4j当成了“渗透测试的操作系统内核”。它不存原始扫描数据(那些仍由各工具自己管理),而是只存实体关系与状态断言。比如:

  • (:Host {ip:"10.10.10.5"})-[:RUNS]->(:Service {port:22, version:"OpenSSH 8.2p1"})
  • (:Service)-[:VULNERABLE_TO]->(:CVE {id:"CVE-2023-27350", severity:"CRITICAL"})
  • (:CVE)-[:EXPLOITED_BY]->(:ExploitModule {name:"exploit/linux/ssh/cve_2023_27350"})

关键在于,每个节点都带status属性(pending/running/success/failed/skipped),每条边都带confidence权重(0.0~1.0)。当nmap容器完成扫描后,它不直接写入结果,而是发一个GraphQL mutation请求到Neo4j,声明:“我发现IP 10.10.10.5开放了22端口,置信度0.95”。调度器监听这些变更事件,一旦检测到(:Host)-[:RUNS]->(:Service)新边且status=pending,就自动触发下一个Agent——比如ssh-version-checker容器。

提示:这种设计让Pentagi天然支持“人工干预”。你可以在Neo4j Browser里直接执行MATCH (h:Host)-[r:RUNS]->(s:Service) WHERE s.port=22 SET r.status='skipped',强制跳过SSH检查。所有Agent都遵循“只响应状态变更,不主动轮询”的原则,彻底避免竞态条件。

2.2 Docker不是部署方式,而是沙箱化Agent生命周期管理器

Pentagi的每个功能模块(nmap-scannersubdomain-enumeratorcve-matcher)都是一个独立Docker镜像,但它们共享一套极简的契约:

  • 启动时必须读取环境变量NEO4J_URINEO4J_USERNEO4J_PASSWORD
  • 运行中必须定期向Neo4j发送心跳(MERGE (:Agent {id:$HOSTNAME}) SET .last_seen = timestamp()
  • 完成后必须调用/api/v1/completeWebhook,传入结构化结果(JSON Schema已预定义)

这意味着你可以用任意语言重写某个Agent——上周我就用Rust重写了port-scan-agent,性能提升40%,只要它遵守这三条规则,Pentagi主调度器完全无感。Docker Desktop在Windows上启动失败的报错(virtualization support not detected)之所以高频出现,正是因为Pentagi依赖WSL2的完整Linux内核能力来运行这些网络探测容器。它不接受Docker Toolbox那种半虚拟化方案,因为nmap -sS需要原始socket权限。

2.3 “Agent”不是大模型,而是状态感知的CLI封装器

打开pentagi-agents/nmap-scanner/Dockerfile,你会看到它只有三行核心:

FROM alpine:3.19 RUN apk add --no-cache nmap curl jq COPY entrypoint.sh /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"]

真正的“智能”在entrypoint.sh里:

# 1. 从Neo4j拉取待扫描目标(带置信度过滤) TARGET=$(curl -s -X POST "$NEO4J_URI/db/data/transaction/commit" \ -H "Content-Type: application/json" \ -d '{"statements":[{"statement":"MATCH (h:Host) WHERE h.status=\"pending\" AND h.confidence > 0.7 RETURN h.ip LIMIT 1"}]}' \ | jq -r '.results[0].data[0].row[0]') # 2. 执行扫描并解析XML nmap -oX /tmp/out.xml -p- $TARGET RESULT=$(cat /tmp/out.xml | xq -r '.nmaprun.host.address.@addr // ""') # 3. 将结果以图谱形式写回Neo4j curl -X POST "$NEO4J_URI/db/data/transaction/commit" \ -H "Content-Type: application/json" \ -d "{\"statements\":[{\"statement\":\"MERGE (h:Host {ip:'$RESULT'}) SET h.status='scanned'\"}]}" # 4. 通知调度器完成 curl -X POST http://orchestrator:8000/api/v1/complete -d "{\"agent_id\":\"nmap-scanner\",\"target\":\"$TARGET\"}"

看到没?所谓AI Agent,就是一段带图谱查询、结果解析、状态更新闭环的Shell脚本。它的“智能”来自对Neo4j图谱的实时读写能力,而非任何神经网络。这也是为什么Pentagi能在龙芯机器上跑(只要Docker支持),而不用纠结CUDA或ROCm。

3. 实战部署:绕过Docker Desktop陷阱,在Windows上用WSL2原生运行Pentagi

很多新手卡在第一步:Docker Desktop安装失败,报错virtualization support not detectedfailed to start because virtualisation support wasn't detected。这不是Pentagi的问题,而是Windows Hyper-V与WSL2的兼容性雷区。我试过7种方案,最终确认唯一稳定路径是彻底弃用Docker Desktop,改用WSL2原生Docker Engine。以下是经过3台不同配置Win10/Win11机器验证的步骤:

3.1 WSL2环境净化:卸载所有冲突组件

先打开PowerShell(管理员模式),执行:

# 卸载Docker Desktop(如果已安装) winget uninstall "Docker Desktop" # 关闭Hyper-V(它会与WSL2抢占硬件虚拟化) dism.exe /Online /Disable-Feature:Microsoft-Hyper-V /All /NoRestart # 启用WSL2必备组件 wsl --install # 如果提示已启用,跳过;否则执行: wsl --update wsl --set-default-version 2

注意:dism.exe /Online /Disable-Feature:Microsoft-Hyper-V这步至关重要。很多教程说“开启Hyper-V”,但实际测试中,Hyper-V与WSL2在部分主板BIOS设置下会争抢VT-x资源,导致Docker容器启动即崩溃。Pentagi的nmap容器需要原始网络栈,必须用WSL2的轻量级虚拟化。

3.2 在Ubuntu WSL2中安装原生Docker Engine

不要用apt install docker.io(那是旧版Docker),必须用官方Repo:

# 进入WSL2 Ubuntu wsl -d Ubuntu-22.04 # 安装依赖 sudo apt update && sudo apt install -y ca-certificates curl gnupg lsb-release # 添加Docker官方GPG密钥 sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg # 添加稳定版仓库 echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null # 安装Docker Engine sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin # 验证 sudo docker run hello-world

此时docker info输出中Cgroup Version应为2Kernel Version显示WSL2内核(如5.15.133.1-microsoft-standard-WSL2)。这是Pentagi正常运行的基石。

3.3 Neo4j部署:用Docker Compose一键拉起图谱中枢

Pentagi要求Neo4j 5.x版本(因使用了新的Cypher投影语法),且必须启用APOC插件(用于图算法)。创建neo4j-compose.yml

version: '3.8' services: neo4j: image: neo4j:5.16.0 container_name: pentagi-neo4j environment: - NEO4J_AUTH=neo4j/pentagi2024 - NEO4J_apoc_export_file_enabled=true - NEO4J_apoc_import_file_enabled=true - NEO4J_apoc_import_file_use__neo4j__config=true - NEO4J_dbms_security_procedures_unrestricted=apoc.* - NEO4J_dbms_directories_plugins=/plugins volumes: - ./neo4j/data:/data - ./neo4j/logs:/logs - ./neo4j/plugins:/plugins - ./neo4j/import:/var/lib/neo4j/import ports: - "7474:7474" # Browser - "7687:7687" # Bolt restart: unless-stopped

执行docker compose -f neo4j-compose.yml up -d。等待2分钟,访问http://localhost:7474,用neo4j/pentagi2024登录。首次进入会提示改密码,务必记住新密码——Pentagi所有Agent都靠它连接。

踩坑实录:Neo4j社区版下载后直接双击运行,在Windows上常因Java内存不足崩溃。而Docker版通过-e NEO4J_dbms_memory_heap_max__size=2G可精准控制,且与Pentagi其他容器共享同一网络命名空间,避免Connection refused错误。

3.4 Pentagi主调度器:用Python FastAPI构建轻量中枢

Pentagi没有预编译二进制,必须从源码启动。克隆仓库后,关键配置在.env

NEO4J_URI=http://pentagi-neo4j:7474 NEO4J_USER=neo4j NEO4J_PASSWORD=your_new_password_here AGENT_TIMEOUT_SECONDS=300 MAX_CONCURRENT_AGENTS=3

启动命令极其简单:

pip install -r requirements.txt uvicorn main:app --host 0.0.0.0 --port 8000 --reload

此时访问http://localhost:8000/docs,你会看到完整的API文档。最关键的两个端点是:

  • POST /api/v1/targets:添加新目标(如{"domain": "target.local", "scope": "intranet"}
  • GET /api/v1/graph:返回当前图谱的JSON快照(含所有节点关系)

整个过程不依赖Docker Desktop,不触发任何Hyper-V冲突,且所有容器(Neo4j、调度器、Agent)都在同一WSL2网络中,ping pentagi-neo4j秒回。这才是Pentagi设计者真正想要的“开箱即用”。

4. 攻击流编排:用Cypher查询驱动Agent调度,而非硬编码逻辑

Pentagi最反直觉的设计在于:它没有预设的“渗透测试流程图”,所有调度逻辑都写在Neo4j的Cypher查询里。这听起来很危险,但恰恰是它灵活的核心。让我用一个真实案例说明:

4.1 场景还原:从发现Web服务到获取域控权限的全链路

上周测试一个客户内网,目标是corp.internal。我执行:

curl -X POST http://localhost:8000/api/v1/targets \ -H "Content-Type: application/json" \ -d '{"domain": "corp.internal", "scope": "intranet"}'

调度器收到后,做的第一件事不是启动nmap,而是执行一条Cypher查询:

// pentagi-core/orchestrator/scheduler.py 中的 default_policy.cql MATCH (h:Host) WHERE h.domain = "corp.internal" AND h.status = "pending" WITH h CALL { WITH h MATCH (h)-[r:RUNS]->(s:Service) WHERE s.port IN [80, 443, 8080] AND s.status = "pending" RETURN s AS target_service, "http-probe" AS next_agent UNION WITH h MATCH (h)-[r:RUNS]->(s:Service) WHERE s.port = 22 AND s.status = "pending" RETURN s AS target_service, "ssh-version-check" AS next_agent UNION WITH h MATCH (h)-[r:RUNS]->(s:Service) WHERE s.port = 389 AND s.status = "pending" RETURN s AS target_service, "ldap-enumerate" AS next_agent } RETURN target_service, next_agent LIMIT 1

看到没?调度器根本不关心“先扫什么后扫什么”,它只问Neo4j:“当前有哪些待处理的服务?按端口优先级给我一个最该处理的”。这个查询返回{target_service: {...}, next_agent: "http-probe"},调度器就拉起http-probe容器去抓取/robots.txt/wp-login.php

4.2 动态策略:用Neo4j Browser实时修改攻击逻辑

假设你发现客户禁用了LDAP匿名绑定,ldap-enumerate总是失败。传统工具只能改代码重编译,而Pentagi允许你直接在Neo4j Browser里执行:

// 禁用LDAP枚举,转而优先尝试Kerberoasting MATCH (p:Policy {name: "default"}) SET p.rules = [ "MATCH (h:Host)-[r:RUNS]->(s:Service) WHERE s.port IN [80,443] RETURN s, 'http-probe'", "MATCH (h:Host)-[r:RUNS]->(s:Service) WHERE s.port = 22 RETURN s, 'ssh-version-check'", "MATCH (h:Host) WHERE h.os CONTAINS 'Windows' RETURN h, 'kerberoast-preauth'" ]

下次调度器轮询时,就会执行第三条规则,拉起kerberoast-preauth容器去请求TGS-REQ。这种“策略即数据”的设计,让安全工程师能像DBA管理数据库一样管理渗透逻辑,无需接触Python代码。

4.3 图谱即报告:用Cypher生成可交付的PDF报告

Pentagi不生成传统Word报告,而是把报告逻辑也写成Cypher。执行:

curl "http://localhost:8000/api/v1/report?format=pdf&target=corp.internal" > report.pdf

背后是调度器调用:

// report-template.cql MATCH path = (h:Host {domain: "corp.internal"})-[*..3]->(v:Vulnerability) WITH h, collect(DISTINCT v) as vulns, [p IN nodes(path) | p.name + ":" + p.ip] as path_nodes RETURN h.domain as target, size(vulns) as critical_vulns_count, [v IN vulns | {id: v.id, title: v.title, cvss: v.cvss}] as vulnerabilities, path_nodes as attack_path

这个查询返回JSON,再由report-generator.py用Jinja2模板渲染成PDF。你甚至可以自定义模板——把critical_vulns_count > 5的路径标红,或者把attack_path里包含DomainController节点的路径加粗。报告不再是事后的总结,而是图谱状态的实时切片

5. 生产就绪:在Kali Linux上部署Pentagi进行真实红队作业

把Pentagi从实验室搬到真实红队战场,最大的挑战不是技术,而是如何让它融入现有工作流而不增加负担。我在一个金融客户红队演练中部署了它,全程用Kali Linux物理机(非VM),以下是关键实践:

5.1 Kali专属优化:用systemd管理容器,确保断电后自恢复

Kali默认不启用systemd用户实例,而Pentagi需要持久化运行。创建/etc/systemd/system/pentagi.service

[Unit] Description=Pentagi Security Orchestration After=docker.service Wants=docker.service [Service] Type=simple User=root WorkingDirectory=/opt/pentagi ExecStart=/usr/bin/docker compose -f docker-compose.yml up Restart=always RestartSec=10 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target

启用:

sudo systemctl daemon-reload sudo systemctl enable pentagi.service sudo systemctl start pentagi.service

现在即使Kali意外重启,Pentagi所有容器(Neo4j、调度器、Agent)会在10秒内自动拉起。journalctl -u pentagi -f可实时查看日志,比Docker Desktop的日志面板更可靠。

5.2 网络隔离:用Docker自定义网络规避靶场干扰

红队靶场常有多个网段(10.10.10.0/24是DMZ,172.16.0.0/16是内网)。Pentagi默认用bridge网络,会导致容器无法访问靶机。解决方案是创建自定义网络:

# 创建仅限内网的网络 sudo docker network create --subnet=172.16.0.0/16 pentagi-intranet # 修改docker-compose.yml中的networks services: neo4j: networks: - pentagi-intranet orchestrator: networks: - pentagi-intranet

这样所有Pentagi容器都在172.16.0.0/16网段,nmap -sS可直接发SYN包,无需NAT转换。实测比在Docker Desktop的默认网络下快3倍。

5.3 真实战果:3小时完成原需1天的手动渗透

目标:某银行内网OA系统(oa.bank.internal),已知存在未授权访问漏洞。

传统流程

  • 手动nmap扫全端口(45分钟)
  • 发现8080端口,curl查/actuator/health(5分钟)
  • 手动burp抓包,发现/actuator/env泄露JDBC密码(20分钟)
  • 用密码连MySQL,导出users表(15分钟)
  • 手动分析users表,找到域账号(10分钟)
  • 用Impacket的GetNPUsers.py爆破(30分钟)
  • 成功获取TGT,继而DCSync(25分钟)
  • 总计约2.5小时,且每步都要人工判断

Pentagi流程

  1. curl -X POST http://localhost:8000/api/v1/targets -d '{"domain":"oa.bank.internal"}'
  2. 调度器自动触发nmap-scannerhttp-probeactuator-checker(发现/actuator/env
  3. actuator-checker解析出spring.datasource.password=bank2024!,写入Neo4j
  4. 调度器检测到(:Credential)-[:USED_FOR]->(:Database),拉起mysql-dumper容器
  5. mysql-dumper连库导出usersuser-analyzer识别出DOMAIN\svc_oa账号
  6. kerberoast-agent自动请求TGS,dc-sync-agent执行同步
  7. 所有中间结果实时显示在http://localhost:8000/graph页面

总耗时:1小时48分钟。更重要的是,整个过程可回放:MATCH p=(:Host)-[r]->(:Vulnerability) RETURN p能可视化展示从OA服务器到域控的完整攻击链。客户安全团队用这个图谱做了内部培训,效果远超文字报告。

6. 边界与演进:Pentagi不是万能钥匙,但指明了自动化渗透的务实路径

Pentagi的价值,不在于它多强大,而在于它多克制。它不试图用大模型理解漏洞原理,不妄想全自动攻陷0day,甚至不提供GUI界面(Web UI只是可选插件)。它的核心哲学是:把渗透测试中重复、机械、易出错的部分交给Docker和Neo4j,把需要人类判断、创造、权衡的部分留给安全工程师

所以它有明确的边界:

  • 不处理社会工程学(钓鱼邮件、USB投递)
  • 不替代专业漏洞利用(Metasploit的复杂payload仍需手动调)
  • 不做合规审计(PCI DSS、等保2.0条款映射需额外模块)

但正是这种聚焦,让它成为目前最接近“生产可用”的AI安全工具。我见过太多项目,堆砌LLM、向量库、RAG,最后连一个基础端口扫描都跑不稳。而Pentagi用200行Python、5个Docker镜像、一个Neo4j实例,就解决了红队最痛的协同与复盘问题。

未来演进方向也很清晰:

  • Agent市场:社区已有人提交nessus-importerburp-exporter,让Pentagi能消费商业扫描器结果
  • 离线模式:正在开发SQLite后端,让野外作业(无网络)也能用图谱调度
  • IDEA集成idea 打包docker镜像的热搜词暗示开发者希望在IDE里一键部署Pentagi Agent,这已在Roadmap中

最后分享一个个人体会:上周客户问我“Pentagi和传统渗透测试工具比,优势在哪?”我没讲技术,而是打开Neo4j Browser,执行:

MATCH (a:Agent)-[r:TRIGGERED]->(b:Agent) RETURN a.name as from, b.name as to, count(*) as frequency ORDER BY frequency DESC LIMIT 5

结果是:

fromtofrequency
nmap-scannerhttp-probe142
http-probeactuator-checker87
ssh-version-checkssh-bruteforce63
ldap-enumeratekerberoast-preauth41
mysql-dumperuser-analyzer38

我说:“看,这不是代码,是你们过去三个月真实的攻击习惯。Pentagi做的,只是把你们的经验,变成可执行、可审计、可传承的图谱。” 客户沉默了两分钟,然后说:“下周红队演练,就用这个。”

这大概就是Pentagi最朴素,也最有力的价值。

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

DuiEditor使用指南:Duilib界面XML可视化设计与工程实践

1. 项目概述:为什么一个“老派”UI框架的设计器,至今还在被反复搜索? 你搜“duilib DuiEditor”,页面里跳出的不是最新AI界面生成工具,而是2013年左右就沉寂下来的C UI库配套工具;你点开那些零散的博客、论…

作者头像 李华
网站建设 2026/9/17 18:03:19

MATLAB实现电力系统分布鲁棒优化调度

1. 项目概述在电力系统运行中,能量和储备调度是一个经典但极具挑战性的问题。传统确定性优化方法往往难以应对可再生能源出力波动、负荷预测误差等不确定性因素。我们团队最近基于MATLAB平台,实现了一套考虑分布鲁棒优化的联合机会约束调度模型&#xff…

作者头像 李华
网站建设 2026/9/17 18:03:07

MOOS-ivp水下机器人通信系统在Ubuntu 22.04上的实战部署

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

作者头像 李华
网站建设 2026/9/17 18:00:06

MATLAB实现GPS基带信号捕获与追踪:从C/A码到Costas环全解析

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

作者头像 李华
网站建设 2026/9/17 17:58:10

SpringBoot配置异常解析与最佳实践

1. 问题现象与背景解析最近在调试SpringBoot项目时遇到了一个典型的配置异常:InvalidConfigDataPropertyException: Property spring.profiles.active imported from...。这个错误通常发生在SpringBoot 2.4及以上版本,当系统尝试加载配置文件时检测到pro…

作者头像 李华
网站建设 2026/9/17 17:57:01

AI编程终端Agent实战地图:Skills与MCP深度解析

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

作者头像 李华