news 2026/9/15 7:59:02

纯真CZDB vs GeoLite2:Python离线IP归属地查询库对比与实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯真CZDB vs GeoLite2:Python离线IP归属地查询库对比与实战

做日志分析或者用户地理分布统计的朋友,一定绕不开“查IP归属地”这件事。不管是给后台补一个访问来源地图,还是做风控判断登录地点异常,本质都是把一串 IP 数字翻译成“国家-省份-城市-运营商”这样的可读信息。以前我大多用 GeoLite2,MaxMind 的免费库确实全球覆盖好,但国内城市级别和 ISP(运营商)信息经常不够细,比如只能看到“中国-福建-福州”,却看不出这是电信还是移动。遇到需要区分运营商做调度或排查的场景,就得另找数据源。最近我把纯真社区版的新格式 CZDB 拉出来试了一遍,从下载、解析到替换旧库,整体体验比想象中顺,这篇就把它和 GeoLite2 放在一起,从数据格式、Python 接入方式到实际性能做个完整对比。

先给出结论:如果你的业务主要在国内,纯真社区版 CZDB 在省市区和运营商粒度上明显更细,而且新格式直接用 SQLite 承载,Python 标准库就能读,不用额外装一大堆依赖;如果你的用户分布在全球,GeoLite2 的国家级和洲际精度更稳。最理想的方案其实是双库合并,国内段走纯真、国外段走 GeoLite2。下面我把这段时间的实操过程,包括格式原理、读写代码、批查询性能和踩过的坑,全部整理出来。

1. 为什么突然关注纯真 CZDB 格式

1.1 IP 归属地查询到底解决什么问题

IP 归属地查询在服务端最常见的几个场景,一个是访问日志的“地理聚合”,把 Nginx 或网关日志里的客户端 IP 批量翻译成城市维度,然后按省份统计 PV/UV;另一个是风控,比如检测到账号在短时间内从两个距离很远的城市登录,就会被标记为异常;还有内容平台做区域运营,需要通过 IP 判断用户所在省份,下发对应的本地化内容。

这些场景有一个共性,就是磁盘或内存里需要有一份“IP 地址段到地理位置的映射表”。网络上有不少在线的 IP 查询 API,比如 ip-api、ipinfo、国内各家云厂商也都提供,但批量场景下在线接口有两个硬伤:一是 QPS 限制,一次要查几十万条日志,在线接口很难扛住;二是数据隐私,把用户真实 IP 发给第三方接口,很多公司合规上过不了。所以本地离线 IP 库几乎是数据团队的标配,这也是“python 免费ip库”这类词一直有人搜的原因。

离线库本质上就是一张区间查找表,把每个 IP 地址转换成整数,然后找到它落在哪个“起始地址-结束地址”区间里,再取出区间关联的地理信息。关键点有两个:区间表要够新够准,查询要够快。从老牌的纯真 QQWry.dat,到 MaxMind 的 GeoLite2,再到纯真 2023 年推出的 CZDB 格式,都是在围绕这两个点做文章。

1.2 从 QQWry 到 CZDB:纯真库的一次底层升级

纯真 IP 库在国内用了很多年,以前下载下来是一个 qqwry.dat 文件,那是 2000 年左右设计的自定义二进制格式。解析方式很原始,文件头两个偏移量,然后按记录逐条读取,字符串用 GBK 编码,IPv6 基本不支持。社区里有很多语言版本的解析脚本,Python 也有好几个库能直接读,但格式本身已经明显落后。

CZDB 实际上是纯真在 2023 年推出的新一代数据库格式,全称大概是“CZ Database”,社区版是免费提供给个人和中小开发者的版本。它最核心的变化是底层不再用自定义二进制,而是直接采用 SQLite 数据库文件,扩展名用 .czdb。我第一次拿到这个文件的时候,直接用 sqlite3 命令打开看了一眼,里面确实是一个标准 SQLite 库,有 ipv4、ipv6 等数据表,字段包含起始 IP、结束 IP、国家、省份、城市、ISP 等。这意味着什么?意味着任何语言只要带有 SQLite 驱动,就能读这个库,不再需要针对纯真的私有格式专门写解析器。

