Python驱动BinaryNinja插件开发:逆向分析自动化与调用图提取实战
简介这份资源面向逆向工程初学者与安全研究人员提供一套基于Python的Binary Ninja插件开发参考帮助读者通过脚本扩展反汇编平台的分析能力降低定制化工具的开发门槛。压缩包共6个文件约9KB以py脚本为核心配合json与yaml配置、md说明文档、license及gitignore等辅助文件结构精简便于快速理解插件工程的基本组成。内容围绕Binary Ninja的Python API展开涵盖插件注册方式、执行逻辑编写、插件目录放置与加载流程并给出高亮调用指令的示例代码同时延伸至自动化漏洞检测、恶意软件分析与代码审计等进阶方向。已有539人学习下载适合希望借助Python提升二进制分析效率、逐步掌握插件开发思路的读者参考。1. 逆向分析效率翻倍Python 驱动 BinaryNinja 插件到底能做什么如果你平时用 BinaryNinja 做逆向分析大概率遇到过这种场景面对一个 stripped 的二进制文件函数名全是sub_401000交叉引用要一个个点开看字符串引用要手动追分析完一个样本半天就没了。BinaryNinja 本身提供了 Python API但很多人只是偶尔在控制台敲两行脚本没有把它做成可复用的插件。这个资源包就是围绕 Python 开发 BinaryNinja 插件展开的包含插件框架模板、常用功能封装、以及几个典型场景的完整实现。它解决的核心问题是把重复性的逆向分析操作自动化让你从「手动点」变成「脚本跑」。适合有基本逆向基础、会写 Python、想让 BinaryNinja 用起来更顺手的从业者。不管你是刚接触 BinaryNinja 插件开发还是已经写过一些零散脚本想系统整理这份资源都能直接拿来改、拿来用。2. BinaryNinja 插件机制与 Python API 入口从加载流程到第一个可运行插件2.1 插件是怎么被 BinaryNinja 加载的BinaryNinja 的插件加载机制其实不复杂但第一次接触容易找不到入口。它会在启动时扫描几个固定目录常见的是用户插件目录不同平台路径不同Windows 一般在%APPDATA%\BinaryNinja\pluginsmacOS 在~/Library/Application Support/BinaryNinja/pluginsLinux 在~/.binaryninja/plugins。放在这里的.py文件会被自动执行执行时注册的 UI 动作、分析钩子、命令才会生效。插件脚本的典型结构是导入binaryninjaui和binaryninja定义初始化函数在函数里注册菜单项或后台任务最后在模块级别调用初始化。很多人翻车的地方是脚本写完了放进插件目录重启 BinaryNinja 却没反应。常见原因是脚本在导入阶段就抛了异常而 BinaryNinja 默认不会弹窗提示。排查方法是打开 BinaryNinja 的日志窗口View - Log看有没有 traceback。一个最小可运行的插件骨架长这样# minimal_plugin.py # 放在 BinaryNinja 插件目录下重启后生效 import binaryninja from binaryninja import PluginCommand from binaryninja.enums import PluginCommandContext def hello_world(bv): 最简单的插件入口在当前二进制视图上执行 binaryninja.log_info(f当前文件: {bv.file.filename}) binaryninja.log_info(f函数数量: {len(bv.functions)}) # 注册命令第一个参数是 BinaryView 类型表示在分析视图可用 PluginCommand.register( 示例插件\\输出基本信息, 打印当前二进制的函数数量, hello_world )这段代码的逻辑很直接PluginCommand.register把hello_world绑定到右键菜单。第一个参数是菜单路径用\\分隔层级第二个是描述第三个是回调函数。回调接收一个BinaryView对象也就是当前打开的文件。bv.functions返回所有已识别函数的列表bv.file.filename拿到文件路径。参数方面PluginCommandContext可以控制命令在什么场景下可用比如只在有函数选中时启用但最小骨架里不传也能跑。2.2 Python API 里最该先掌握的几类对象BinaryNinja 的 Python API 对象很多但日常插件开发高频用到的就那么几类。先搞清楚它们之间的关系后面写代码才不会迷路。BinaryView是你操作的入口代表一个加载的二进制文件。通过它拿函数列表、字符串、符号、段信息。Function对象代表一个函数有name、start、symbol等属性还能拿到basic_blocks做控制流分析。Symbol是符号表里的条目分函数符号、数据符号、导入符号等。ReferenceSource用来查交叉引用比如谁调用了这个函数、谁引用了这个字符串。我一般会先写一个「信息收集」插件把当前文件的基本情况打印出来确认 API 调用没问题再往上叠功能。比如下面这个脚本遍历所有函数输出函数名、地址、基本块数量# collect_info.py import binaryninja def collect_function_info(bv): 遍历所有函数输出名称、地址、基本块数量 for func in bv.functions: name func.name addr hex(func.start) block_count len(list(func.basic_blocks)) # 只输出基本块数量大于 5 的函数过滤掉小函数 if block_count 5: binaryninja.log_info(f{addr} {name} blocks{block_count}) PluginCommand.register( 示例插件\\收集函数信息, 输出基本块数量大于5的函数, collect_function_info )这里func.basic_blocks返回的是迭代器用list()转一下才能拿长度。func.start是整数地址hex()转成十六进制方便看。过滤条件block_count 5是随手加的实际用的时候可以改成按函数大小、按调用次数、按是否包含特定指令来筛。参数没有硬性要求但建议在插件里加日志输出方便确认执行到了哪一步。2.3 注册 UI 动作与后台任务的区别插件有两种常见的执行模式一种是用户手动触发比如右键菜单点一下另一种是后台自动跑比如文件加载后自动分析。前者用PluginCommand.register后者用BinaryView的钩子或者BackgroundTask。手动触发适合交互式分析比如「选中一个函数自动重命名所有调用它的函数」。后台任务适合批量处理比如「文件加载后自动提取所有字符串并标记可疑项」。后台任务要注意别阻塞 UI 线程BinaryNinja 提供了BackgroundTask类来跑耗时操作。一个常见的坑是在后台任务里直接操作 UI 对象会崩。正确做法是在后台线程里只做计算把结果存下来再通过binaryninja.mainthread.execute_on_main_thread回到主线程更新界面。这个细节在插件开发里很关键但很多示例代码没写清楚导致新手一跑就闪退。3. 从零写一个函数调用关系提取插件API 组合与数据落地3.1 需求拆解要拿到什么数据假设我们要做一个插件功能是遍历当前二进制里所有函数提取每个函数的调用者和被调用者最后输出成 JSON 文件。这个需求在逆向分析里很常见比如你想快速定位某个关键函数的调用链或者想把调用关系导入到外部工具做可视化。拆解下来需要几步第一步拿到所有函数第二步对每个函数查交叉引用找到谁调用了它第三步对每个函数查它调用了谁第四步把结果整理成字典第五步写文件。每一步都对应 BinaryNinja API 里的具体方法。3.2 核心代码交叉引用查询与调用图构建# callgraph_extractor.py import json import binaryninja from binaryninja import PluginCommand def extract_callgraph(bv): 提取函数调用关系输出为 JSON callgraph {} for func in bv.functions: func_addr hex(func.start) func_name func.name # 查谁调用了当前函数调用者 callers [] for ref in bv.get_code_refs(func.start): caller_func bv.get_function_at(ref.address) if caller_func: callers.append({ name: caller_func.name, address: hex(caller_func.start) }) # 查当前函数调用了谁被调用者 callees [] for block in func.basic_blocks: for instr in block: # 只处理调用指令 if instr.operation.name.startswith(CALL): target instr.dest # 尝试解析目标地址对应的函数 callee_func bv.get_function_at(target) if callee_func: callees.append({ name: callee_func.name, address: hex(callee_func.start) }) callgraph[func_addr] { name: func_name, callers: callers, callees: callees } # 写 JSON 文件 output_path bv.file.filename _callgraph.json with open(output_path, w, encodingutf-8) as f: json.dump(callgraph, f, indent2, ensure_asciiFalse) binaryninja.log_info(f调用图已输出到: {output_path}) PluginCommand.register( 示例插件\\导出调用图, 提取函数调用关系并输出 JSON, extract_callgraph )这段代码的关键点有几个。bv.get_code_refs(func.start)返回所有引用该地址的代码位置包括调用、跳转等。bv.get_function_at(ref.address)把引用地址转成函数对象如果引用来自非函数区域比如数据段返回None所以要判空。遍历基本块里的指令时instr.operation.name判断是不是调用指令不同架构的调用指令名称不一样x86 是CALLARM 是BL或BLX这里用startswith(CALL)只覆盖了 x86 场景实际用的时候需要按架构适配。参数方面json.dump的indent2让输出可读ensure_asciiFalse保留中文字符。输出路径直接拼在原始文件名后面简单但够用。如果文件在只读目录写文件会失败这时候需要加异常处理或者改成输出到用户指定目录。3.3 输出格式与外部工具对接JSON 格式的调用图可以直接导入到 NetworkX、Gephi 或者自己写的可视化脚本里。如果要做进一步分析比如找循环调用、找最短路径用 NetworkX 几行代码就能搞定# 在外部 Python 环境里分析调用图 import json import networkx as nx with open(sample_callgraph.json, r, encodingutf-8) as f: data json.load(f) G nx.DiGraph() for addr, info in data.items(): G.add_node(addr, nameinfo[name]) for callee in info[callees]: G.add_edge(addr, callee[address]) # 找所有环 cycles list(nx.simple_cycles(G)) print(f发现 {len(cycles)} 个循环调用)这里nx.simple_cycles返回所有简单环对于分析递归调用或者死循环很有用。G.add_node的name属性方便后面按函数名搜索。整个流程下来从 BinaryNinja 提取数据到外部工具分析链路是通的。4. 插件开发避坑指南加载失败、API 变更与性能问题4.1 插件不生效先看日志再怀疑代码现象脚本放进插件目录重启 BinaryNinja 后菜单里找不到注册的命令。原因最常见的是脚本导入阶段抛异常比如import了不存在的模块、语法错误、或者调用了不存在的 API。BinaryNinja 默认不弹窗报错只在日志里记录。解决打开View - Log看有没有 traceback。如果有ImportError检查依赖是否安装如果是AttributeError检查 API 名称是否拼错。另一个常见原因是插件目录路径不对可以在 BinaryNinja 控制台执行binaryninja.user_plugin_path()确认实际路径。4.2 API 版本差异同一段代码换个版本就跑不了现象在某个 BinaryNinja 版本上跑得好好的插件升级后报AttributeError或TypeError。原因BinaryNinja 的 Python API 在不同版本之间有变动比如某些方法改名、参数调整、返回值类型变化。解决在插件开头加版本检查用binaryninja.core_version()拿到当前版本对不兼容的 API 做分支处理。更稳妥的做法是锁定开发时用的版本在插件文档里写清楚兼容范围。我一般会在插件里加一个MIN_VERSION常量启动时判断不满足就打印警告并退出。4.3 性能问题遍历大文件时卡死现象插件在小文件上跑得很快换到几百 MB 的二进制文件就卡住UI 无响应。原因在主线程里做了耗时操作比如遍历所有函数的所有指令。BinaryNinja 的 UI 线程被阻塞后界面就卡死了。解决把耗时逻辑放到BackgroundTask里跑或者用binaryninja.mainthread.execute_on_main_thread把 UI 更新切回主线程。另外遍历指令时尽量用func.basic_blocks而不是func.instructions前者按块组织后者可能触发大量重复计算。如果只需要函数级别的信息别深入到指令级别。4.4 写文件权限与路径问题现象插件执行到写文件那一步报PermissionError。原因输出路径指向了只读目录或者文件名里包含了非法字符。解决写文件前先检查目录是否可写用os.access(os.path.dirname(path), os.W_OK)判断。更稳妥的做法是让用户在插件里选择输出目录或者默认输出到临时目录。另外Windows 上文件名不能包含:/\|?*这些字符如果原始文件名里有需要先做 sanitize。4.5 交叉引用查不到分析没跑完就执行插件现象插件里查交叉引用结果为空但手动在界面里能看到引用。原因BinaryNinja 的分析是异步的文件刚加载时分析可能还没完成这时候查引用拿不到完整结果。解决在插件里判断分析状态用bv.analysis_progress()看进度或者等bv.update_analysis_and_wait()跑完再执行。如果插件是后台自动触发的最好监听分析完成事件而不是在文件加载后立刻执行。5. 进阶技巧把插件做成可配置、可复用的分析流水线5.1 用配置文件驱动插件行为写多了插件会发现很多逻辑是通用的只是参数不同。比如「提取调用图」这个功能有的场景要过滤掉库函数有的场景要包含所有函数。与其改代码不如把配置抽出来。常见做法是在插件目录放一个config.json插件启动时读取用户改配置不用动代码。# config_driven_plugin.py import json import os import binaryninja CONFIG_PATH os.path.join( binaryninja.user_plugin_path(), callgraph_config.json ) DEFAULT_CONFIG { min_block_count: 3, exclude_library: True, output_suffix: _callgraph.json } def load_config(): 加载配置不存在则用默认值 if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH, r, encodingutf-8) as f: user_config json.load(f) # 合并默认配置和用户配置 config {**DEFAULT_CONFIG, **user_config} else: config DEFAULT_CONFIG # 首次运行时写出默认配置方便用户修改 with open(CONFIG_PATH, w, encodingutf-8) as f: json.dump(config, f, indent2) return config这里{**DEFAULT_CONFIG, **user_config}是 Python 3.5 的字典合并写法用户配置里的键会覆盖默认值。首次运行时写出默认配置用户能看到有哪些可调参数。binaryninja.user_plugin_path()拿到插件目录配置文件放在一起方便管理。5.2 插件之间的数据传递如果你写了一组插件比如一个负责提取函数信息一个负责提取字符串一个负责提取调用图它们之间的数据可以共享。常见做法是约定一个中间格式比如都输出到同一个目录下的 JSON 文件或者用 BinaryNinja 的bv.store_metadata在内存里传递。bv.store_metadata(key, value)可以把任意 Python 对象存到当前 BinaryView 上其他插件通过bv.query_metadata(key)取出来。这个机制适合在同一个会话里传递临时数据文件关闭后就没了。如果要持久化还是得写文件。5.3 验证插件效果用已知样本做回归测试插件写完后怎么确认它没跑偏我一般会准备几个已知样本比如一个自己编译的带符号二进制手动分析一遍记录下函数数量、调用关系、字符串列表然后跑插件对比输出是否一致。如果插件输出和手动分析对不上说明逻辑有问题。对于调用图提取插件验证方法是在 BinaryNinja 界面里手动点开几个函数看交叉引用列表和插件输出的 JSON 对比。如果某个函数的调用者在界面里有三个插件只输出了两个就要查为什么漏了一个。常见原因是引用类型过滤太严或者分析没跑完。5.4 一个我常用的调试习惯写插件时我会在关键步骤加日志用binaryninja.log_info输出中间结果。比如遍历函数时每处理 100 个函数打一条日志确认进度。查交叉引用时如果结果为空打一条警告把函数地址和名称带上。这样插件跑完后翻日志就能定位到哪一步出了问题。还有一个习惯是新插件先在 BinaryNinja 的 Python 控制台里逐段执行确认每个 API 调用返回符合预期再组装成完整插件。控制台里可以用bv binaryninja.load(/path/to/sample)手动加载文件然后一行行试。这样比反复重启 BinaryNinja 快得多。从那以后我每次写新插件都强制走一遍「控制台验证 API → 组装脚本 → 日志调试 → 已知样本回归」的流程省下来的时间远比花在调试上的多。希望帮到你。本文还有配套的精品资源点击获取

