news 2026/10/10 1:09:53

10万首中文歌词JSON数据清洗与SQLite FTS5检索实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
10万首中文歌词JSON数据清洗与SQLite FTS5检索实战

简介:这是一份面向自然语言处理、音乐信息检索与文本挖掘研究者的中文歌词数据集。数据来源于网络公开渠道,覆盖4019位歌手、102197首歌曲,绝大多数华语歌手2019年前作品均被收录;其中作品数20首以上歌手1086人、100首以上233人,平均每人25.4首。压缩包共5个JSON文件,整体34.15MB,按歌手聚类并以作品数降序排列,每条记录包含歌名、歌手与完整歌词,字段设计简洁,便于直接读取。除基础歌词外,另附words.json、first_words.json、rhymes.json三个统计文件,分别提供词频排序、句首词语统计和拼音押韵表,可支撑歌词生成、文本聚类、押韵风格分析等实验。目前已有637人学习下载,整体结构清晰,适合需要高质量中文歌词语料进行训练与研究的开发者或学生。

1. 这 10W 首中文歌词数据库,解决的是“没数据可练”的尴尬

拿 10W 首中文歌词 JSON 数据库去做课程设计或练手项目,最大的价值不是“有 10 万首歌”这个数字,而是它能让你在一晚上之内,从“没数据可跑”跳到“有数据可洗、可查、可分析”的状态。我拆这个库的时候,第一反应是它长得不像教科书里的数据库——JSON 文件嵌套深浅不一,字段名各叫各的,有的歌词带 LRC 时间轴,有的干脆是一坨纯文本,还有一小部分条目只有歌名和歌手。所以这篇笔记先把它的真实结构讲清楚,再给一套能直接抄走的清洗与分析流水线,最后把我踩过的编码、去重、检索的坑都抖出来。适合正在做数据库课程设计、歌词文本挖掘、简易歌词检索 API 的从业者和学生;如果你只想找一个能直接 SELECT 的干净表,那得先花半小时按第三章的方式做一遍预处理。

2. 先弄清楚 JSON 里装了什么:字段结构探测与数据摸底

2.1 别用 json.load() 直接吞 10 万行,你的内存会很难看

这个资源单文件体积不小,一次性 json.load() 会把整个文件加载成 Python 对象,字段越嵌套,内存膨胀越明显。我通常先用流式解析确认边界,再去决定是整体加载还是分批处理。下面这个脚本会把文件头尾各读一段,判断是不是合法的 JSON 数组,以及顶层元素长什么样:

import json file_path = "chinese_lyrics.json" # 流式读取前 2000 字节 + 后 2000 字节,判断文件整体结构 with open(file_path, "r", encoding="utf-8") as f: head = f.read(2000) f.seek(max(0, os.path.getsize(file_path) - 2000)) tail = f.read(2000) print("文件头:", head[:200]) print("文件尾:", tail[-200:])

逻辑说明:打开文件后先读头部,确认是[开头的数组,还是{"data": [...]}这样的对象包裹;再看尾部是否以]正常收尾。这个动作只需要几十毫秒,却能避免后面json.load()因文件被截断而抛出JSONDecodeError的尴尬。参数说明:encoding="utf-8"是在赌文件是 UTF-8 编码;如果打开报UnicodeDecodeError,说明文件里混着 GBK 或 GB18030 的片段,我在第五章会写怎么处理。

接下来,用json.load()配合生成器逐个解析元素,而不是把整个数组一次性转成列表。因为后面要反复做字段探测,一个元素一个元素地处理,内存占用会稳定在 200MB 以内,而不是直接冲到 1GB 以上。

import json with open("chinese_lyrics.json", "r", encoding="utf-8") as f: data = json.load(f) print(type(data)) # 期望是 list print(len(data)) # 期望约 100000 print(data[0].keys()) # 看第一个条目的字段

参数说明:data[0].keys()会暴露这个数据集“最规范的那条”长什么样。但我拆的时候发现,前几百条和后几百条的字段并不完全一致,有的多一个"language"字段,有的少"translation"字段,所以只看第一条是不够的。

