news 2026/10/17 1:10:38

豆瓣Top250爬虫实战:从请求伪装到数据落库的完整工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
豆瓣Top250爬虫实战:从请求伪装到数据落库的完整工程实践

网络爬虫,豆瓣电影排行榜,这两个词放在一起,很多人的第一反应是“又一个Top250练手项目”。确实,网上关于榜单数据抓取的教程一抓一大把,但大部分只会告诉你装个requests、调个BeautifulSoup、把结果print出来,跑通就完事了。真到了要拿这个项目去练“高级”能力——比如请求头的伪装策略、翻页参数的构造逻辑、脏数据的清洗规则、异常重试机制、数据落库与增量更新——你会发现那些教程根本不够用。

这篇文章不打算带着你print一遍榜单就结束,而是把这个经典项目里真正值得花时间的细节掰开揉碎:页面结构怎么定位、请求频率怎么控制、数据字段怎么标准化、被封了怎么排查,以及最重要的,怎么在一个真实的、有反爬限制的网站上优雅地拿到数据。整个项目难度中等偏上,适合已经会写基础循环和函数、想从“能跑”跨到“能扛事”的爬虫学习者,也适合拿这份数据做后续可视化分析或数据集构建的人参考。

1. 项目概述与目标拆解

1.1 核心需求解析

先把这个项目到底要做什么说清楚。目标是从豆瓣电影排行榜中抓取完整的榜单信息,包括电影排名、电影名称、导演主演、年份地区、类型、评分、评价人数等结构化字段,并最终存储为可复用的本地数据文件。

注意关键词是“高级”,不是把第一页25条数据抓下来就完事。完整的项目目标至少包含四个层次:

  • 数据完整性:覆盖全部250部电影,不是单页演示。
  • 字段规范性:抓下来的原始文本要拆解成整洁的字段,能直接进Excel或数据库。
  • 稳定性:中途断网、被限流、解析失败时能自动重试,而不是脚本崩溃从头再来。
  • 可维护性:请求频率、请求头、解析规则这些参数要集中管理,换一个榜单也能快速复用。

我在帮别人review爬虫代码时,最常看到的问题就是“能跑但脆弱”。第一页抓得顺顺利利,爬到第三页突然报错,或者某部电影的简介为空导致整条记录报废。高级和初级的分水岭就在这些地方。

1.2 为什么拿这个项目练手

选择豆瓣电影Top250作为练手目标,不是因为它的数据有多稀缺,而是它的页面结构非常适合用来训练“结构化数据提取”的思维。

榜单页面是经典的分页列表布局,一页25条,共10页,数据密度高、字段丰富、结构规律。每条电影都包含文本信息和评分信息,既有固定格式的内容,也有长度不定的简介,涵盖了数据解析中会遇到的绝大多数情况。更关键的是,这个站点对爬虫并不算完全开放,一定频率内的单线程抓取可以正常完成,但频率稍高就可能遇到临时限制。这种“管得不严但也不是没管”的状态,反而是学习反爬应对的最佳训练场——既不会劝退新手,又能让有经验的人玩出花活。

我做这个项目时的主要任务不是“抓下来”,而是“可持续地抓完”。这就涉及到后面要展开的请求策略、解析策略、异常处理策略三大块。

2. 页面分析与数据解析策略

2.1 页面结构观察与关键字段定位

拿到一个抓取任务,我习惯先不写代码,而是打开浏览器,直接查看目标页面的HTML结构。这一步省下来的时间,远比想象中多。

打开榜单第一页后,用开发者工具查看元素可以观察到一个规律:每部电影都被包在class为grid_view的列表容器里,具体到每一部,又是一个li标签。每条li内部的层次结构大致是:

  • class="pic"的div里存着排名和海报,排名在em标签中。
  • class="info"的div里存着核心信息,其中hd区域是电影标题,bd区域是导演主演、年份、地区、类型文本,以及评分信息。
  • 评分数字在class="rating_num"的span里,评价人数在class="star"区域内的其他span中。
  • 如果这部电影有经典台词,会出现在class="quote"区域下的inq标签中,没有则整个区块缺失。

这里有一个新手很容易踩的坑:不能默认每条数据都有完整字段。250条电影里,有一小部分没有经典台词,如果解析代码直接取quote标签的文本,遇到缺失字段就会报错或插入空值。正确做法是先判断元素是否存在,再决定赋值方式。

2.2 解析库选型:BeautifulSoup、lxml还是正则