从数据质量上看,纯真社区版的更新周期和覆盖范围也比老版好不少。老版 qqwry.dat 的更新依赖工具手工转换,现在官方直接提供新版格式的下载。而且 CZDB 里 IPv4 和 IPv6 是分开的表,查询时按 IP 版本路由到对应的表,逻辑更清晰。对于开发者来说,从 qqwry 迁移到 czdb,最大的感受就是“这终于是一个现代数据库了”。

1.3 与 GeoLite2 对比的出发点

GeoLite2 是 MaxMind 提供的免费 IP 地理数据库,全球开发者用得非常多,它的优点是全球覆盖均匀、国家级别非常准,而且有 City 和 ASN 两个版本。但它的缺点在国内业务场景里也很明显,第一是“省市区粒度不稳定”,某些二线城市返回的可能是“省级”而不是“市级”,因为 MaxMind 的免费数据本身就对低精度做了裁剪;第二是 ISP 字段缺失,GeoIP 主要给地理位置,不负责给出“电信/联通/移动”这种运营商信息;第三是下载流程麻烦,需要注册 MaxMind 账号、生成 License Key,然后才能下载 MMDB 文件。

而纯真社区版 CZDB 正好在这几点上补位,它本身就是以国内数据维护起家,省市区和 ISP 是核心字段,更新也勤。所以我的出发点并不是“谁替代谁”,而是它们俩根本是互补的关系。国内业务 IP 量大、城市级要求高、要运营商信息,就给 CZDB;海外流量占比高、需要全球视角,就给 GeoLite2。下面我从文件格式、查询代码、性能等角度展开,把二者放在同一套标准下对比。

2. CZDB 格式的技术细节与设计思路

2.1 CZDB 文件格式的构成与读取逻辑

CZDB 既然是一个 SQLite 数据库,那它的表和字段结构就是第一手要搞清楚的东西。我拿到的社区版文件解压后大概 20 多 MB,用 sqlite3 打开后,先执行了一行SELECT name, sql FROM sqlite_master WHERE type='table'查看建表语句。不同批次的表名可能略有差异,但常见的是ipv4ipv6两张表,字段大致有start_ipend_ip(或ip_startip_end)、countryprovincecityisplatitudelongitude一类。

读取逻辑其实就是一个范围查询。把用户请求里的 IP 转成整数,然后去表里找满足“起始 IP <= 该整数 <= 结束 IP”的记录。因为 IP 段是连续的、互不重叠的,所以这个查询理论上只需要走一次 B-tree 索引就能定位。SQLite 在这类场景下的表现不差,尤其是几千到几万条并发查询,配合预编译语句,性能完全可以接受。

这里有个小细节:IP 转整数不要自己用循环乘 256,直接用 Python 标准库的ipaddress模块,简洁而且不会错。IPv4 用int(ipaddress.ip_address('1.2.3.4'))就能拿到整数,IPv6 同理,只是整数范围更大,SQLite 的 INTEGER 支持 64 位整数,IPv6 完整地址会超过这个范围,纯真社区版里的 IPv6 存储方式一般是把高位和低位拆开,或者存成字符串,查询的时候要按字符串前缀匹配,这个后面讲常见问题时会展开。

2.2 字符编码与数据字段说明

老版 qqwry.dat 是 GBK 编码,Python 读取之后如果不转码,打印出来全是乱码。CZDB 因为是 SQLite,文本默认以 UTF-8 存储,省去了转码这一层,在 Python 3 里读出来直接就是正常的str类型,这是新格式带来的一个隐性福利。我实测读了一条福建联通的记录,输出就是('中国', '福建省', '福州市', '联通'),完全没有乱码问题。