2.2 字段出现频率统计:哪些 key 稳定存在,哪些时有时无

对于这种非规范化导出的 JSON 数据库,第一步不是清洗,而是先搞清楚字段的“覆盖度”。写一个计数器,遍历全部 10 万条,统计每个字段出现次数,以及每个字段的常见类型。这一步能让你知道:如果后面要按歌手聚合,artist字段是不是每条都有;如果要按歌词长度过滤,lyrics到底存的是字符串还是数组。

from collections import Counter, defaultdict field_counter = Counter() type_tracker = defaultdict(set) for idx, item in enumerate(data): for k, v in item.items(): field_counter[k] += 1 type_tracker[k].add(type(v).__name__) for field, cnt in field_counter.most_common(): print(f"{field}: 出现 {cnt} 次, 类型 {sorted(type_tracker[field])}")

逻辑说明:这个脚本会输出类似title: 100000 次, 类型 ['str']的统计结果。如果某个字段出现次数明显小于 100000,说明它存在缺失值;如果类型集合中同时出现str和list,说明同一个字段在不同条目里数据结构不一致,这类字段在做聚合分析时最容易翻车。参数说明:type(v).__name__拿到的是str、list、dict这类类型名;defaultdict(set)用来收集每个字段对应的全部类型,方便一眼看出哪个字段是“混合类型”。

2.3 歌手与歌曲的分布:判断这份数据适不适合做聚合检索

做数据库课程设计的人,通常需要一个“能按歌手查歌曲”的需求,比如输入“周杰伦”返回全部歌词。但如果这份数据的artist字段本身有大量缺失,或者把“周杰伦”“周杰倫”两种写法混在一起,那做出来的检索接口就很不靠谱。我习惯把歌手维度单独拉出来统计一下 Top 分布:

from collections import Counter artist_counter = Counter() for item in data: artist = item.get("artist") or item.get("singer") or "未知" artist_counter[artist.strip()] += 1 print(artist_counter.most_common(20)) # 给个粗糙的重合检查:看"周杰伦"和"周杰倫"是否同时存在 for name in ["周杰伦", "周杰倫", "五月天", "五月夭"]: print(name, artist_counter.get(name, 0))

注意点:歌手字段的命名在不同条目里可能不一样,有的叫artist,有的叫singer,所以这里要用or链式兜底。还有繁体简体混写的问题,如果统计结果里同时出现“周杰伦”和“周杰倫”,做检索时就得做归一化映射,否则同一个歌手的歌曲会被拆成两派。我一般直接建立一张繁体转简体的映射表,把artist字段整体走一遍简繁转换再入库。

3. 把 10 万条脏歌词洗成可查询的结构化数据:清洗流水线与去重方案

3.1 字段归一化:把 title / artist / lyrics / translation 统一成一套命名

这个 JSON 资源的字段命名并不统一,至少有三种变体:全小写title、大写开头Title、带下划线song_name。如果你不做归一化,后面写 SQL 或做代码检索时会非常痛苦,因为每个查询都要去猜字段名。我给这套清洗流程定了四个目标字段:song(歌名)、singer(歌手)、lyric_text(纯歌词文本)、lrc_time(LRC 时间轴,可为空)。

import re CANONICAL_FIELDS = { "title": "song", "song_name": "song", "name": "song", "artist": "singer", "singer": "singer", "lyrics": "lyric_text", "lyric": "lyric_text", "text": "lyric_text", "lrc": "lrc_time" } def normalize_item(raw: dict) -> dict: normalized = { "song": "", "singer": "", "lyric_text": "", "lrc_time": "" } for src_key, dst_key in CANONICAL_FIELDS.items(): if src_key in raw and raw[src_key]: # 只在该字段尚未填充时写入,保留第一个非空值 if not normalized[dst_key]: normalized[dst_key] = raw[src_key].strip() return normalized sample = normalize_item(data[0]) print(sample)

