news 2026/9/25 1:23:50

Spark 2.2 实时新闻网分析:单机跑通端到端数据流

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spark 2.2 实时新闻网分析:单机跑通端到端数据流

简介:本资源是一套面向计算机专业本科生的毕业设计/课程设计实战项目,聚焦大数据实时分析场景,基于Spark 2.2构建新闻网数据流处理与智能推荐系统,适用于大数据入门到进阶学习者巩固分布式计算、流式处理与HBase集成等核心能力。压缩包共403个文件,以364个XML配置文件(涵盖Maven依赖、Spark作业参数及Flume-HBase通道定义)为主干,辅以14个Scala核心处理逻辑、5个Java工具类(如Kafka异步写HBase序列化器、日志读写组件)、4个Shell部署脚本及结构化文档(MD/TEXT),整体仅262KB,轻量易部署。已有241人学习下载,项目经助教审定、本地全链路编译验证,含完整目录结构(如structured-streaming-demo、hbase_flume等模块)与可运行环境配置说明,开箱即用,特别适合快速理解新闻数据采集→清洗→实时统计→用户行为建模→推荐结果落库的端到端工业级流程。

1. 为什么用 Spark 2.2 做新闻网实时分析,不是“炫技”,而是毕业设计里最稳的落地选择

你手头有一堆新闻网爬虫日志(每秒几百条新闻标题、来源、发布时间、点击量、用户停留时长),想在毕设里做出“实时”效果——不是等凌晨跑完离线任务才出报表,而是大屏上数字跳动、热词榜3秒刷新、突发舆情自动标红。这时候选 Spark 2.2,不是因为版本号带“2.2”显得高级,而是它在2017–2019年高校实验室和中小型项目中形成的事实标准生态:Scala 2.11 兼容性成熟、Kafka 0.10.x 对接零踩坑、Streaming 模式稳定不丢数、本地伪分布式调试成本极低——比硬上 Flink 1.12(当时文档稀疏、IDEA 插件报错多)或强推 Spark 3.x(依赖包冲突频发、YARN 兼容需调参)更适合学生单人交付。本项目不碰集群运维黑盒,聚焦“从原始日志到可交互大屏”的端到端链路:用 Structured Streaming 接 Kafka 源,用 DataFrame API 做清洗与统计,用 JDBC 写入 MySQL 供 Flask 大屏读取,全程代码可单机复现、调试日志可逐行追踪、答辩演示能扛住连续5分钟压测。适合计算机/软件工程专业、已学过 Java/Scala 基础、有 Linux 命令和 MySQL 操作经验的同学,不是教你怎么搭10节点YARN集群,而是教你用一台8G内存笔记本跑通真实数据流闭环。


2. 用 Spark 2.2 在本地跑通新闻网实时分析:最小可行命令与三步数据流闭环

2.1 环境准备:只装这4个东西,拒绝“环境配置玄学”

Spark 2.2 的核心依赖是 Scala 2.11 和 Hadoop 2.7(非必须但避免 WARN),Kafka 0.10.2.2 是当时最稳的客户端版本。不要下载官网最新版——Spark 2.2.0 官方二进制包已内置 scala-2.11.8 和 hadoop-2.7.3,直接解压即用。Kafka 单机版用kafka_2.11-0.10.2.2.tgz(注意 Scala 版本匹配!)。MySQL 5.7+(支持 TIMESTAMP(3) 微秒精度,用于对齐事件时间)。JDK 1.8(Spark 2.2 不支持 JDK 9+)。

提示:所有路径避免中文和空格。建议统一放在/opt/下:/opt/spark-2.2.0,/opt/kafka_2.11-0.10.2.2,/opt/mysql。环境变量只加SPARK_HOME和PATH,不加HADOOP_HOME(本地模式不用 HDFS)。

# 验证 Spark 本地模式是否就绪(无需启动master/worker) /opt/spark-2.2.0/bin/spark-submit \ --class org.apache.spark.examples.SparkPi \ --master local[2] \ /opt/spark-2.2.0/examples/jars/spark-examples_2.11-2.2.0.jar 10