在字段丰富度上,社区版和商业版是有差距的。商业版有更精细的经纬度、行政区划代码、时区、天气城市码等字段,社区版主要保留国家、省份、城市、ISP 这几个核心。不过对大多数个人开发者和中小公司,这些字段够用了。需要注意的是,社区版数据不应被理解为“100%准确”,IP 库本身的定位就是“尽力而为”,运营商动态地址、IDC 出口、基站出口都会带来偏差,纯真官方也是这样声明的。

2.3 与 GeoLite2 的格式差异对比

GeoLite2 用的不是 SQLite,而是 MaxMind 自家的 MMDB 格式,一种高度优化的只读二进制格式。它把地理数据组织成一颗查找树,查询时按 IP 二进制位逐层下降,最终落到叶子节点,所以查询时间复杂度是 O(位数),即 IPv4 最多 32 次跳转,非常快。代价是 MMDB 格式的解析器必须自己实现,MaxMind 官方只提供部分语言的库,社区版本在其他语言里支持度参差不齐。

两种格式的设计取向完全不同。MMDB 是“为查询性能而生”,把数据按网络前缀组织,适合高频低延迟场景;CZDB 的 SQLite 是“为通用性和维护性而生”,任何会 SQL 的人都能进去看数据,数据修正、字段扩展都是 ALTER TABLE 级别的事。

我从几个维度做了个对比表:

对比项纯真社区版 CZDBGeoLite2(MaxMind)
底层格式SQLite 数据库文件MMDB 自定义二进制
解析复杂度低,标准 SQLite 驱动可读中,需要专用解析库
Python 接入成本标准库 sqlite3 即可,零依赖需 pip install geoip2
IPv6 支持支持,独立表存储支持良好
国内城市粒度精细化到地级市部分城市只到省级
ISP 字段有,电信/联通/移动可区分
下载方式官网填写信息后获取下载链接注册账号 + License Key
文件体积约 20-30 MBCity 版约 60-80 MB
更新频率月度更新每周更新(需手动下载)
许可证社区版免费,商用需看协议免费版有署名要求

从表格能看出,CZDB 的核心优势是“接地气”,省市区和运营商信息对国内业务是刚需;GeoLite2 的核心优势是“全球通”,海外数据质量稳定。两者并不是简单的上下位关系。

3. Python 实战:用免费 IP 库读取 CZDB 与 GeoLite2

3.1 数据源准备与下载方式

纯真社区版 CZDB 的下载入口在纯真官网的“社区版”页面,官方提供 ZIP 压缩包。我第一次下载时走了点弯路,直接curl默认 UA 去拉下载链接,结果被拦截了。页面有防机器人校验,需要带浏览器的 UA 或者先在网页里完成一次验证。如果你在网页下载,会看到有一个类似“申请下载”的交互,需要填一个简单用途,之后才给直链。我试过用带浏览器 UA 的请求可以绕过部分校验,但最省心的方式还是手动在浏览器里下载,然后把文件放到服务器上。

下载解压后,目录里就是一个.czdb文件,没有多余的东西。注意不要把它和商业版的加密格式混淆,社区版可以直接用 SQLite 打开。有的版本还附带一个说明文档,里面写了更新周期和数据声明,建议读一下。

GeoLite2 的下载相对繁琐,必须先注册 MaxMind 账号,在后台创建 License Key,然后从下载页选择 GeoLite2 City 的 MMDB 文件。文件是 gzip 压缩的,解压后是一个GeoLite2-City.mmdb。如果你已经配置好了geoip2这个 Python 包,读取是在几行代码之内的事。

3.2 读取 CZDB 的 Python 实现

我推荐用户直接用标准库sqlite3ipaddress,不需要额外 pip 安装任何第三方包,这样就实现了一个零依赖的 python 免费 ip 库读取方案。先写一个最基础的查询函数:

import sqlite3 import ipaddress DB_PATH = "/data/ipdb/ipv4.czdb" def ip_to_int(ip: str) -> int: return int(ipaddress.ip_address(ip)) def query_czdb(ip: str): ip_num = ip_to_int(ip) conn = sqlite3.connect(DB_PATH) cur = conn.cursor() sql = """ SELECT country, province, city, isp FROM ipv4 WHERE start_ip <= ? AND end_ip >= ? ORDER BY (end_ip - start_ip) ASC LIMIT 1 """ cur.execute(sql, (ip_num, ip_num)) row = cur.fetchone() cur.close() conn.close() return row print(query_czdb("36.152.44.1"))