相关新闻

Python爬虫+Django+ECharts:招聘数据爬取分析可视化系统实战

Python爬虫+Django+ECharts:招聘数据爬取分析可视化系统实战

做毕业设计这几年,我经常被学生问同一个问题:到底是选一个老老实实的管理系统,还是选一个有技术含量的爬虫项目?这两类题目各有各的坑,管理系统容易做成“换皮增删改查”,爬虫项目又容易写到一半发现反爬成…

2026/10/9 14:23:44 阅读更多 →
Github Copilot 实战:从代码辅助到研发效能跃迁的 TaoToken 统一接入方案

Github Copilot 实战:从代码辅助到研发效能跃迁的 TaoToken 统一接入方案

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

2026/10/9 14:23:44 阅读更多 →
HX8394 MIPI屏幕驱动:时序校准、寄存器分阶段初始化与DTS实战

HX8394 MIPI屏幕驱动:时序校准、寄存器分阶段初始化与DTS实战

简介:本资源为嵌入式Linux平台下HX8394 MIPI显示屏的轻量级内核驱动实现,面向嵌入式驱动开发工程师、Linux BSP工程师及高校相关方向学习者,解决MIPI DSI接口TFT-LCD屏幕在主流SoC平台上快速适配与稳定显示的核心问题。压缩包仅含2个C源文件&…