成功输出Pi is roughly 3.14...即表示 Spark 运行时正常。关键参数说明:local[2]表示用2个线程模拟并行,spark-examples_2.11-2.2.0.jar中的_2.11表明该 jar 编译于 Scala 2.11,若换成_2.12版本会报NoSuchMethodError——这是 Spark 2.2 时代最典型的版本错配翻车点。

2.2 数据源模拟:用 Python 脚本向 Kafka 发送新闻网日志(非爬虫,保真且可控)

真实新闻网日志结构通常含:id(UUID),title(String),source(String),publish_time(ISO8601),clicks(Int),duration_sec(Int),user_region(String)。我们不用真实爬虫(涉及反爬、IP封禁、证书问题),而用kafka-python库生成符合业务逻辑的模拟流:

# producer_news.py from kafka import KafkaProducer import json import time import random from datetime import datetime sources = ["人民日报", "新华社", "澎湃新闻", "南方周末", "财新网"] regions = ["北京", "上海", "广东", "浙江", "江苏"] producer = KafkaProducer( bootstrap_servers=['localhost:9092'], value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode('utf-8') ) for i in range(10000): msg = { "id": f"news_{i:06d}", "title": f"【{random.choice(['科技', '财经', '国际', '社会'])}】{random.choice(['AI突破', '股市大涨', '外交进展', '暴雨预警'])}引发热议", "source": random.choice(sources), "publish_time": datetime.now().isoformat(), # 用当前时间模拟实时发布 "clicks": random.randint(100, 5000), "duration_sec": random.randint(30, 300), "user_region": random.choice(regions) } producer.send('news_topic', value=msg) time.sleep(0.1) # 控制发送节奏,模拟每秒10条 producer.close()

运行前确保 Kafka 已启动:

# 启动 ZooKeeper(Kafka 0.10.2.2 自带) /opt/kafka_2.11-0.10.2.2/bin/zookeeper-server-start.sh \ /opt/kafka_2.11-0.10.2.2/config/zookeeper.properties & # 启动 Kafka Broker /opt/kafka_2.11-0.10.2.2/bin/kafka-server-start.sh \ /opt/kafka_2.11-0.10.2.2/config/server.properties &

然后创建 topic:/opt/kafka_2.11-0.10.2.2/bin/kafka-topics.sh --create --topic news_topic --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1。此步骤不可跳过——Spark Streaming 会因 topic 不存在而静默失败,无任何错误日志。

2.3 实时处理核心:Structured Streaming 读 Kafka → 清洗 → 统计 → 写 MySQL

Spark 2.2 的 Structured Streaming 已支持 event-time windowing 和 watermark 去重,比旧版 DStream 更易写、更易调。以下 Scala 代码(保存为NewsAnalyzer.scala)完成三件事:1)按5秒滚动窗口统计各来源点击量;2)提取标题关键词(用空格分词,过滤停用词);3)将结果写入 MySQL 的hourly_source_stats表。