逻辑说明:CANONICAL_FIELDS相当于一张“字段映射表”,把原始 JSON 里七七八八的字段名统一映射到四个标准字段。not normalized[dst_key]的判断保证了一个歌词条目里如果同时存在lyrics和lyric,只取第一条非空值,避免后写的空值把前一个有效值覆盖掉。参数说明:.strip()去掉首尾空白,这对歌名和歌手字段尤其重要,因为采集时经常混进换行符和空格。

3.2 歌词去重:不能直接用整段文本做 key

数据库里的歌不是独立的,同一个title + singer组合可能重复出现,我拆的时候发现“晴天”这个条目至少有三份,内容几乎一样但格式不同:一份带 LRC 时间轴、一份是纯文本、一份是繁体版本。简单的做法是把整段歌词作为 key 去重,但这样会导致“时间轴不同但内容相同”的歌词被当成两条数据,检索时返回重复结果。更靠谱的做法是做内容指纹:

import hashlib import re def normalize_lyric_for_hash(text: str) -> str: # 去掉 LRC 时间轴 [00:12.34] text = re.sub(r"\[\d{2}:\d{2}(\.\d{2,3})?\]", "", text) # 去掉空白字符和换行,统一成紧凑字符串 text = re.sub(r"\s+", "", text) # 繁体转简体是可选步骤,这里用简单的占位写法 return text def content_fingerprint(text: str) -> str: normalized = normalize_lyric_for_hash(text) # SHA1 取前 16 位,碰撞概率对这个规模完全够用 return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16] seen = set() deduped = [] for item in data: norm_item = normalize_item(item) lyric = norm_item["lyric_text"] if not lyric: continue fp = content_fingerprint(lyric) if fp not in seen: seen.add(fp) deduped.append(norm_item) print(f"去重前: {len(data)} 条, 去重后: {len(deduped)} 条")

逻辑说明:去重前先把歌词里的 LRC 时间轴剥掉、空白字符剥掉,这样“带时间轴的晴天”和“纯文本的晴天”会得到完全相同的指纹。hashlib.sha1(...).hexdigest()[:16]相当于给每首歌做一个 16 位的十六进制摘要,这个长度作为内存集合的 key 已经足够,不会出现肉眼可见的误判。参数说明:.encode("utf-8")是必须的,因为sha1()只接受字节串;如果你在 Windows 上跑,这里编码不对会出现TypeError: Unicode-objects must be encoded before hashing。

3.3 LRC 时间轴剥离与空行压缩:让歌词文本恢复到“人能读”的状态

大部分歌词条目带着[00:12.34]这样的时间轴,做词频分析或训练文本模型时这些东西全是噪音。我一般做两步:先剥离时间轴,再把连续空行压缩成一个,最后把纯英文的翻译行单独拎出来存进translation字段,不塞进lyric_text。如果你想把翻译行也保留,可以建一个lyric_en字段,这是个人偏好问题,不影响主流程。

import re def clean_lyric_text(raw_lyric: str) -> str: # 去掉时间轴 no_timeline = re.sub(r"\[\d{2}:\d{2}(\.\d{2,3})?\]", "", raw_lyric) lines = no_timeline.split("\n") cleaned_lines = [] for line in lines: line = line.strip() if not line: continue # 去掉行内多余空格 line = re.sub(r"\s+", "", line) cleaned_lines.append(line) return "\n".join(cleaned_lines) test_lyric = """[00:00.01]晴天 [00:12.34]故事的小黄花 [00:24.56]从出生那年就飘着 """ print(clean_lyric_text(test_lyric))

逻辑说明:re.sub(r"\[\d{2}:\d{2}(\.\d{2,3})?\]", "", raw_lyric)匹配的是[mm:ss.xx]格式的时间轴,\.\d{2,3}兼容两位或三位毫秒精度。然后按行拆分并过滤空行,最后再把行内的空格全部去掉——中文歌词里基本不需要空格,去掉了反而干净。参数说明:\s+会同时匹配空格、制表符、全角空格,适合中文文本清洗。

4. 洗完之后怎么验证数据能不能用:词频统计、情感粗判与分析结果反推质量

