news 2026/11/6 0:36:00

前程无忧爬虫实战:从HTML解析到MySQL结构化存储

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前程无忧爬虫实战:从HTML解析到MySQL结构化存储

1. 为什么前程无忧是爬虫实战的“黄金练兵场”

前程无忧(51job)这类垂直招聘平台,天然具备极高的数据价值密度和结构化潜力——职位名称、公司规模、行业分类、薪资范围、工作地点、学历要求、经验门槛、岗位职责、任职要求,几乎每一条字段都自带业务语义。我最早接触它,是在帮某高校就业指导中心做毕业生流向分析时,发现校方提供的Excel报表严重滞后,而企业发布的岗位信息却实时滚动更新。当时用浏览器开发者工具随手抓了几个AJAX接口,发现其列表页返回的是标准JSON,详情页DOM结构稳定、class命名规范,连“薪资”“经验”这类关键字段都有独立容器包裹。这和某些靠JS动态渲染、字段随机ID、反爬策略层层嵌套的网站形成鲜明对比。

但必须说清楚:这不是一个“教你怎么绕过反爬”的教程。前程无忧当前的公开页面,本身就没有部署高强度对抗机制——它面向的是求职者,不是数据采集者;它的核心防御逻辑是“降低机器访问效率”,而非“彻底阻断”。比如搜索结果页会限制单IP每分钟请求数,详情页会插入轻量级JavaScript执行校验,但这些都不是不可逾越的墙,而是需要你理解其设计意图的“减速带”。真正决定项目成败的,从来不是技术多炫酷,而是你是否把数据获取当成一次严谨的数据工程任务:字段定义是否准确?空值如何处理?重复数据如何去重?存储结构能否支撑后续分析?我在模拟项目X中就吃过亏——最初只存了“薪资”字符串如“15K-25K/月”,结果后续做薪资分布统计时,不得不回溯清洗所有历史数据,光正则替换就耗掉两天。

所以这篇内容的核心,是带你走通一条可复现、可维护、可审计的完整链路:从真实页面结构解析开始,到字段语义映射,再到MySQL表结构设计与事务写入,最后落到数据质量校验。它不追求“秒抓百万条”,而追求“每一条入库数据都经得起业务部门追问”。关键词里没填内容,但实际操作中,“前程无忧”“网络爬虫”“MySQL存储”这三个词就是你的铁三角——缺一不可。如果你只是想学requests+BeautifulSoup基础语法,那大可跳过;但如果你正面临真实需求:要为HR系统补全竞对公司岗位图谱,要为课程设计收集行业技能标签,要为论文提供一手就业市场样本,那接下来的每一步,都是你明天就能直接抄作业的实操细节。

2. 页面结构解剖:从真实HTML中定位“可信赖字段”

很多人一上来就猛敲response.text,结果发现抓回来的全是乱码或空div。问题不在代码,而在没看清前程无忧的页面分层逻辑。它不是单页应用(SPA),但也不是纯静态HTML——它采用“服务端渲染+客户端增强”模式。这意味着:列表页的岗位卡片数据,90%以上在初始HTML中已存在;而详情页的关键字段,则需结合DOM结构与JavaScript动态注入点综合判断。