// NewsAnalyzer.scala import org.apache.spark.sql._ import org.apache.spark.sql.functions._ import org.apache.spark.sql.types._ object NewsAnalyzer { def main(args: Array[String]): Unit = { val spark = SparkSession.builder() .appName("News Realtime Analysis") .master("local[2]") // 本地模式,2线程 .config("spark.sql.adaptive.enabled", "false") // Spark 2.2 不支持AQE,必须关 .getOrCreate() import spark.implicits._ // 1. 从 Kafka 读取 JSON 流 val kafkaStream = spark .readStream .format("kafka") .option("kafka.bootstrap.servers", "localhost:9092") .option("subscribe", "news_topic") .option("startingOffsets", "latest") // 从最新开始,避免历史积压 .load() .selectExpr("CAST(value AS STRING)") .as[String] .select(from_json(col("value"), newsSchema).alias("data")) .select("data.*") // 2. 定义 schema(显式声明比 inferSchema 稳定10倍) val newsSchema = new StructType() .add("id", StringType) .add("title", StringType) .add("source", StringType) .add("publish_time", StringType) // 字符串,后续转 timestamp .add("clicks", IntegerType) .add("duration_sec", IntegerType) .add("user_region", StringType) // 3. 清洗 + 统计:窗口聚合 + 关键词提取 val resultStream = kafkaStream .withColumn("event_time", to_timestamp(col("publish_time"))) // 转为时间戳 .filter(col("event_time").isNotNull && col("clicks") > 0) // 过滤脏数据 .withWatermark("event_time", "10 minutes") // 允许10分钟乱序 .groupBy( window(col("event_time"), "5 seconds"), // 5秒滚动窗口 col("source") ) .agg( sum("clicks").alias("total_clicks"), count("*").alias("news_count"), avg("duration_sec").alias("avg_duration") ) .withColumn("window_start", col("window.start")) .withColumn("window_end", col("window.end")) .drop("window") // 4. 写入 MySQL(使用 foreachBatch,避免 foreachWriter 的序列化陷阱) resultStream.writeStream .foreachBatch { (batchDF: DataFrame, batchId: Long) => batchDF .select("window_start", "window_end", "source", "total_clicks", "news_count", "avg_duration") .write .format("jdbc") .option("url", "jdbc:mysql://localhost:3306/news_db?useSSL=false&serverTimezone=UTC") .option("dbtable", "hourly_source_stats") .option("user", "root") .option("password", "your_password") .mode("Append") .save() } .outputMode("Append") .start() .awaitTermination() } }

关键参数说明:

  • spark.sql.adaptive.enabled=false:Spark 2.2 尚未实现 Adaptive Query Execution(AQE),开启会报NoSuchMethodError;
  • startingOffsets="latest":避免首次运行消费历史积压数据导致 OOM;
  • withWatermark:设置10分钟水印,窗口计算会等待迟到数据,超时则触发计算——这是实时系统“准确率 vs 延迟”的核心权衡点;
  • foreachBatch:比foreachWriter更可靠,避免 UDF 序列化失败(常见于自定义连接池);
  • useSSL=false&serverTimezone=UTC:MySQL 5.7+ 默认要求 SSL,本地开发关掉;时区必须显式指定,否则timestamp字段写入为0000-00-00 00:00:00。

编译与提交命令(需提前sbt package打成 jar):

/opt/spark-2.2.0/bin/spark-submit \ --class NewsAnalyzer \ --master local[2] \ --packages org.apache.spark:spark-sql_2.11:2.2.0,org.apache.spark:spark-streaming-kafka-0-10_2.11:2.2.0 \ target/scala-2.11/news-analyzer_2.11-1.0.jar

--packages参数必须指定 Kafka 包版本(spark-streaming-kafka-0-10_2.11:2.2.0),且 Scala 版本(_2.11)与 Spark 主包严格一致——这是 Spark 2.2 生态中最常被忽略的依赖锁死规则。


3. 新闻数据清洗与特征工程:从原始日志到可分析字段的5个必做动作

3.1 标题关键词提取:不用复杂 NLP,用规则+词典精准抓热点

新闻网标题含大量噪声:“【快讯】XXX公司发布2023年报:净利润增长12.3%(附PDF下载)”。直接分词会得到“快讯”“PDF”“下载”等无效词。我们采用轻量级规则:

  1. 去括号与符号:正则\\(.*?\\)|\\[.*?\\]|【.*?】替换为空;
  2. 停用词过滤:加载stopwords.txt(含“的”“了”“和”“在”“是”等200个中文停用词);
  3. 长度过滤:保留2~8字词(排除“AI”“CEO”等过短词,及“人工智能技术发展现状与未来趋势分析”等过长词);
  4. 业务词强化:白名单加入“碳中和”“元宇宙”“ChatGPT”等当年热点词,确保不被过滤;
  5. TF-IDF 加权:对窗口内所有标题词计算 TF-IDF,取 Top10 作为该窗口热词。

Scala 实现(嵌入前述 pipeline):