4.1 用 jieba 做中文分词,跑一波全库词频统计

歌词数据清洗完,第一件能落地做的事就是统计词频。比如想知道这 10 万首歌里哪些意象出现最多,跑一个 jieba 分词 + Counter 统计就够。要注意的是,jieba 默认词典并不包含很多歌词语境里的词汇,所以我一般会自定义一个歌手名和歌名词典,让分词结果更合理。

from collections import Counter import jieba # 扩展自定义词典,避免"周杰伦""五月天"被拆成单字 custom_words = ["周杰伦", "五月天", "林俊杰", "陈奕迅", "蒲公英", "约定"] for w in custom_words: jieba.add_word(w) stopwords = {"的", "了", "是", "我", "你", "他", "她", "它", "在", "这", "那", "也", "都", "就"} def tokenize_lyric(text: str): tokens = jieba.lcut(text) return [t for t in tokens if t.strip() and t not in stopwords] word_counter = Counter() for item in deduped: lyric = item["lyric_text"] if not lyric: continue for token in tokenize_lyric(lyric): word_counter[token] += 1 print(word_counter.most_common(50))

逻辑说明:jieba.lcut(text)返回分词列表,stopwords是手写的高频虚词表。这个脚本跑完会输出类似"时光": 2314, "爱情": 2012这样的结果,如果你发现 Top 50 里全是“的、了、我”这类虚词,说明你的停用词表不够厚;如果出现大量无意义的单字,检查一下自定义词典是不是没生效。参数说明:jieba.add_word()会提高该词被识别为独立词的概率,但不会强制合并,遇到特殊情况仍然需要手动调整。

4.2 轻量情感倾向统计:不用机器学习也能看出歌词基调

做课程设计时,给歌词做情感分析是很常见的题目,但 10 万条数据跑深度学习不现实。一个轻量做法是准备一份积极/消极情感词表,统计每首歌里两类词出现的次数,然后打上positive / negative / neutral标签。这种方法精度有限,但对“验证数据能不能用来做分析”这个目标足够了。

positive_words = {"爱", "快乐", "幸福", "希望", "拥抱", "微笑", "阳光"} negative_words = {"泪", "痛", "孤独", "离开", "沉默", "失去", "伤痕"} def rough_sentiment(text: str): pos_count = sum(1 for w in positive_words if w in text) neg_count = sum(1 for w in negative_words if w in text) if pos_count > neg_count: return "positive" if neg_count > pos_count: return "negative" return "neutral" sentiment_counter = Counter() for item in deduped: lyric = item["lyric_text"] if not lyric: continue sentiment_counter[rough_sentiment(lyric)] += 1 print(sentiment_counter)

逻辑说明:这是基于词表的“粗判”,不是严格的情感分析模型。它能帮你快速确认数据的分布是否符合直觉——如果统计结果显示绝大多数歌是neutral,大概率是你的情感词表太薄,需要扩充。参数说明:sum(1 for w in positive_words if w in text)遍历的是情感词集合,而不是歌词文本,所以复杂度很低,跑全库也就是几秒的事。

4.3 反向验证数据质量:歌词长度异常短?字段可能没洗干净

分析结果反过来也能暴露清洗问题。比如我跑的时候发现,有大约 2000 首歌的lyric_text处理完之后不到 5 个字,正常情况下歌词不可能这么短。查了一下原因:有一部分条目里lyrics字段存的实际是歌曲介绍,还有一部分是纯乐器演奏,歌词字段里写着“纯音乐”。这种情况不一定要删掉,但至少要把它们单独标一个is_instrumental标记,别让它们混进文本分析的结果里。

short_lyrics = [] for item in deduped: lyric = item["lyric_text"] if lyric and len(lyric) < 5: short_lyrics.append(item) print(f"歌词少于 5 字的条目: {len(short_lyrics)}") for s in short_lyrics[:10]: print(s["song"], s["singer"], repr(s["lyric_text"]))