2026/10/9 14:23:44 阅读更多 →

最新新闻

概率输出如何干掉幻觉:Kev 确定性判定的技术底牌

概率输出如何干掉幻觉:Kev 确定性判定的技术底牌

概率输出如何干掉幻觉:Kev 确定性判定的技术底牌 【免费下载链接】kev Jev-like family of decision models built on top of Qwen3.5/3.8 you can train and run on your own 项目地址: https://gitcode.com/gh_mirrors/kev2/kev 大模型落地到业务判定场景&…

2026/10/10 17:45:11 阅读更多 →
如何一键生成完整营销网站:full-stack-ai-agent-template落地页、定价页与博客实战指南

如何一键生成完整营销网站:full-stack-ai-agent-template落地页、定价页与博客实战指南

如何一键生成完整营销网站:full-stack-ai-agent-template落地页、定价页与博客实战指南 【免费下载链接】full-stack-ai-agent-template Full-stack AI app generator — FastAPI Next.js with AI Agents, RAG, streaming, auth, and 20 integrations out of the b…

2026/10/10 17:45:11 阅读更多 →
tldr 文档体系中的 systemctl condrestart:条件重启别名的用法与别名页实现

tldr 文档体系中的 systemctl condrestart:条件重启别名的用法与别名页实现

文档教程知识库 【免费下载链接】tldr Collaborative cheatsheets for console commands 📚. 项目地址: https://gitcode.com/GitHub_Trending/tl/tldr 点击查看 免费下载 本篇文章聚焦 tldr 仓库中的别名页 pages.bg/linux/systemctl-condrestart.md&a…