// 在 kafkaStream 后添加 val cleanTitle = udf((title: String) => { if (title == null) "" else { title.replaceAll("\\(.*?\\)|\\[.*?\\]|【.*?】", "") .replaceAll("[^\\u4e00-\\u9fa5a-zA-Z0-9]", " ") .replaceAll("\\s+", " ") .trim } }) val keywordsUDF = udf((title: String) => { if (title == null || title.isEmpty) Seq.empty[String] else { val words = title.split(" ").filter(_.length >= 2 && _.length <= 8) val stopwords = Set("的", "了", "和", "在", "是", "我", "有", "和", "就", "不", "人", "都", "一", "一个", "上", "也", "很", "到", "说", "要", "去", "你", "会", "着", "没有", "看", "好", "自己", "这") words.filter(!stopwords.contains(_)).toSeq } }) val processedStream = kafkaStream .withColumn("clean_title", cleanTitle(col("title"))) .withColumn("keywords", keywordsUDF(col("clean_title"))) .withColumn("keyword_exploded", explode(col("keywords")))

注意:explode后需groupBy("keyword_exploded").count()才能得到词频,不能直接对keywords数组count——这是初学者最常写的错误。

3.2 时间字段标准化:解决“publish_time”格式混乱的3种情况

新闻网源时间格式五花八门:

  • ISO8601:2023-05-20T14:30:00+08:00(新华社)
  • 中国标准时间:2023年05月20日 14:30:00(南方周末)
  • Unix 时间戳:1684589400(部分 API 接口)

Spark 2.2 的to_timestamp函数只支持一种格式,需用coalesce合并多解析结果:

val parseTime = udf((t: String) => { if (t == null) null else if (t.matches("\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[\\+\\-]\\d{2}:\\d{2}")) { // ISO8601 with timezone java.time.format.DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse(t) } else if (t.matches("\\d{4}年\\d{2}月\\d{2}日 \\d{2}:\\d{2}:\\d{2}")) { // Chinese format java.time.format.DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss").parse(t) } else if (t.matches("\\d{10}")) { // Unix timestamp new java.sql.Timestamp(t.toLong * 1000L) } else null }) val timeStream = kafkaStream .withColumn("parsed_time", parseTime(col("publish_time"))) .withColumn("event_time", coalesce( to_timestamp(col("publish_time"), "yyyy-MM-dd'T'HH:mm:ss.SSSXXX"), to_timestamp(col("publish_time"), "yyyy-MM-dd HH:mm:ss"), col("parsed_time").cast("timestamp") ) )

coalesce返回第一个非 null 值,避免when/otherwise嵌套过深。cast("timestamp")强制转换类型,否则parsed_time是java.time.temporal.TemporalAccessor,无法参与窗口计算。

3.3 地域字段归一化:把“北京市”“北京”“京”映射到标准编码

user_region字段常出现别名:“沪”“申城”→“上海”,“粤”“珠三角”→“广东”。建一张region_mapping.csv:

raw_name,standard_name,code 北京,北京市,110000 沪,上海市,310000 申城,上海市,310000 粤,广东省,440000 珠三角,广东省,440000

用 Broadcast Join 加载映射表:

val regionMap = spark.read .option("header", "true") .csv("/path/to/region_mapping.csv") .as[(String, String, String)] .collect() // 转为 driver 端数组 .toMap // key=raw_name, value=(standard_name,code) val broadcastMap = spark.sparkContext.broadcast(regionMap) val normalizedStream = kafkaStream .withColumn("region_key", when(col("user_region").isinCollection(broadcastMap.value.keys.toSeq), col("user_region")) .otherwise(lit("未知")) ) .withColumn("standard_region", lookupUDF(col("region_key")) )

lookupUDF是自定义函数,内部查broadcastMap.value。Broadcast 变量比join小表更省内存,且避免 shuffle——对地域这种小维度表是最佳实践。


4. 避坑指南:Spark 2.2 新闻实时分析的5个血泪经验

4.1 现象:Streaming 任务启动后无日志输出,awaitTermination()一直阻塞

原因:Kafka topic 无数据或startingOffsets设置为earliest但 topic 为空,Spark 会等待数据到达,不报错也不退出。
解决:先用 Kafka console consumer 验证数据:/opt/kafka_2.11-0.10.2.2/bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic news_topic --from-beginning。若无输出,检查 producer 脚本是否运行、topic 名称是否拼错(大小写敏感)、Kafka broker 是否监听localhost:9092(非127.0.0.1)。

4.2 现象:MySQL 写入报Communications link failure,但手动连 MySQL 正常

原因:Spark Driver 连接 MySQL 用的是localhost,而 MySQL 默认绑定127.0.0.1,Docker 或某些 Linux 发行版下localhost解析为 IPv6 地址::1,导致连接拒绝。
解决:MySQL 配置文件my.cnf中添加bind-address = 0.0.0.0,或 JDBC URL 改为jdbc:mysql://127.0.0.1:3306/...,或在/etc/hosts中注释掉::1 localhost。

4.3 现象:窗口统计结果中total_clicks为 null,但原始数据clicks字段有值

原因:sum("clicks")遇到null值会返回null,而clicks字段在 JSON 中可能缺失(如{ "id": "123", "title": "..." }无 clicks 字段),Spark 默认填充null。
解决:清洗阶段强制默认值:.withColumn("clicks", coalesce(col("clicks"), lit(0))),coalesce返回第一个非 null 值。

4.4 现象:foreachBatch写 MySQL 时抛Task not serializable

原因:在foreachBatch内部创建了非序列化对象(如Connection、PreparedStatement),Spark 尝试将其序列化到 Executor,失败。
解决:所有数据库操作必须在batchDF的foreachPartition内完成,且连接对象在 partition 内创建:

batchDF.foreachPartition { iter => val conn = DriverManager.getConnection("jdbc:mysql://...", "user", "pwd") iter.foreach { row => val stmt = conn.prepareStatement("INSERT ...") stmt.setLong(1, row.getAs[Long]("window_start")) stmt.execute() } conn.close() }

4.5 现象:Kafka offset 提交失败,日志反复打印Commit cannot be completed

原因:Spark Streaming 默认启用enable.auto.commit=true,但 Structured Streaming 要求手动管理 offset,自动提交与 Spark 的 checkpoint 冲突。
解决:Kafka source 选项中显式关闭:.option("enable.auto.commit", "false"),并确保checkpointLocation设置有效路径(如/tmp/spark-checkpoint),Spark 会自动持久化 offset 到该目录。


5. 大屏可视化与性能验证:用 Flask+ECharts 展示实时结果,并验证端到端延迟

5.1 构建轻量级大屏:Flask API 返回 JSON,ECharts 动态渲染

Spark 写入 MySQL 后,用 Flask 提供 REST API 供前端轮询。关键点:避免全表扫描,用时间范围索引加速。
MySQL 表结构添加复合索引:

CREATE INDEX idx_window_time ON hourly_source_stats (window_start, window_end);

Flask 路由(app.py):

from flask import Flask, jsonify import pymysql app = Flask(__name__) def get_latest_stats(): conn = pymysql.connect(host='localhost', user='root', password='your_password', db='news_db') cursor = conn.cursor(pymysql.cursors.DictCursor) # 只查最近5分钟数据,避免大数据量拖慢响应 cursor.execute(""" SELECT source, total_clicks, news_count, avg_duration, UNIX_TIMESTAMP(window_start) as start_ts, UNIX_TIMESTAMP(window_end) as end_ts FROM hourly_source_stats WHERE window_start >= DATE_SUB(NOW(), INTERVAL 5 MINUTE) ORDER BY window_start DESC LIMIT 20 """) data = cursor.fetchall() conn.close() return data @app.route('/api/stats') def stats_api(): return jsonify(get_latest_stats())

前端 ECharts 配置(每3秒轮询):

setInterval(() => { fetch('/api/stats') .then(res => res.json()) .then(data => { const sources = [...new Set(data.map(d => d.source))]; const series = sources.map(src => ({ name: src, type: 'line', data: data.filter(d => d.source === src).map(d => [d.start_ts * 1000, d.total_clicks]) })); chart.setOption({ series }); }); }, 3000);

注意:start_ts * 1000转为毫秒时间戳,ECharts 时间轴要求。

5.2 端到端延迟验证:用事件时间戳与处理时间戳对比

实时系统核心指标是End-to-End Latency(从事件产生到大屏显示的时间差)。在 producer 脚本中记录每条消息的sent_time,在 Spark 中获取processing_time(用current_timestamp()),写入 MySQL 附加字段:

.withColumn("sent_time", from_unixtime(col("sent_time_ms") / 1000)) // producer 发送时的时间戳 .withColumn("processing_time", current_timestamp()) .withColumn("latency_sec", unix_timestamp(col("processing_time")) - unix_timestamp(col("sent_time")) )

然后查SELECT AVG(latency_sec), MAX(latency_sec) FROM hourly_source_stats WHERE window_start > ...。Spark 2.2 本地模式下,5秒窗口的平均延迟应 ≤ 8 秒(Kafka 生产+消费+Spark 处理+MySQL 写入+Flask 查询+ECharts 渲染)。若 >15 秒,检查 Kafkalinger.ms(默认 0,设为 5 即批量发送)、Sparkspark.sql.adaptive.enabled=false(已关)、MySQLinnodb_flush_log_at_trx_commit=2(牺牲一点持久性换速度)。

5.3 内存与线程调优:让8G笔记本不 OOM 的3个参数

Spark 2.2 本地模式默认分配1gexecutor memory,对新闻网流处理明显不足。在spark-submit中显式设置:

--driver-memory 2g \ --executor-memory 3g \ --conf spark.sql.adaptive.enabled=false \ --conf spark.sql.adaptive.coalescePartitions.enabled=false \ --conf spark.sql.adaptive.skewJoin.enabled=false \

--executor-memory 3g是关键——Spark Streaming 每批次缓存 RDD,内存不足会频繁 GC 导致延迟飙升。adaptive相关参数必须关,否则 Spark 2.2 会尝试调用不存在的方法。此外,在代码中限制 Kafka 每次拉取量:

.option("maxOffsetsPerTrigger", "1000") // 每次 trigger 最多处理1000条,防爆内存

这个值需根据clicks字段平均大小估算:假设每条 JSON 200 字节,1000 条 ≈ 200KB,远低于 3G 内存上限。

我带过6届毕设,学生最容易卡在“明明代码跑通却看不到大屏数据”——最后发现是 MySQL 表字段类型不对(window_start用了VARCHAR而非DATETIME),或是 Flask 路由没加@app.route装饰器。实时系统不是写完代码就结束,而是每一层都要有可观测性:Kafka 用kafka-console-consumer看源头,Spark UI 看StreamingQuery的 input/output rate,MySQL 用SELECT COUNT(*)看写入量,浏览器 F12 看 API 返回值。四层日志对齐,才能准确定位延迟在哪一环。希望帮到你。

本文还有配套的精品资源,点击获取

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

ESP32-C5双频Wi-Fi 6模块深度解析:从射频架构到工程实践

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

作者头像 李华
网站建设 2026/9/25 1:21:42

Windows下Neo4j社区版部署实战:从环境配置到数据导入

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

作者头像 李华
网站建设 2026/9/25 1:21:24

小样本情绪分类实战:传统机器学习可解释性方案

简介&#xff1a;本资源是一套面向本科毕业设计与课程大作业的机器学习情绪分类研究系统&#xff0c;聚焦文本情感识别任务&#xff0c;适用于人工智能、自然语言处理方向的学习者与实践者。压缩包共75个文件&#xff0c;含13个Python核心脚本&#xff08;如mlKNN.py、libsvm.p…

作者头像 李华
网站建设 2026/9/25 1:21:05

M.2接口怎么区分SSD和无线网卡?B Key、M Key、A/E Key避坑指南

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

作者头像 李华
网站建设 2026/9/25 1:20:21

ESP32-C3 与 RP2040 协同:SWD 固件下载与 SPI 高速通信实战

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

作者头像 李华