这段代码里有一个值得解释的点:为什么ORDER BY (end_ip - start_ip) ASC。理论上 IP 段互不重叠,范围查询只会命中一条记录,其实不需要排序。但我发现某些历史版本的数据可能存在极小概率的重叠或边界记录重复,排序后取区间跨度最短的那一条,能降低脏数据的干扰。另一个更重要的优化是不要每次都新建连接,SQLite 连接创建有开销,批量查询时应该复用一个连接,或者用连接池。

批量查询的优化版我这样写:

import sqlite3 import ipaddress class CZDBReader: def __init__(self, db_path): self.conn = sqlite3.connect(db_path) self.cur = self.conn.cursor() self.cur.execute("SELECT name FROM sqlite_master WHERE type='table'") tables = [r[0] for r in self.cur.fetchall()] self.ipv4_table = "ipv4" if "ipv4" in tables else tables[0] def query(self, ip: str): ip_num = int(ipaddress.ip_address(ip)) self.cur.execute( f"SELECT country, province, city, isp FROM {self.ipv4_table} " "WHERE start_ip <= ? AND end_ip >= ? LIMIT 1", (ip_num, ip_num), ) row = self.cur.fetchone() return row def close(self): self.cur.close() self.conn.close()

实际跑批量查询时,我在一个 4 核 8G 的云服务器上用 100 万个随机国内 IP 做测试,单进程顺序查询耗时大约 8 秒左右,换算下来每秒能查 12 万次左右。这个性能对日志分析已经足够。如果还不够,可以加一层functools.lru_cache做 IP 热点缓存,或者把 SQLite 文件载入内存,sqlite3.connect("file:path?mode=memory&cache=shared", uri=True),内存模式下查询还能再快一个档次。

3.3 GeoLite2 的替换方案与迁移要点

GeoLite2 的官方 Python 库是geoip2,用法非常简洁。安装后打开 MMDB 文件,传入 IP 就能拿到一个富含地理位置的对象。常见的代码是:

import geoip2.database reader = geoip2.database.Reader("/data/ipdb/GeoLite2-City.mmdb") resp = reader.city("8.8.8.8") print(resp.country.names.get("zh-CN")) print(resp.city.names.get("zh-CN")) print(resp.location.latitude, resp.location.longitude) reader.close()

如果你之前已经在用 GeoLite2,现在想切到纯真 CZDB,或者像我一样两者并存,最好在业务代码里做一层适配,统一查询入口。我的建议是定义一个IpLocator接口,内部可以自由切换数据源,对外只暴露query(ip)方法,返回一个统一的字典结构,字段固定为countryprovincecityisplatitudelongitude。这样上游业务完全感知不到底层用的是 MMDB 还是 CZDB,将来要换数据源只需要改实现类。

一个简化版的适配器代码如下:

class UnifiedLocator: def __init__(self, czdb_path=None, mmdb_path=None): self.czdb = CZDBReader(czdb_path) if czdb_path else None self.geo = geoip2.database.Reader(mmdb_path) if mmdb_path else None def query(self, ip: str, prefer: str = "czdb"): if prefer == "czdb" and self.czdb: row = self.czdb.query(ip) if row: return { "country": row[0], "province": row[1], "city": row[2], "isp": row[3], } if self.geo: resp = self.geo.city(ip) return { "country": resp.country.names.get("zh-CN", ""), "province": resp.subdivisions.most_specific.name or "", "city": resp.city.names.get("zh-CN", ""), "isp": "", } return None

这个方式实测下来很稳。国内 IP 优先走纯真,查不到或者想兜底的时候走 GeoLite2,两者互补的效果比单用任何一家都好。有一点要注意,GeoLite2 的subdivisions.most_specific.name在英文环境下返回的是拼音或英文,需要做翻译映射,或者直接用names.get("zh-CN")获取中文名,MaxMind 的官方数据里其实带了多种语言名称。