2026/10/10 17:45:11 阅读更多 →
KOReader图标换装简单粗暴:SimpleUI图标包与自定义图标,一键美化全套界面

KOReader图标换装简单粗暴:SimpleUI图标包与自定义图标,一键美化全套界面

【免费下载链接】simpleui.koplugin A highly customizable UI plugin for KOReader that features a home screen, bottom navigation bar, top bar and desktop modules/widgets. 项目地址: https://gitcode.com/gh_mirrors/si/simpleui.koplugin 点击查看 免费下…

2026/10/10 17:45:11 阅读更多 →
Hotdata CLI 全文检索指南:3行命令建好BM25索引,无需Elasticsearch也能秒级搜索

Hotdata CLI 全文检索指南:3行命令建好BM25索引,无需Elasticsearch也能秒级搜索

【免费下载链接】hotdata-cli CLI for Hotdata 项目地址: https://gitcode.com/gh_mirrors/ho/hotdata-cli 点击查看 免费下载 Hotdata CLI 是 Hotdata 的官方命令行工具,一条 hotdata search create 就能在数据库表列上建好 BM25 全文检索索引——不用…

2026/10/10 17:45:11 阅读更多 →
华为OD机考C卷:手机App防沉迷系统Java题解与实战

华为OD机考C卷:手机App防沉迷系统Java题解与实战