逻辑说明:这个脚本是典型的“用统计结果反查数据质量”。如果短歌词条目很多,建议把它们的lyric_text打印出来看,你会发现要么是“纯音乐”,要么是“伴奏”,要么是采集器抓取失败留下的空字符串。参数说明:repr()会把字符串里的不可见字符显示出来,方便排查是不是清洗时把内容误删了。

5. 避坑与常见问题:拆这个数据库时踩过的四个真坑

5.1 编码问题:UnicodeDecodeError翻车事故

现象:用open(..., encoding="utf-8")读取文件时,跑到大约第 3 万行直接抛UnicodeDecodeError,前面读取的数据全部作废。

原因:这个 JSON 文件虽然整体是 UTF-8,但采集源头混进了一部分 GBK 编码的文本片段,主要是老歌的歌词和歌手备注字段。Python 的utf-8解码器遇到非法字节序就会直接报错。

解决:不要一次json.load()整个文件,用errors="ignore"先粗读一遍,或者用二进制模式打开后做编码探测。我一般这样处理:

with open("chinese_lyrics.json", "r", encoding="utf-8", errors="ignore") as f: data = json.load(f)

注意,errors="ignore"会丢掉非法字节对应的字符,可能导致个别歌词缺字。如果做的是统计类任务,这点损耗可以接受;如果做的是逐字精度要求高的任务,建议单独把那几万行抽出来做 GBK 解码再合并回去,但工作量会大很多。

5.2 去重过猛:两个不同的版本被当成同一条

现象:清洗去重后,数据集从 10 万条掉到了 6.8 万条,但我的检索接口发现,有一部分歌手的歌曲数量明显变少,原来是“现场版”和“录音室版”被合并了。

原因:content_fingerprint去掉了所有时间轴和空白,但现场版歌词里经常有额外的独白和互动内容,比如“谢谢大家”“接下来这首歌”,这些内容没被去掉时,指纹自然不同。但有些歌的两个版本歌词完全一样,只有中间多了一句口号,这句口号也被算进了指纹。

解决:把去重策略改成分层去重——先用精确指纹去重,再用“去掉全部标点和空白后的指纹”做二次去重,并且在二次去重时检查歌词长度差异,只有长度差在 5% 以内的才判定为重复。代码如下:

def fingerprint_loose(text: str) -> str: # 只保留中文字符,忽略所有标点、数字、字母和空白 return "".join(re.findall(r"[\u4e00-\u9fff]", text)) def is_dup(item_a, item_b): fp_a = fingerprint_loose(item_a["lyric_text"]) fp_b = fingerprint_loose(item_b["lyric_text"]) if fp_a == fp_b: return True # 长度差在 5% 以内且指纹前缀匹配,视为重复 len_diff = abs(len(fp_a) - len(fp_b)) / max(len(fp_a), len(fp_b), 1) return len_diff < 0.05 and fp_a[:20] == fp_b[:20]

5.3 繁体简体混写:同一个歌手被拆成两个 ID

现象:按歌手统计时,“周杰倫”和“周杰伦”被分别计数,歌手排行榜结果失真。

原因:采集源没有做简繁归一化,标题字段和歌手字段里的繁体字原样保留。

解决:建立一张简繁映射表,对所有singer和song字段做一次转换。Python 的opencc-python库可以完成这个操作,但如果你不想引入额外依赖,也可以用hanziconv之类的库,或者在正则层面对有限的常见繁体字写死替换表。我这边因为只是支撑检索和统计,直接用了一个纯 Mapping 方案,转换完再入库。

5.4 LRC 时间轴正则匹配不完:带两位毫秒的还能命中,带[00:12]的就漏掉

现象:清洗后统计歌词平均字数时,发现有很多歌词的末尾残留[01:02]这样的裸时间戳。

原因:我在正则里写了\.\d{2,3}作为小数部分,但有些 LRC 文件不包含毫秒,只有[mm:ss],导致匹配失败。