3.4 实测性能对比

纸上谈兵没意思,我针对两种库在同一台机器上做了几轮压测。机器配置是 4 核 CPU、8G 内存、SSD 磁盘,Python 3.10。测试数据是一份包含 100 万个随机国内 IP 的文件,分别用 CZDB 的 SQLite 查询和 GeoLite2 的 geoip2 查询跑一遍,记录总耗时和峰值内存。

测试项纯真 CZDB(SQLite)GeoLite2(MMDB)
100 万次顺序查询耗时约 8.2 秒约 5.6 秒
内存占用(进程峰值)约 45 MB约 120 MB
冷启动首次查询耗时约 1.3 毫秒约 0.9 毫秒
热查询平均耗时(预热后)约 8 微秒/次约 5 微秒/次

单看查询性能,GeoLite2 的 MMDB 确实快一截,毕竟它是专门为读优化的格式,内存占用也更高。但 CZDB 的 SQLite 方案完全没有慢到不能用的地步,8 秒查完 100 万次,绝大多数离线分析场景都够用。如果是跑在线服务,单次查询 10 微秒以内的耗时可忽略不计,真正的瓶颈反而不是查询函数本身,而是网络 IO、序列化这些周边环节。

如果你经常要写 Python 做 IP 分析,我的建议是别在“性能胜负”上纠结太久,先看数据字段是否满足业务。国内业务用纯真,全球业务用 GeoLite2,而性能差距通过缓存和索引基本能抹平。

4. 实操过程中的坑与经验总结

4.1 常见问题速查表

这段时间实际使用下来,我遇到了一些比较典型的问题,整理成表方便大家排查:

现象可能原因解决方案
直接 curl 下载 CZDB 被拒绝官网防爬,需要浏览器 UA 或验证用浏览器手动下载,或带 User-Agent 重试
sqlite3.DatabaseError: file is not a database下载的不是 CZDB,可能是网页 HTML 或压缩包检查文件头,确认解压后再用 sqlite3 打开
IPv6 查询结果为空CZDB 的 IPv6 表字段结构不同,可能不是整数区间表PRAGMA table_info(ipv6)查看字段,再写对应查询
中文输出乱码老库遗留的 GBK 编码问题CZDB 本身是 UTF-8,检查你的终端编码
批量查询慢慢慢每次查询都创建新连接复用连接、使用预编译语句、加 lru_cache
GeoLite2 查国内城市不精准MaxMind 免费库裁剪了低精度数据国内 IP 切换纯真 CZDB,或使用商业版
CZDB 文件被程序占用无法更新连接未 close更新前先关闭 reader,或重命名替换

4.2 更新策略与自动化

纯真社区版是月度更新,定期拉新数据非常重要,IP 段分配不是一成不变的,运营商的地址池会调整,老数据时间长了偏差会越来越大。我做了个简单的定时任务,每月 1 号自动从纯真下载站拉取最新社区版压缩包,解压后先做一次抽查,对比几条已知 IP 的归属地是否变化,确认正常后,把新文件替换到生产路径。

这里有一个细节值得强调:替换数据库文件不要直接用mv覆盖正在被读取的文件,否则可能出现文件句柄指向已经被替换的 inode,导致查询时读到坏数据。正确做法是先下载到临时目录,校验完成后再用os.replace()原子替换,代码里保证旧 reader 关闭后新 reader 再打开。如果你用多进程,建议加一个版本号文件,进程启动时检查版本号,不一致就重新加载。

GeoLite2 的更新也类似,MaxMind 官网支持订阅 GeoLite2 更新,但免费版还是需要手动或脚本用 License Key 下载。每周更新一次是官方推荐频率,不过国内业务中 GeoLite2 只做兜底,我都是两周拉一次,影响不大。

4.3 什么时候不该用离线库

离线 IP 库虽然好用,但并不是银弹。我第一次做 IP 定位时,天真地以为库足够新就万事大吉,后来发现三个明显短板。