项目标题标了“高级”,但在工具选型上我反而不推荐上来就用过于复杂的东西。数据解析这块有两条路线:

第一条是BeautifulSoup + lxml解析器。BeautifulSoup的API非常直观,配合CSS选择器读代码就像读页面结构,适合快速开发和维护。性能虽然比xpath略慢,但榜单总共只有10页,性能差异可以忽略不计。

第二条是纯正则表达式直接匹配文本。优点是速度快、不依赖第三方库,缺点是可读性差、改起来痛苦。比如要提取导演姓名、主演列表、年份、地区、类型,这些信息全部混在一段用斜杠分隔的文本里,正则写起来很绕,一旦页面微调就要重新排查。

这个项目的体量决定了选择器可读性比解析性能重要得多。我的建议是:用BeautifulSoup的CSS选择器来做主体解析,只在需要从bd区域混合文本里拆字段时,用正则配合处理。这种混搭风格在实际项目中非常常见,也是我认为最实用的选型。

3. 核心代码实现与关键参数设计

3.1 基础抓取框架搭建

先看第一版能跑通整体流程的代码。这个版本不追求花哨,但把请求、解析、存储的主干搭好,所有“高级”的改进都是在它之上做加法。

import requests from bs4 import BeautifulSoup import time import csv HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://movie.douban.com/" } def fetch_page(start): url = "https://movie.douban.com/top250" params = {"start": start, "filter": ""} resp = requests.get(url, params=params, headers=HEADERS, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return resp.text def parse_page(html): soup = BeautifulSoup(html, "lxml") items = [] for li in soup.select(".grid_view li"): item = {} em = li.select_one(".pic em") item["rank"] = em.text.strip() if em else "" title = li.select_one(".hd .title") item["title"] = title.text.strip() if title else "" info_p = li.select_one(".bd p") item["info"] = info_p.text.strip() if info_p else "" rating = li.select_one(".rating_num") item["rating"] = rating.text.strip() if rating else "" people = li.select_one(".star span:nth-of-type(4)") item["people"] = people.text.strip() if people else "" quote = li.select_one(".quote .inq") item["quote"] = quote.text.strip() if quote else "" items.append(item) return items def main(): with open("douban_top250.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["rank", "title", "info", "rating", "people", "quote"]) writer.writeheader() for start in range(0, 250, 25): html = fetch_page(start) for item in parse_page(html): writer.writerow(item) time.sleep(3) print("抓取完成") if __name__ == "__main__": main()

这个骨架整体上是干净高效的。先说一个细节:写入CSV时用的是utf-8-sig而不是utf-8。直接存utf-8格式的文件,Excel打开会出现中文乱码,而utf-8-sig会自动在文件开头加BOM标识,Excel一眼认出来。这是所有中文数据导出场景的通用水位线问题,知道一次就永远记住了。

另一个细节是resp.encoding = "utf-8"。虽然requests在拿到响应时会根据HTTP头猜编码,但偶尔会猜错,导致中文乱码。手动指定是最稳妥的做法。

3.2 请求头伪装与User-Agent管理

这个项目能被称为“高级”,一个重要原因就是你不能拿requests默认的User-Agent去抓。服务器端的反爬系统看一眼请求头里没有浏览器特征,会直接拒绝服务。

我的做法是单独维护一个HEADERS字典,把它放在文件顶部作为全局配置。其中最关键的是User-Agent,每次请求至少要伪装成一个真实浏览器的标识。更严谨一点的做法是准备一个UA列表,每次请求随机选一个,避免每次都带着完全相同的指纹:

import random USER_AGENTS = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0", ] def get_headers(): headers = HEADERS.copy() headers["User-Agent"] = random.choice(USER_AGENTS) return headers

说到“高级”,除了UA池,一个真实的爬虫项目还应该有Cookie管理机制。第一次访问页面时,先用requests.Session建立会话,让服务器种下必要的Cookie,后续请求都复用同一个Session对象。这样整个抓取过程更像一个真实用户在连续浏览页面,而不是每次请求都是“陌生人”。

很多人忽略Referer字段。有些站点的反爬策略会校验请求从哪个页面发过来,如果Referer不正确也可能被拒。对于豆瓣这种对来源不特别严格的站点,加上https://movie.douban.com/这个来源地址是比较稳的。

3.3 翻页逻辑与URL参数构造

榜单的翻页不是通过点击第2页、第3页的后台真实逻辑,而是通过start参数控制的。每页固定25条,第1页start=0,第2页start=25,以此类推。