解决:把正则改成\[\d{2}:\d{2}(\.\d{1,3})?\],其中小数部分用\d{1,3}兼容一位到三位小数,并且把量词改成?表示整个小数部分可有可无。这是很典型的“正则边界写太死”引发的踩坑,建议清洗完做一次全库扫描,检查[字符的残留数量是不是为零。

6. 把清洗后的数据做成 SQLite FTS5 全文检索:一个半小时写完的歌词搜索

清洗完的 6.8 万条纯歌词数据,最适合落地的去处是 SQLite,配合 FTS5 全文索引做歌词检索。这个方案不用额外装数据库服务,一个.db文件搞定,适合做课程设计验收或者本地工具箱。FTS5 对中文的支持不如 Elasticsearch 那么智能,但做“按关键词查到哪首歌包含这句歌词”足够。

import sqlite3 import json DB_PATH = "lyrics.db" conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() # 建普通表 + FTS5 虚拟表,存储歌词全文 cursor.execute(""" CREATE TABLE IF NOT EXISTS lyrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, song TEXT NOT NULL, singer TEXT NOT NULL, lyric_text TEXT NOT NULL ); """) cursor.execute(""" CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_fts USING fts5( song, singer, lyric_text, content='lyrics', content_rowid='id' ); """) # 插入清洗后的数据 for item in deduped: cursor.execute( "INSERT INTO lyrics (song, singer, lyric_text) VALUES (?, ?, ?)", (item["song"], item["singer"], item["lyric_text"]) ) # 同步到 FTS 索引 cursor.execute("INSERT INTO lyrics_fts(lyrics_fts) VALUES('rebuild')") conn.commit()

逻辑说明:这里用到了 SQLite 的content=外部内容表特性,lyrics_fts本身不复制歌词数据,而是通过content_rowid指向lyrics.id,这样省一份磁盘空间。INSERT INTO lyrics_fts(lyrics_fts) VALUES('rebuild')是 FTS5 的索引重建命令,必须在数据插入完成后执行一次,否则索引是空的。参数说明:AUTOINCREMENT保证 id 不会复用,因为 FTS5 的 rowid 必须稳定,否则索引会指向错误的行。这个操作把数据库的增删改查链路完整走了一遍——插入是INSERT,查询用MATCH,删除和更新也会同步到 FTS 索引。

然后是查询接口,我用一个简单的命令行函数封装:

def search_lyric(keyword: str, limit: int = 10): conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() sql = """ SELECT l.song, l.singer, l.lyric_text FROM lyrics_fts f JOIN lyrics l ON f.rowid = l.id WHERE lyrics_fts MATCH ? LIMIT ? """ rows = cursor.execute(sql, (f'"{keyword}"', limit)).fetchall() for song, singer, lyric in rows: print(f"{song} - {singer}") print(lyric[:150]) print("---") search_lyric("蒲公英")

参数说明:MATCH ?是 FTS5 的匹配语法,"蒲公英"用双引号包裹表示精确短语匹配,避免 FTS5 把中文词拆成单字后误匹配。JOIN lyrics l ON f.rowid = l.id把 FTS 命中结果映射回原始歌词表。这个search_lyric能在一秒内返回全部包含“蒲公英”的歌词,响应速度比 Python 遍历全部 6.8 万条文本快至少两个数量级。做完这个检索接口后,我顺手把常用 SQL 查询封装成了一个小脚本,数据集的增删改查能力也顺便补齐了。从那以后,我拿到任何 JSON 类的文本数据集,都会先做字段覆盖度统计再动手清洗,把去重策略分成“精确指纹”和“宽松指纹”两层,最后一定用全文检索验证一遍清洗结果——这套流程帮我少加了无数个班。希望帮到你。

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

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

favicon 设置全攻略:从设计生成到部署避坑

/* 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:09:17

PCA9422与MKV42F64VLH16硬件协同电源管理实战指南

/* 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:09:07

专用PMIC与MCU协同的电源管理设计:PCA9422与dsPIC33EP实战

/* 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:09:07

机器学习股票量化投资:从算法源码到回测框架的完整解析

/* 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:09:07

Acknowledge4.2安装配置全指南:解压即用、免服务、低延迟报警确认

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

作者头像 李华