上周刚把华为OD机考C卷考完,抽到的正是手机App防沉迷系统这道题,Java语言,双机位全程盯着,说实话压力比平时刷题大不少。这道题本身不是那种让你背红黑树、写线段树的难题,但它把“时间区间 优先级抢占 同刻事件”三…

2026/10/10 17:44:10 阅读更多 →

日新闻

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

卫星轨道分类全解析:从LEO到GEO的选型逻辑与工程实践

1. 从“卫星轨道分类”这个标题说起:为什么值得花时间搞懂第一次接触“卫星轨道分类”这个概念,很多人会觉得它离自己很远——不就是天上的星星怎么转吗?但如果你正在做航天任务规划、遥感数据接收、星座设计,甚至只是准备一场航天…

2026/10/10 0:00:39 阅读更多 →
Spring AOP 核心原理与实战:从概念到日志切面落地

Spring AOP 核心原理与实战:从概念到日志切面落地

1. 从一个真实痛点说起:为什么你的代码里到处都是重复逻辑刚入行那会儿,我写过一个用户管理模块,注册、登录、改密码、注销四个接口。每个接口里都塞了几乎一样的日志打印、参数校验、事务开启和提交。当时觉得没什么,能跑就行。直…

2026/10/10 0:00:40 阅读更多 →
Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

Python招聘数据采集与分析可视化:从采集清洗到薪资技能城市可视化全链路

简介:这是一套面向计算机相关专业学生与项目实战学习者的Python数据采集与分析可视化完整项目,以Boss直聘岗位数据为对象,适合用作毕业设计、课程设计或期末大作业。资源包共38个文件,约246KB,以13个py源码文件为核心&…

2026/10/10 0:00:40 阅读更多 →

周新闻

KT148A语音芯片外挂8002D功放的工程实践指南

KT148A语音芯片外挂8002D功放的工程实践指南

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

2026/10/10 11:14:25 阅读更多 →
LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

LLC谐振变换器增益公式推导:从FHA等效到完整归一化表达式

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

2026/10/10 1:36:08 阅读更多 →
ARM架构深度解析:从RISC设计理念到交叉编译实战

ARM架构深度解析:从RISC设计理念到交叉编译实战

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

2026/10/10 11:14:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/10 5:23:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/9 21:32:20 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/10 10:38:42 阅读更多 →