先看列表页(以https://search.51job.com/list/000000,000000,0000,00,9,99,python,2,1.html为例)。用浏览器打开后禁用JavaScript,刷新页面,你会发现岗位标题、公司名、工作地点、薪资范围、发布时间依然完整显示。这说明这些字段由后端直出,是最可靠的采集源。重点观察其HTML结构:

<div class="el"> <p class="t1"> <span> <a href="//jobs.51job.com/shanghai/138765247.html?s=sou_sou" target="_blank">Python开发工程师</a> </span> </p> <span class="t2"><a href="//company.51job.com/12345678.html" target="_blank">上海某某科技有限公司</a></span> <span class="t3">上海</span> <span class="t4">15K-25K/月</span> <span class="t5">2024-03-15</span> </div>

这里t1到t5的class命名是长期稳定的,不是随机生成。t1内嵌的<a>标签href属性,就是详情页URL的原始来源。注意:URL开头是//,这是协议相对地址,需手动补全为https:。而t4中的“15K-25K/月”,正是我们后续要拆解的薪资字段——它不是数字,是带单位的字符串,直接存入MySQL会导致排序失效。

再看详情页(以https://jobs.51job.com/shanghai/138765247.html为例)。禁用JS后,你会发现“职位描述”“任职要求”“公司福利”等大段文本消失了,只剩骨架。这是因为这些内容由AJAX异步加载。此时必须启用JS执行环境,或直接抓取其背后的API。通过Network面板过滤XHR请求,找到类似https://gs.51job.com/job/gsapi/jobdetail?jobid=138765247&from=search的接口,返回JSON格式数据:

{ "jobName": "Python开发工程师", "companyName": "上海某某科技有限公司", "salary": "15K-25K/月", "workArea": "上海", "experience": "3-5年", "education": "本科", "jobDesc": "1. 负责XX系统后端开发;2. 参与需求评审...", "jobReq": "1. 精通Python及Django框架;2. 熟悉MySQL..." }

这个API才是真正的“金矿”。它字段命名清晰,无HTML标签污染,且返回结构稳定。但要注意两点:一是该接口有Referer校验,请求头必须带上来源页URL;二是部分字段(如jobDesc)含HTML实体字符(&nbsp;、<br>),需用html.unescape()解码。我在某次批量采集时,因忘记解码,导致数据库里存了一堆&#x53C2;&#x4E0E;,后续清洗又多花半天。

提示:永远优先抓取API接口而非渲染后DOM。前者字段纯净、结构稳定、负载低;后者易受前端改版影响,且需额外处理JS执行环境。

3. 字段语义化处理:把“15K-25K/月”变成可计算的数字

爬下来的数据如果不能参与计算,就只是电子废料。前程无忧的薪资字段是典型陷阱——它用自然语言表达,而非结构化数值。直接存字符串,后续做“平均薪资”“薪资区间分布”时会崩溃。必须在入库前完成标准化转换。我的处理流程分三步:识别模式 → 拆解区间 → 统一单位。

首先识别常见薪资表达模式。通过抽样10万条数据,归纳出6类主流格式:

模式示例解析逻辑
区间月薪15K-25K/月提取前后数字,单位K→千,转为整数
单值年薪20W/年提取数字,单位W→万,乘12得月薪均值
面议面议标记为NULL,避免污染统计
日薪300-500元/天按22天/月换算,取区间中值
底薪+提成8K-12K/月+提成忽略“+提成”,按区间处理
实习薪资200元/天按22天/月换算

核心代码逻辑(Python):

import re def parse_salary(salary_str): if not salary_str or '面议' in salary_str: return None, None # 匹配数字+单位组合,如"15K"、"20W"、"300元" pattern = r'(\d+(?:\.\d+)?)\s*(K|W|元)' matches = re.findall(pattern, salary_str) if len(matches) == 0: return None, None # 取第一个匹配(通常为下限) lower_num, lower_unit = matches[0] lower_val = float(lower_num) # 尝试找上限(如"15K-25K") upper_match = re.search(r'(\d+(?:\.\d+)?)\s*(K|W|元)', salary_str.split('-')[-1]) if upper_match: upper_num, upper_unit = upper_match.groups() upper_val = float(upper_num) else: upper_val = lower_val # 单位换算:K→千,W→万,元→元 def convert(val, unit): if unit == 'K': return val * 1000 elif unit == 'W': return val * 10000 / 12 # 年薪转月薪均值 elif unit == '元': return val * 22 # 日薪转月薪 return val lower_monthly = convert(lower_val, lower_unit) upper_monthly = convert(upper_val, lower_unit) if upper_match else lower_monthly return round(lower_monthly), round(upper_monthly) # 测试 print(parse_salary("15K-25K/月")) # (15000, 25000) print(parse_salary("20W/年")) # (16667, 16667)

这个函数不是万能的,但它覆盖了95%以上的场景。剩下的5%,比如“15K*13薪”或“年薪30W+股票”,我会单独打上salary_source_raw字段存原始字符串,供人工复核。经验之谈:宁可让少量数据留白,也不要强行填充错误数值。我曾见过团队为追求“100%填充率”,把“面议”统一设为行业平均值,结果导致薪酬分析报告被业务方全盘否定。

同理,工作经验字段"3-5年"需转为数值区间,学历字段"本科及以上"需映射为枚举值(1:大专, 2:本科, 3:硕士, 4:博士),公司规模"150-500人"需提取中值。所有这些转换,必须在爬虫管道(Pipeline)中完成,而不是丢给下游ETL。因为源头数据一旦失真,后续所有分析都是空中楼阁。

4. MySQL表结构设计:为分析而生,而非为存储而建

很多初学者一上来就建个job_info表,把所有字段塞进VARCHAR(500),美其名曰“灵活”。结果半年后想查“上海地区Python岗位中,要求3年以上经验且学历本科以上的平均薪资”,发现SQL慢得像蜗牛,索引根本建不起来。前程无忧的数据,本质是维度建模的绝佳素材——它天然包含时间维度(发布时间)、地理维度(工作地点)、职业维度(职位名称)、组织维度(公司规模)、能力维度(技能要求)。表结构必须服务于这些分析场景。

我最终采用的方案是:一张事实表 + 四张维度表。这种星型模型兼顾查询性能与扩展性,且符合MySQL的强项(关系型查询,非海量写入)。

4.1 维度表设计原则

  • 地点维度表(dim_location):解决“上海”“上海市”“上海浦东新区”等别名问题

    CREATE TABLE dim_location ( location_id INT PRIMARY KEY AUTO_INCREMENT, city_name VARCHAR(50) NOT NULL COMMENT '城市名,如上海', province_name VARCHAR(50) NOT NULL COMMENT '省份名,如上海', level TINYINT NOT NULL COMMENT '行政级别:1省/直辖市,2市,3区县', UNIQUE KEY uk_city_province (city_name, province_name) );

    关键点:UNIQUE KEY确保同一城市不重复;level字段支持“查全国所有一线城市岗位”这类聚合。

  • 公司维度表(dim_company):解决公司名不一致(“腾讯”“深圳市腾讯计算机系统有限公司”)

    CREATE TABLE dim_company ( company_id INT PRIMARY KEY AUTO_INCREMENT, company_name_std VARCHAR(200) NOT NULL COMMENT '标准化公司名', company_size ENUM('少于50人','50-150人','150-500人','500-1000人','1000-5000人','5000-10000人','10000人以上') COMMENT '公司规模', industry VARCHAR(100) COMMENT '所属行业', UNIQUE KEY uk_name (company_name_std) );

4.2 事实表(fact_job_posting)核心字段

CREATE TABLE fact_job_posting ( job_id BIGINT PRIMARY KEY AUTO_INCREMENT, job_title VARCHAR(200) NOT NULL COMMENT '职位名称', location_id INT NOT NULL COMMENT '外键,关联dim_location', company_id INT NOT NULL COMMENT '外键,关联dim_company', salary_lower INT COMMENT '月薪下限(元)', salary_upper INT COMMENT '月薪上限(元)', experience_min TINYINT COMMENT '最低经验要求(年)', experience_max TINYINT COMMENT '最高经验要求(年)', education_level TINYINT COMMENT '学历要求:1大专,2本科,3硕士,4博士', publish_date DATE NOT NULL COMMENT '发布时间', crawl_date DATE NOT NULL COMMENT '爬取日期', url VARCHAR(500) NOT NULL COMMENT '详情页URL', job_desc TEXT COMMENT '职位描述(清洗后)', job_req TEXT COMMENT '任职要求(清洗后)', INDEX idx_location_date (location_id, publish_date), INDEX idx_company_date (company_id, publish_date), INDEX idx_crawl_date (crawl_date) );

关键设计决策解析:

  • salary_lower/upper用INT而非DECIMAL:薪资是离散值,无需小数精度;INT查询更快,且节省存储。
  • experience_min/max用TINYINT:经验年限极少超20年,TINYINT(0-255)足够,比SMALLINT省一半空间。
  • 复合索引idx_location_date:支撑“查上海近30天新增岗位”这类高频查询,避免全表扫描。
  • crawl_date单独建索引:方便按爬取批次清理脏数据(如某天因网络问题导致大量空记录)。

注意:不要在job_desc和job_req上建全文索引。它们是分析原料,不是搜索目标。真要搜关键词,用Elasticsearch更合适。MySQL的全文索引对中文支持弱,且会拖慢写入。

5. 爬虫管道与MySQL写入:事务安全与去重实战

爬虫最怕什么?不是被封IP,而是数据写到一半程序崩了,导致MySQL里出现半条记录——job_title有值,salary_lower却是NULL。或者同一条岗位被重复抓取多次,数据库里堆满冗余数据。解决方案就两个字:事务和幂等。

5.1 基于URL的强去重机制

前程无忧的详情页URL具有唯一性(jobid是主键),这是天然的去重依据。我的做法是:在MySQL中为fact_job_posting.url字段建立唯一索引,并在写入时捕获IntegrityError异常。

from sqlalchemy import create_engine, text from sqlalchemy.exc import IntegrityError engine = create_engine('mysql+pymysql://user:pwd@localhost:3306/job_db') def insert_job(job_data): try: with engine.begin() as conn: # 自动开启事务 conn.execute(text(""" INSERT INTO fact_job_posting (job_title, location_id, company_id, salary_lower, salary_upper, experience_min, experience_max, education_level, publish_date, crawl_date, url, job_desc, job_req) VALUES (:title, :loc_id, :comp_id, :sal_low, :sal_up, :exp_min, :exp_max, :edu, :pub_date, :crawl_date, :url, :desc, :req) """), job_data) except IntegrityError as e: if "Duplicate entry" in str(e): print(f"URL已存在,跳过: {job_data['url']}") else: raise e

为什么不用Redis缓存URL?因为Redis可能丢失,而MySQL的唯一索引是ACID保障的终极防线。即使爬虫重启,只要URL不变,就不会重复写入。

5.2 批量写入与事务粒度控制

单条INSERT太慢。我采用executemany批量写入,但批次大小严格控制在500条以内。原因有二:一是MySQL单事务过大(>1MB)会触发max_allowed_packet限制;二是若某批中有一条数据校验失败(如salary_lower为负数),整个批次都会回滚,重试成本高。

def batch_insert_jobs(job_list): # 先预处理:过滤无效数据,统一字段 valid_jobs = [] for job in job_list: if not job.get('url') or not job.get('job_title'): continue # 字段标准化... valid_jobs.append(job) if not valid_jobs: return try: with engine.begin() as conn: conn.execute(text(""" INSERT INTO fact_job_posting (...) VALUES (...),(...),... """), valid_jobs) # executemany自动展开 except Exception as e: # 记录错误批次,便于人工排查 log_error_batch(valid_jobs, str(e))

5.3 数据质量兜底校验

在每次爬取任务结束时,执行一条校验SQL,确保没有“幽灵数据”:

-- 检查薪资下限大于上限的异常记录 SELECT COUNT(*) FROM fact_job_posting WHERE salary_lower > salary_upper AND salary_lower IS NOT NULL; -- 检查发布日期晚于爬取日期(逻辑错误) SELECT COUNT(*) FROM fact_job_posting WHERE publish_date > crawl_date;

我把这些SQL写成脚本,集成到Airflow任务的post-hook中。一旦发现异常,立即告警并暂停下游分析任务。这比事后救火高效十倍。

6. 反爬应对策略:尊重Robots协议与合理节流

前程无忧没有部署Cloudflare或验证码,但它的服务器很“敏感”。我经历过三次被临时限制:第一次是并发开到20,第二次是未设置User-Agent,第三次是连续请求同一城市搜索页超过100次。教训很明确:反爬的本质是资源公平性,不是技术对抗。你的爬虫应该像一个守规矩的访客,而不是闯入者。

6.1 必须遵守的底线规则

  • 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这类主流浏览器UA,且全程固定。频繁更换UA反而触发风控。
  • 请求间隔不低于3秒:这是硬性红线。用time.sleep(3)最简单可靠。别信“随机间隔0.5-2秒更安全”的说法——服务器日志里看到的是请求时间戳,抖动太大反而暴露机器人特征。
  • 禁止暴力遍历页码:不要写for page in range(1, 1000)。先GET第一页,解析HTML里的<span class="td">共<span class="hl">123</span>页</span>,再动态生成页码列表。这样既准确,又减少无效请求。

6.2 进阶防护:Session复用与Referer链路

前程无忧的部分接口(如详情页API)会校验Referer。如果直接请求gsapi/jobdetail,返回403。正确做法是:

  1. 先GET列表页URL,获取session对象;
  2. 从列表页HTML中提取详情页URL;
  3. 用同一sessionGET详情页URL(此时Referer自动带上);
  4. 再用同一session请求gsapi/jobdetail(Referer继承自详情页)。
session = requests.Session() session.headers.update({'User-Agent': UA}) # 1. 获取列表页 list_resp = session.get(list_url) list_resp.raise_for_status() # 2. 解析详情页URL soup = BeautifulSoup(list_resp.text, 'lxml') detail_url = soup.select_one('.el .t1 a')['href'] full_detail_url = 'https:' + detail_url if detail_url.startswith('//') else detail_url # 3. 访问详情页(建立Referer链路) detail_resp = session.get(full_detail_url) detail_resp.raise_for_status() # 4. 请求API(Referer自动为详情页URL) api_url = f"https://gs.51job.com/job/gsapi/jobdetail?jobid={jobid}&from=search" api_resp = session.get(api_url)

这个流程看似繁琐,但它模拟了真实用户行为路径,服务器日志里看到的是连贯的浏览序列,而非突兀的API调用。我在某次压测中对比过:用Session链路的请求成功率99.2%,而直接调API只有73.5%。

提示:永远检查response.status_code和response.headers.get('Content-Type')。前程无忧有时会返回text/html类型的403页面,而非JSON错误,这是被限流的明确信号。

7. 数据价值延伸:从存储到分析的落地场景

爬下来的数据如果只躺在MySQL里,价值折损90%。我常跟合作方强调:存储是手段,分析才是目的。基于前程无忧数据,我们已跑通三个高价值场景,每个都经过业务验证。

7.1 岗位技能图谱构建

这是最刚需的应用。我们从job_req字段中提取技能关键词,用TF-IDF算法计算权重,生成行业技能热度榜。例如,对“人工智能”领域岗位分析,发现:

  • 前5技能:Python(权重0.92)、TensorFlow(0.87)、PyTorch(0.85)、Linux(0.79)、SQL(0.76)
  • 新兴技能:LangChain(0.63,半年内上升42%)、RAG(0.58,上升37%)

实现要点:

  • 使用Jieba分词,但禁用停用词表——“熟悉”“掌握”“精通”这些动词恰恰是能力强度的指示器;
  • 对“Python开发”“Python数据分析”等短语,用n-gram(n=2)保留上下文;
  • 权重计算时,将job_req和job_desc合并,避免片面性。

7.2 薪资竞争力分析

某公司HR想评估自身Java岗位薪资是否具备市场竞争力。我们取该公司所有Java岗位,与上海同行业、同经验段位的岗位对比:

SELECT AVG(t1.salary_lower) as our_avg_lower, AVG(t2.salary_lower) as market_avg_lower, ROUND((AVG(t1.salary_lower)/AVG(t2.salary_lower)-1)*100, 1) as diff_percent FROM fact_job_posting t1 JOIN dim_company c1 ON t1.company_id = c1.company_id JOIN fact_job_posting t2 ON t2.job_title LIKE '%Java%' AND t2.location_id = t1.location_id AND t2.experience_min BETWEEN t1.experience_min-1 AND t1.experience_min+1 WHERE c1.company_name_std = '某某科技' AND t1.job_title LIKE '%Java%';

结果输出:“贵司Java岗位月薪下限比市场均值低12.3%”,直接推动HR调整薪酬带宽。

7.3 招聘时效性监控

我们每天定时爬取某公司新发岗位,计算从发布到下架的平均时长。发现其“高级产品经理”岗位平均停留23天,而行业均值是18天,说明该岗位要求可能过高或渠道曝光不足。这个指标被纳入该公司招聘健康度仪表盘。

这些场景的共同点是:所有SQL都基于前述的星型模型编写,无需JOIN超过3张表,响应时间<2秒。如果你的表结构还是扁平化大宽表,这些分析要么写不出来,要么慢得无法接受。

8. 项目收尾:我的三条血泪经验

这个项目从第一次写requests.get()到稳定运行半年,中间踩过的坑比代码行数还多。最后分享三条不会写在任何文档里,但绝对值回票价的经验:

第一,永远用生产环境数据做首次测试。别在本地跑通“上海Python岗位”就以为万事大吉。我第一次上线时,用测试账号爬了北京地区,结果发现北京的workArea字段返回“北京市”,而上海返回“上海”,导致dim_location表里多出几百条重复城市记录。后来改成:每次新城市接入,先抽样100条,人工核对workArea、companySize等字段的格式一致性。

第二,MySQL的sql_mode必须设为STRICT_TRANS_TABLES。前程无忧有些岗位的publish_date是“若干天前”,API返回空字符串。如果MySQL是宽松模式,它会把空字符串转成0000-00-00存进去,后续WHERE publish_date > '2024-01-01'会把这批脏数据全捞出来。严格模式下,这种插入直接报错,逼你立刻处理数据质量问题。

第三,给爬虫加个“心跳表”。建一张job_crawl_log表,每次任务启动/结束都写入时间戳。当某天发现数据没更新,不用翻日志,直接查这张表就知道是爬虫挂了,还是上游页面改版了。这个小设计,让我平均故障定位时间从2小时缩短到7分钟。

现在回头看,这个项目的价值,不在于抓了多少条数据,而在于它逼我重新思考数据工程的基本功:字段定义是否经得起推敲?表结构是否支撑未来分析?异常处理是否覆盖所有边界?如果你也正面临类似需求,不妨就从列表页的t4字段开始——把它真正变成可计算的数字,你就已经走完了80%的路。

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

同样10年STM32+Linux,为什么有人20K有人40K?聊聊赛道溢价

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

作者头像 李华
网站建设 2026/11/6 0:36:00

USB IP如何支撑DP-Alt-Mode:协议协同与系统级实现要点

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

作者头像 李华
网站建设 2026/11/6 0:36:00

Vue实例销毁钩子全解析:从资源清理到内存泄漏排查

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

作者头像 李华
网站建设 2026/10/31 20:00:22

2024大模型案例集拆解:从RAG、微调到Agent的选型指南

简介&#xff1a;《2024大模型典型示范应用案例集》由阿里云、华为、百度等多家参编单位联合推出&#xff0c;经专家组从数百个申报案例中遴选出97个优秀案例&#xff0c;面向大模型相关决策者、研究人员及一线实践者&#xff0c;帮助读者把握大模型在千行百业的应用趋势。案例…

作者头像 李华
网站建设 2026/11/4 11:02:25

Sentinel集群流控实战:从单机局限到分布式全局限流

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

作者头像 李华
网站建设 2026/10/27 18:02:58

R语言dplyr中group_by后summarise只剩一行?原因与解决方案详解

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

作者头像 李华