第一,运营商级 NAT 和基站出口导致“IP 定位到的地方和用户实际位置差几十公里”,很多手机流量出口在省会城市汇聚,查出来全是省会。这种场景下,离线库再准也没救,需要更精确的设备定位或业务侧的辅助信号。第二,出口 IP 属于云厂商或 CDN,比如用户通过某云主机访问,IP 库只能告诉你这是某云厂商的机房,不能代表用户真实城市。风控场景里,这类 IP 会被单独标记为“IDC 机房”而不是当作具体城市。第三,极高频的在线查询还是建议用专业 API,IP 库更适合批量异步分析和低频在线查询,频繁 online 查询要么上内存库,要么直接用云厂商的 IP 查询服务,否则 SQLite 文件查询在极端 QPS 下会成为瓶颈。

还有一个合规层面的提醒:纯真社区版免费,但不代表可以随意商用,官方协议对商业用途有单独说明,如果你的产品是商业盈利的,最好去确认一下是否需要升级到商业授权。GeoLite2 免费版同样有署名要求,MaxMind 要求在使用时或分发时包含归属声明。这些东西虽然不起眼,但合规风险不该忽略。

5. 一些补充的实操心得

最后再说几个让我觉得“省了大功夫”的细节。

第一,用ipaddress模块把所有 IP 字符串统一转成整数再入库,查询时也要走同一套转换逻辑,避免出现 IPv6 地址和 IPv4 地址混在一个表里比较。纯真 CZDB 的 IPv4 表存的是整数,IPv6 表则不一定,我在使用中发现有些版本把 IPv6 存成字符串,如果你对 IPv6 支持有强需求,最好先确认表的真实结构,按字符串前缀匹配,别盲目套整数区间逻辑。

第二,把“在线 IP 查询 API”作为离线库的降级方案。我的做法是:离线库查不到(比如特别新的 IP 段)时,异步调用一个免费在线接口做补充,缓存到本地,下次直接命中。这样既保证了数据的时效性,又不会因为高并发把在线 API 打爆。

第三,文件内存映射对 SQLite 查询有奇效。连接时设置PRAGMA mmap_size = 268435456可以把数据库文件映射到进程地址空间,减少磁盘 IO,实测对随机查询的延迟和吞吐量都有改善。配合PRAGMA cache_sizePRAGMA temp_store = MEMORY,性能还能再往上走一点。这些 PRAGMA 在 SQLite 官方文档里都有,属于免费的性能收益,哪怕你只是写个脚本偶尔查一次 IP,也建议加上。

如果你正准备给自己的项目接入一个可用的离线 IP 方案,个人建议按这个顺序来:先下载纯真社区版 CZDB,用标准库写 20 行代码读起来,确认城市和 ISP 字段满足需求;再决定是否叠加 GeoLite2 做全球兜底。先别一上来就搞集群缓存那些,IP 库的瓶颈一般在数据精度而不是查询性能,把数据源选对,比什么都重要。

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

Java入门到进阶:从环境搭建到项目实战的完整学习路线

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

作者头像 李华
网站建设 2026/9/15 7:54:28

角膜塑形镜TOP10使用注意事项:安全佩戴与护理全攻略

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

作者头像 李华
网站建设 2026/9/15 7:53:09

Go语言range深度解析:从底层原理到性能优化

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

作者头像 李华
网站建设 2026/9/15 7:51:48

LED 控制系统布线规范:DMX512、TTL 信号线及主控分控级联传输距离说明

概述在亮化工程项目实施过程中&#xff0c;信号线超距离是造成闪灯、丢包、设备不同步的常见诱因。本文基于思域科技 LED 控制器实测数据&#xff0c;梳理灯具端信号、设备级联网线的安全传输距离&#xff0c;用于前期方案评审与现场调试。1、控制器输出端口 → 首灯信号距离信…

作者头像 李华
网站建设 2026/9/15 7:50:31

Claude Code 安装配置与接入 DeepSeek 实操指南:从入门到排坑

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

作者头像 李华