搞清楚这一点后,翻页就变成了一个简单的循环:for start in range(0, 250, 25),逐页抓取直到覆盖全部250部电影。

这里有个隐藏的检查点:最后一页的数据量可能不满足预期,所以在parse_page里要统计当页实际解析出的条数,如果为0就需要告警。更细致一点的做法是在main函数里维护一个全局计数的变量,循环结束后校验总数是否等于250,不是的话说明有页面漏抓了,需要重新跑一次。这是拿“高级”的标准要求自己的体现。

3.4 数据清洗与字段格式化

第3.1节的代码把info字段整段存进CSV,但真正做数据分析时,很少有人愿意去看那段混着所有信息的原始文本。所以这个部分的重点是从info里继续拆字段。

导演主演、年份、地区、类型这些信息都存放在bd区域的p标签里,文本形如:

导演: 克里斯托弗·诺兰 Christopher Nolan / 主演: 马修·麦康纳 Matthew McConaughey / ... / 2014 / 美国 / 科幻 剧情 / 片长

这段文本用换行符和斜杠分隔了多个逻辑单元。实战中我的拆法是用正则分两步:

import re def parse_info(info_text): director = "" actor = "" year = "" region = "" genre = "" lines = [line.strip() for line in info_text.split("\n") if line.strip()] if lines: first_line = lines[0] director_part = re.search(r"导演: (.+?)(?: / 主演|$)", first_line) if director_part: director = director_part.group(1).strip() actor_part = re.search(r"主演: (.+?)(?: / 主演|$)", first_line) if actor_part: actor = actor_part.group(1).strip() if len(lines) > 1: parts = lines[1].split("/") year = parts[0].strip() if len(parts) > 0 else "" region = parts[1].strip() if len(parts) > 1 else "" genre = parts[2].strip().replace(" ", " ") if len(parts) > 2 else "" return { "director": director, "actor": actor, "year": year, "region": region, "genre": genre, }

解析这段文本真正容易出问题的地方,是导演和主演之间的分隔。有些电影有多个导演,有的一行里没有主演,所以正则里用非贪婪模式匹配到“主演”或行尾为止。不提前把这些边界情况处理掉,后面数据清洗会非常痛苦。

评分信息和评价人数相对简单,但也要注意类型转换。评分保留为字符串没问题,但如果你要算平均分或者排序,建议存的时候就转成float。评价人数里的“约万人评价”这种中文单位也要先清洗干净再做数值化,这一步留到后续数据分析时再处理也可以。

3.5 数据存储方案选型

CSV是最轻量、最通用的存储方案,适合数据量小、需要人工打开查看的项目。但要说“高级”,我更推荐在项目里把数据同时落到SQLite数据库。

import sqlite3 conn = sqlite3.connect("douban_top250.db") cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS top250 ( rank INTEGER PRIMARY KEY, title TEXT, director TEXT, actor TEXT, year INTEGER, region TEXT, genre TEXT, rating REAL, people TEXT, quote TEXT ) """) conn.commit()

数据库方案的第一个优势是去重。如果脚本中途挂了,重启后可以按rank主键判断哪些电影已经抓过,只补抓缺失的。第二个优势是方便后续做筛选和统计:想看2010年以后的科幻片,一句SQL就查出来了,这在纯CSV文件里做就没那么顺手。

落库时注意一个细节:数据库字段的类型如果和Python值类型不匹配,需要在写入前做转换。比如评分字段在CSV阶段是字符串,入库前要确保是float;rank在解析阶段是字符串,入库前要转成int。所有类型转换最好集中在数据清洗阶段统一完成,而不是散落在各个写入逻辑里。

4. 反爬应对与性能优化

4.1 频率控制与限速策略

爬虫项目的反爬应对,说到底就是和服务器端的限速策略做博弈。频率控制是最核心的手段,也是最容易被新手忽视的。

我在第3.1节代码里留了time.sleep(3),这个3秒不是随便拍的。经过观察,在一定时间窗口内,如果请求间隔小于2秒,很快会触发临时限制;间隔拉到3秒以上就相对安全。但这只是基于该站点的经验值,不是通用公式。通用做法是给每次请求设置一个可控的延时区间:

import random import time time.sleep(random.uniform(2.5, 4.5))

为什么要随机而不是固定值?因为真实用户的浏览节奏本身就是不规律的,固定间隔反而暴露出机器行为的特征。随机延时虽然总体上平均间隔差不多,但在服务器看来这是更接近人的访问模式。

这个项目的整体耗时大概在30到45秒之间,完全能接受。如果你后续要抓的数据量翻十倍,再看要不要上并发,而不要一上来就用多线程狂轰滥炸。

4.2 异常重试与断点续爬

只要爬过真实网站的排行榜数据,就会明白“网络永远不稳定”这句话的含义。超时、连接重置、暂时限流,这些都是家常便饭。一个没有重试机制的爬虫,表面上跑通了,实际上跑完整个项目就烧高香了。

重试逻辑不要写得到处都是,用一个统一的重试器包住请求函数就好。我习惯用Python标准库配合循环实现:

def request_with_retry(fn, retries=3, delay=2): for i in range(retries): try: return fn() except requests.RequestException as e: if i == retries - 1: raise print(f"请求失败,第{i+1}次重试:{e}") time.sleep(delay * (i + 1))

这里用了退避策略:第一次失败等2秒,第二次等4秒,第三次等6秒。逐步拉大间隔,给服务器留出恢复时间,而不是每次都立刻重试。

断点续爬这块,结合前面提到的SQLite主键去重就能搞定。程序启动时先查一下库里已有哪些rank,抓的过程中跳过已存在的记录。这样哪怕跑到第7页网络断了,修好之后重跑脚本,不会从头再来,也不用担心重复写入。

4.3 多线程与并发提速的取舍

作为一个标了“高级”的项目,很多读者会期待看到多线程并发抓取。但我想先泼一盆冷水:并发在本项目里不是必选项,甚至可能是负优化。

排行榜总共只有10页请求,单线程加随机延时也就半分钟完成。用上ThreadPoolExecutor把并发线程数开到5,总时间也许能压到10秒,可代价是什么?是更高的触发限流概率。你为一个快了20秒的需求承担封IP的风险,这笔账不划算。

我的判断标准是:只有当抓取规模达到几百页、且目标站点对并发不太敏感时,才考虑上并发。而且并发数一定要保守,我见过太多人把线程数拉到50,然后看着一屏幕的403报错不知所措。实际项目中,5个线程以内的低频并发是大多数公开网站的容忍上限。

如果真想优化,优先级从高到低排序应该是:先做断点续爬,再改进度日志,最后才考虑并发。优化稳定性的收益永远大于优化速度。

5. 常见问题与排查技巧实录

5.1 请求被拒绝或返回403

403状态码是爬虫最常遇到的敌人。排查步骤我一般按这个顺序来:

  • 先确认User-Agent是不是浏览器标识,这一步能解决至少一半的403问题。
  • 检查有没有使用Session维持Cookie会话。
  • 看一下请求频率是不是太快,连续请求间隔小于2秒时被拒很正常。
  • 最终手段是在Header里补充更多浏览器特征字段,比如Accept、Accept-Language、Accept-Encoding。

这里要特别强调:如果你在短时间内触发限制,服务器返回的可能不是403,而是一个让你验证身份的页面。这时候代码里如果用了raise_for_status(),它不会报错,因为状态码可能是200,但返回的HTML内容已经不是榜单页面,解析出来的结果自然为空。这个坑非常隐蔽,很多人卡在这。

5.2 解析结果为空或字段缺失

解析为空的原因通常有两种:第一种是服务器返回了验证页面,HTML结构和正常页面完全不同,选择器自然匹配不到任何元素;第二种是页面结构调整了,class名发生了变化。

针对第一种情况,最好在解析前做一道防线:检查HTML里是否包含榜单的某个特征字符串,比如“rating_num”,没有的话说明拿到的不是期望页面。针对第二种情况,我习惯在代码里加一条字段完整性校验:如果连续多个页面都解析不到数据,直接停止程序并抛异常,而不是让错误数据静默写入。宁可让任务失败,也不能让数据库里混进半桶水。

字段缺失的问题前面提过,核心就是每条字段都不能直接访问元素文本,建议先判断是否存在,再决定赋值。另一个容易被忽略的点是:有些标题包含外文名,在.title下会有两个span,第一个是中文名,第二个是外文名。如果你只要中文名,选择器应该指定第一个。如果想保留双语,就把两个都抓下来拼接。

5.3 中文乱码问题

乱码基本都出在编码识别不准上。requests库在获取响应后会通过响应头猜编码,但有些服务器返回的Content-Type里没有指定charset,requests就会默认用ISO-8859-1解码,中文自然变成乱码。

解决办法就是手动指定编码为UTF-8,代码里直接用resp.encoding = "utf-8"。更稳妥的方式是先检测内容再设编码:

resp.encoding = resp.apparent_encoding

apparent_encoding是根据页面内容实际统计出来的编码,准确性比HTTP头要高。对于这个项目,UTF-8是确定的,手动指定就行。

写完CSV后Excel打开乱码是另一条完全不同的路径,和抓取时的编码没半毛钱关系,问题是文件本身的编码格式。解决办法就是用utf-8-sig编码写文件,前面代码里已经做过。

5.4 稳定性监控与合规意识

“高级”爬虫和普通爬虫的另一个区别是:高级爬虫有日志。我是这么做的:在程序的每个关键节点打点输出,当前抓了多少条、当前进度是第几页、最近一次请求的耗时是多久。少量数据时可以print,数据量大时就要考虑写到日志文件里,方便程序跑挂了之后回溯。

更重要的是要有边界意识。写爬虫的人必须明白:任何数据抓取行为都应该遵守目标网站的服务条款,控制请求频率,个人学习用途的项目不做大规模抓取,更不做商业化采集。这个项目里我全程只抓了250条结构化数据,请求频率控制在人类浏览的正常节奏附近,完全不触碰网站的敏感接口和未公开数据。爬虫技术本身是中立的,但怎么用、用到什么程度,体现的是工程师的职业素养。我见过不少从业者在技术分享里只讲技巧不讲边界,那是把路走窄了。

5.5 一个容易被忽略的细节:本地运行环境

最后提一个看起来和爬虫无关、实际影响很大的环境问题:某些公司或家庭的网络环境经过层层网关,对高频率请求有额外限制。如果你在自己电脑上跑得好好的,换了个网络环境发现频繁超时,先别急着怀疑代码,先换一个稳定的网络源试试。这个排查方向我在多个项目里都验证过,往往能省下大量对着代码发呆的时间。

另外,pip安装依赖时建议用虚拟环境:

python -m venv .venv source .venv/bin/activate pip install requests beautifulsoup4 lxml

这里要补充一个经验:解析器不要省略。BeautifulSoup默认使用Python内置的html.parser,虽然也能用,但对复杂HTML的容错性和解析速度都不如lxml。装好lxml之后,在BeautifulSoup(html, "lxml")里显式指定它,兼容性会好很多。

5.6 扩展方向:这个项目还能怎么进阶

榜单数据抓取只是个起点。这个项目做完之后,数据已经躺在SQLite里了,后续的扩展方向其实非常多。

比较直接的路线是数据可视化:按年份画一个电影产量分布图,按评分做一个直方图,按地区做一个各国家和地区电影上榜数量的排名。有了这些数据,写十几行代码就能出一个不错的图表。

再进阶一点,可以做电影信息的关联抓取。榜单页只给了摘要信息,点击标题进入电影详情页后,还能拿到更长的剧情简介、演职员列表、短评数等数据。详情页的URL可以从榜单页的链接字段里提取出来,有了Top250之后,这个扩展就成了“从一个页面到另一个页面”的典型蜘蛛爬虫设计,进阶学习的路线一下就打开了。

我个人在实际操作中的体会是:爬虫这个方向,入门容易,真正见功夫的从来不是把某个页面抓下来,而是把“抓取-解析-清洗-存储-容错”这条链路真正跑得又稳又规范。拿豆瓣排行榜练手最大的价值就在这里——它把你圈在了一个有限的数据范围内,逼你把每个环节都打磨到位,而不是贪多嚼不烂。当你把250条数据完整、干净、可重复地抓下来那一刻,你掌握的已经不只这个项目的本身了。

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

两串锂电池被动均衡方案:MP2642GR-Z与STM32L432KC实战

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

作者头像 李华
网站建设 2026/10/17 1:10:34

两串电芯被动均衡方案:MP2642GR-Z与MKV46F128VLH16协同设计

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

作者头像 李华
网站建设 2026/10/17 1:10:32

双节锂电主动均衡设计:MP2642GR-Z与STM32F732IE工程实践

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

作者头像 李华
网站建设 2026/10/17 1:10:12

MP2642GR-Z与ATmega32协同实现高精度双节电池主动均衡

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

作者头像 李华
网站建设 2026/10/17 1:09:53

Tango Control System自定义客户端开发实战:架构、API与避坑指南

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

作者头像 李华
网站建设 2026/10/17 1:09:46

Zephyr信号量深度解析:从线程同步到ISR场景的实战避坑指南

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

作者头像 李华