简介:这份资源是缠中说禅博客的完整归档压缩包,面向对缠论与股票、期货技术分析感兴趣的投资者及研究者,尤其适合希望系统研读原文、结合评论理解理论脉络的中高级学习者。包内共约2000个文件,以html网页存档为主体,辅以大量jpg、gif、png图片及少量css、js样式脚本,另有若干bin与无后缀数据文件,整体约72.86MB,基本还原了博客的图文排版与阅读结构。资源核心价值在于完整收录缠中说禅关于走势类型、中枢、级别、背驰与买卖点的理论阐述,并附带全部读者评论,其中包含对具体走势的解析、缠论应用讨论及答疑内容,便于读者在原文与互动中对照理解理论如何落地。目前已有6211人学习下载,可作为缠论学习与实战复盘的一手参考资料。
1. 缠中说禅博客(带全部评论):一个被低估的文本语料工程
如果你手里有一份「缠中说禅博客(带全部评论)」的完整数据,你面对的不是一个简单的网页存档,而是一个横跨数年、包含正文与海量互动评论的中文长文本语料库。正文部分涉及大量技术分析术语、市场逻辑推演,评论部分则是读者与作者之间的多轮追问、质疑和补充,信息密度极高。很多人拿到这类数据的第一反应是「先存着」,但真正有价值的问题是:怎么把它从一堆 HTML 或纯文本,变成可检索、可分析、可复现的结构化数据?这篇文章面向的是手里已经有原始数据、或者准备从零搭建这套语料管线的开发者。我会按「数据清洗 → 结构化存储 → 全文检索 → 评论关系重建 → 增量更新」这条线,把每一步的命令、参数和踩坑点讲清楚。适合有 Python 基础、做过爬虫或数据清洗的读者,新手可以跟着步骤走,熟手可以直接跳到参数调优和边界讨论。
2. 从原始页面到干净文本:清洗管线的四个关键决策
2.1 为什么不能直接上 BeautifulSoup 一把梭
拿到「缠中说禅博客(带全部评论)」的原始数据,常见形态有三种:单页 HTML 存档、按日期分文件的纯文本、或者从数据库导出的 CSV。不管哪种,第一步都是把正文和评论分离。很多人直接写一个soup.find_all('div', class_='content')就完事,结果发现正文里混进了「上一篇 / 下一篇」的导航链接,评论里混进了「回复」按钮的文字。更麻烦的是,早期博客平台的 HTML 结构不统一,同一批数据里可能有三四种不同的 class 命名。
我的做法是先做一轮结构探测,统计所有div和p标签的 class 分布,找出出现频率最高且文本长度中位数最大的那几个 class,大概率就是正文和评论的容器。这一步不需要写复杂代码,用collections.Counter就能跑。
from bs4 import BeautifulSoup from collections import Counter import glob # 统计所有 div 的 class 分布,找出正文和评论的候选容器 class_counter = Counter() for filepath in glob.glob('./raw_html/*.html'): with open(filepath, 'r', encoding='utf-8') as f: soup = BeautifulSoup(f.read(), 'html.parser') for div in soup.find_all('div'): cls = div.get('class') if cls: # 只统计文本长度超过 50 的 div,过滤掉导航和按钮 text_len = len(div.get_text(strip=True)) if text_len > 50: class_counter.update(cls) # 打印出现频率最高的 20 个 class for cls_name, count in class_counter.most_common(20): print(f"{cls_name}: {count}")这段代码的逻辑是:遍历所有 HTML 文件,对每个div提取 class 属性,只保留文本长度超过 50 的(过滤掉短导航),然后统计 class 出现频率。参数text_len > 50需要根据实际数据调整——如果评论普遍很短,可以降到 20;如果正文段落很长,可以提到 100。跑完之后你会看到类似post-content: 3200、comment-body: 15000这样的分布,正文和评论的容器就浮出来了。
2.2 正文与评论的边界判定:用时间戳和作者字段做锚点
分离出容器之后,下一个问题是:怎么确定哪条评论属于哪篇正文?常见做法是找「评论列表」的父容器,然后按子元素顺序遍历。但「缠中说禅博客(带全部评论)」这类数据有个特点:评论可能跨页,或者同一篇正文下的评论被折叠在「查看更多」按钮后面。如果你拿到的已经是展开后的完整 HTML,那直接按 DOM 顺序提取即可;如果评论是异步加载的,就需要检查原始数据里有没有内嵌的 JSON。
我一般会先检查页面里有没有window.__INITIAL_STATE__或类似的脚本变量,很多博客平台会把评论数据以 JSON 形式塞在<script>标签里。如果有,直接解析 JSON 比解析 HTML 靠谱得多。
import json import re def extract_comments_from_script(html_content): """从 script 标签中提取内嵌的评论 JSON""" # 匹配常见的初始化状态变量 patterns = [ r'window\.__INITIAL_STATE__\s*=\s*(\{.*?\});', r'window\.__DATA__\s*=\s*(\{.*?\});', r'var\s+comments\s*=\s*(\[.*?\]);' ] for pattern in patterns: match = re.search(pattern, html_content, re.DOTALL) if match: try: data = json.loads(match.group(1)) # 根据实际结构取评论列表,这里假设是 data['comments'] return data.get('comments', []) except json.JSONDecodeError: continue return [] # 使用示例 with open('./raw_html/post_001.html', 'r', encoding='utf-8') as f: html = f.read() comments = extract_comments_from_script(html) print(f"提取到 {len(comments)} 条评论")这里的关键参数是正则里的.*?和re.DOTALL——DOTALL让.能匹配换行符,.*?是非贪婪匹配,防止跨多个 script 标签。如果 JSON 里嵌套了 HTML 实体(比如<),还需要做一轮html.unescape。这一步的坑在于:有些平台的 JSON 是分页加载的,第一页只有 20 条,剩下的在后续请求里。如果你拿到的存档没有包含全部评论,那就需要回到数据采集阶段补全,而不是在清洗阶段硬凑。
2.3 文本归一化:全角半角、空白符和编码陷阱
清洗到这一步,你手里应该有了正文和评论的原始文本。接下来是归一化。中文博客数据里最常见的问题是:全角标点混半角、不间断空格(\xa0)、零宽字符(\u200b)、以及 GBK 和 UTF-8 混编导致的乱码。我见过最离谱的一份数据里,同一篇文章的正文是 UTF-8,评论是 GBK,直接合并后满屏「锟斤拷」。
处理顺序很重要:先统一编码到 UTF-8,再做字符级替换。不要反过来,否则 GBK 里的某些字节会被误判为 UTF-8 的合法序列,产生更隐蔽的乱码。
import unicodedata def normalize_text(text): """文本归一化:编码统一、空白符处理、全角转半角""" if isinstance(text, bytes): # 先尝试 UTF-8,失败则回退 GBK try: text = text.decode('utf-8') except UnicodeDecodeError: text = text.decode('gbk', errors='replace') # 替换不间断空格和零宽字符 text = text.replace('\xa0', ' ').replace('\u200b', '') # 全角转半角(保留中文标点) result = [] for char in text: code = ord(char) if code == 0x3000: # 全角空格 code = 0x20 elif 0xFF01 <= code <= 0xFF5E: # 全角字符范围 code -= 0xFEE0 result.append(chr(code)) text = ''.join(result) # 合并连续空白符为单个空格,但保留换行 text = re.sub(r'[^\S\n]+', ' ', text) text = re.sub(r'\n{3,}', '\n\n', text) return text.strip() # 测试 raw = "这是正文\xa0\xa0测试\u200b内容" print(normalize_text(raw))这段代码的参数说明:errors='replace'在解码失败时用替换字符占位,避免整个流程崩溃;全角转半角的范围0xFF01-0xFF5E覆盖了英文字母、数字和常见符号,但中文标点(如。、,)不在这个范围,会被保留。如果你希望中文标点也统一,需要额外处理0x3000-0x303F区间。re.sub(r'[^\S\n]+', ' ', text)的意思是:把所有非空白字符之外的连续空白(不包括换行)压缩成一个空格,这样既清理了多余空格,又保留了段落结构。
2.4 输出结构化 JSONL:字段设计与去重策略
清洗完的文本需要落盘。我推荐 JSONL(每行一个 JSON 对象),因为它在流式处理和增量追加时比 CSV 方便得多。字段设计上,正文和评论分开存,用post_id关联。每条评论至少要有:comment_id、post_id、author、content、timestamp、reply_to(如果是回复某条评论)。
去重是个容易被忽略的环节。「缠中说禅博客(带全部评论)」的数据里,同一篇正文可能被多次采集,评论也可能因为分页加载而重复。我的策略是:正文用post_id去重,评论用(post_id, author, timestamp, content[:50])做联合去重。content[:50]取前 50 个字符作为指纹,避免长评论因末尾差异导致去重失败。
import json import hashlib def save_as_jsonl(posts, comments, output_path): """将正文和评论分别保存为 JSONL,评论用联合指纹去重""" seen_comments = set() with open(output_path, 'w', encoding='utf-8') as f: for post in posts: record = { 'type': 'post', 'post_id': post['id'], 'title': post['title'], 'content': post['content'], 'timestamp': post['timestamp'] } f.write(json.dumps(record, ensure_ascii=False) + '\n') for comment in comments: # 联合指纹去重 fingerprint = hashlib.md5( f"{comment['post_id']}|{comment['author']}|{comment['timestamp']}|{comment['content'][:50]}".encode('utf-8') ).hexdigest() if fingerprint in seen_comments: continue seen_comments.add(fingerprint) record = { 'type': 'comment', 'comment_id': comment['id'], 'post_id': comment['post_id'], 'author': comment['author'], 'content': comment['content'], 'timestamp': comment['timestamp'], 'reply_to': comment.get('reply_to') } f.write(json.dumps(record, ensure_ascii=False) + '\n') print(f"写入完成,正文 {len(posts)} 篇,评论 {len(seen_comments)} 条") # 使用示例 # save_as_jsonl(posts_list, comments_list, './clean/blog_data.jsonl')ensure_ascii=False保证中文不被转义成\uXXXX,方便直接阅读。hashlib.md5在这里只做指纹用,不涉及安全场景。如果你需要保留评论的层级关系(比如「回复某条评论」),reply_to字段要存父评论的comment_id,后续可以用它重建树形结构。
3. 全文检索落地:用 SQLite FTS5 把评论查明白
3.1 为什么选 SQLite FTS5 而不是 Elasticsearch
「缠中说禅博客(带全部评论)」的数据量通常在几十万到几百万条评论之间,这个量级用 Elasticsearch 属于杀鸡用牛刀——部署重、内存占用高、维护成本大。SQLite 的 FTS5 扩展在单机环境下能轻松处理百万级文档的全文检索,而且零依赖、单文件、备份就是复制一个.db文件。我实测过:50 万条中文评论,建索引不到 30 秒,查询响应在 10 毫秒以内。
当然,FTS5 也有边界:它不支持分布式、不支持复杂的相关性排序(比如 BM25 的变体),中文分词需要自己处理。但对于个人开发者或小团队做语料分析,这些都不是问题。如果你后续要做语义搜索,可以在 FTS5 之上再加一层向量索引,那是另一个话题。
3.2 建表与索引:中文分词的两个方案
FTS5 默认的分词器对中文不友好,它会把整段中文当成一个 token。解决方案有两个:一是用unicode61分词器配合手动插入空格(预处理阶段把中文按字或按词分开),二是编译 SQLite 时启用simple分词器或第三方中文分词扩展。我一般选第一个方案,因为不需要重新编译 SQLite,用 Python 的jieba在入库前做分词即可。
import sqlite3 import jieba def build_fts_index(jsonl_path, db_path): """从 JSONL 构建 FTS5 索引,中文用 jieba 预分词""" conn = sqlite3.connect(db_path) cursor = conn.cursor() # 创建主表和 FTS5 虚拟表 cursor.execute(''' CREATE TABLE IF NOT EXISTS documents ( doc_id TEXT PRIMARY KEY, doc_type TEXT, post_id TEXT, author TEXT, content TEXT, timestamp TEXT ) ''') cursor.execute(''' CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5( content, author, content='documents', content_rowid='rowid', tokenize='unicode61' ) ''') # 读取 JSONL 并插入 import json with open(jsonl_path, 'r', encoding='utf-8') as f: for line in f: record = json.loads(line) # 用 jieba 分词,空格连接 segmented = ' '.join(jieba.cut(record['content'])) cursor.execute(''' INSERT OR REPLACE INTO documents (doc_id, doc_type, post_id, author, content, timestamp) VALUES (?, ?, ?, ?, ?, ?) ''', ( record.get('comment_id') or record.get('post_id'), record['type'], record.get('post_id', ''), record.get('author', ''), segmented, record.get('timestamp', '') )) # 重建 FTS 索引 cursor.execute("INSERT INTO docs_fts(docs_fts) VALUES('rebuild')") conn.commit() conn.close() print("索引构建完成") # 使用示例 # build_fts_index('./clean/blog_data.jsonl', './clean/blog.db')关键参数说明:content='documents'表示 FTS5 表是外部内容表,不重复存储原文,节省空间;content_rowid='rowid'关联主表的 rowid;tokenize='unicode61'是基础分词器,配合 jieba 预分词后的空格分隔文本,效果等价于中文分词。INSERT OR REPLACE保证重复运行不会报错。rebuild命令在批量插入后重建索引,比逐条插入时自动更新索引快得多。
3.3 查询语法与排序:让「缠中说禅」的评论按相关度出来
FTS5 的查询语法很灵活,支持AND、OR、NOT、短语搜索(用双引号)和前缀搜索(用*)。比如你想找评论里同时提到「中枢」和「背驰」的内容:
SELECT d.doc_id, d.author, d.content, d.timestamp FROM docs_fts f JOIN documents d ON d.rowid = f.rowid WHERE docs_fts MATCH '中枢 AND 背驰' ORDER BY rank LIMIT 20;ORDER BY rank是 FTS5 的内置相关性排序,基于 BM25 算法。如果你希望按时间排序,把rank换成d.timestamp DESC即可。注意:MATCH后面的字符串里,中文词之间用空格分隔表示 AND 关系,用OR显式指定或关系。短语搜索要加双引号,比如"走势终完美"会匹配连续出现的这五个字。
一个常见的坑是:FTS5 的MATCH对大小写不敏感,但对中文标点敏感。如果你的查询词里带了全角逗号,可能匹配不到。建议在查询前对关键词做和入库时相同的归一化处理。
3.4 性能调优:批量插入、PRAGMA 和索引重建时机
当数据量超过 10 万条时,逐条INSERT会非常慢。我的做法是:先用executemany批量插入主表,再一次性rebuildFTS 索引。另外,SQLite 的PRAGMA设置对写入速度影响很大:
conn = sqlite3.connect(db_path) conn.execute('PRAGMA journal_mode=WAL') # 写前日志,提升并发读性能 conn.execute('PRAGMA synchronous=OFF') # 批量导入时关闭同步,导入后改回 NORMAL conn.execute('PRAGMA cache_size=-64000') # 64MB 缓存 conn.execute('PRAGMA temp_store=MEMORY') # 临时表放内存synchronous=OFF在导入阶段能提速 3-5 倍,但断电可能丢数据。导入完成后记得改回NORMAL或FULL。cache_size的负值表示 KB,-64000就是 64MB。这些参数不是玄学,是实打实的写入优化,但别在线上环境用synchronous=OFF,血泪经验。
4. 评论关系重建:从扁平列表到对话树
4.1 识别回复关系:三种常见的数据形态
「缠中说禅博客(带全部评论)」的评论数据,回复关系通常有三种存法:一是每条评论带parent_id字段,直接指向父评论;二是评论按时间顺序排列,回复关系靠@某人的文本模式推断;三是嵌套的 JSON 结构,子评论在replies数组里。第一种最好处理,第二种需要写正则,第三种直接递归解析。
如果你拿到的数据里没有parent_id,但评论内容里有@某某,可以用正则提取被回复者,再结合时间顺序和作者名做匹配。但这种方法有误差:同名用户、或者@后面跟的不是用户名而是普通文本,都会导致误判。我的建议是:如果原始数据没有明确的关系字段,不要强行重建,保留扁平结构,在查询时用「同一 post_id 下按时间排序」来近似对话流。
4.2 用递归 CTE 查询对话链
假设你的数据里已经有reply_to字段,SQLite 的递归 CTE 可以轻松查出某条评论的完整对话链:
WITH RECURSIVE thread AS ( -- 锚点:起始评论 SELECT comment_id, post_id, author, content, reply_to, 0 AS depth FROM documents WHERE comment_id = 'target_comment_id' UNION ALL -- 递归:向上找父评论 SELECT d.comment_id, d.post_id, d.author, d.content, d.reply_to, t.depth + 1 FROM documents d JOIN thread t ON d.comment_id = t.reply_to WHERE t.depth < 10 -- 防止无限递归 ) SELECT * FROM thread ORDER BY depth DESC;depth < 10是安全阀,防止数据异常导致无限递归。ORDER BY depth DESC让最早的评论排在最前面,符合阅读习惯。这个查询在几万条评论的库上跑,响应时间在毫秒级。
4.3 对话树的存储与可视化准备
如果你需要把对话树导出给前端做可视化,建议存成嵌套 JSON。用 Python 从扁平列表构建树的标准做法是:先建一个comment_id -> node的字典,再遍历一遍把子节点挂到父节点上。
def build_thread_tree(comments): """从扁平评论列表构建对话树""" nodes = {c['comment_id']: {**c, 'children': []} for c in comments} roots = [] for comment in comments: node = nodes[comment['comment_id']] parent_id = comment.get('reply_to') if parent_id and parent_id in nodes: nodes[parent_id]['children'].append(node) else: roots.append(node) return roots # 使用示例 # tree = build_thread_tree(comments_list) # print(json.dumps(tree, ensure_ascii=False, indent=2))这段代码的时间复杂度是 O(n),空间也是 O(n)。注意{**c, 'children': []}会复制一份字典,避免修改原始数据。如果评论量很大(比如超过 50 万),递归导出 JSON 可能导致栈溢出,建议用迭代方式或限制导出深度。
5. 避坑与排查:评论数据清洗的五个血泪教训
5.1 现象:清洗后评论数比原始数据少了一半
原因:去重指纹设计得太严格。比如用(post_id, author, timestamp, content)做联合去重,但同一用户在同一秒内发了多条不同评论,timestamp精度不够,导致误杀。
解决:把timestamp换成comment_id(如果有),或者用content的完整 MD5 而不是前 50 个字符。如果comment_id也没有,至少加上content的长度作为辅助指纹。
5.2 现象:FTS5 查询中文返回空结果
原因:入库时用了 jieba 分词,查询时没有对关键词做同样的分词处理。比如入库的是「中 枢 震 荡」,查询时输入「中枢震荡」,FTS5 匹配不到。
解决:封装一个search函数,在查询前对关键词做' '.join(jieba.cut(keyword)),保证查询词和索引词的分词方式一致。
5.3 现象:递归 CTE 查询报「too many levels of recursion」
原因:数据里存在循环引用,A 回复 B,B 又回复 A,递归没有终止条件。
解决:在递归 CTE 里加depth限制(如depth < 10),或者在应用层做环检测。更彻底的做法是在入库前检查reply_to链,发现环就断开。
5.4 现象:SQLite 数据库文件越来越大,删除数据后空间不释放
原因:SQLite 删除数据后不会自动收缩文件,需要手动VACUUM。
解决:定期执行VACUUM;命令。注意VACUUM会重建整个数据库,需要额外的磁盘空间,大库上执行可能耗时几分钟。建议在低峰期做,或者用PRAGMA auto_vacuum=INCREMENTAL开启增量清理。
5.5 现象:评论里的 HTML 实体没有解码,显示为&<
原因:清洗时只做了标签去除,没有做实体解码。
解决:在归一化阶段加一步html.unescape(text)。注意顺序:先unescape再去除标签,否则<script>会被误认为是标签。
6. 增量更新与长期维护:让语料库活起来
6.1 增量采集的触发条件与去重
「缠中说禅博客(带全部评论)」这类数据不是一次性的,博客可能还在更新(或者你在持续补采历史评论)。增量更新的核心是:每次只处理新增的post_id或comment_id。我的做法是在数据库里维护一张sync_log表,记录每个post_id的最后采集时间和评论数。下次采集时,先查sync_log,只请求有更新的页面。
import sqlite3 from datetime import datetime def get_posts_to_sync(db_path, stale_hours=24): """找出超过指定时间未同步的 post_id""" conn = sqlite3.connect(db_path) cursor = conn.cursor() cursor.execute(''' SELECT post_id, last_sync FROM sync_log WHERE last_sync < datetime('now', ?) ''', (f'-{stale_hours} hours',)) return [row[0] for row in cursor.fetchall()] def update_sync_log(db_path, post_id, comment_count): """更新同步日志""" conn = sqlite3.connect(db_path) conn.execute(''' INSERT OR REPLACE INTO sync_log (post_id, last_sync, comment_count) VALUES (?, ?, ?) ''', (post_id, datetime.now().isoformat(), comment_count)) conn.commit() conn.close()stale_hours=24表示超过 24 小时未同步的帖子会被重新采集。这个参数根据更新频率调整:如果博客每天更新,设 12 小时;如果周更,设 72 小时。
6.2 数据库迁移与备份策略
SQLite 的备份很简单:sqlite3 blog.db ".backup blog_backup.db"。但如果你在写入时直接复制文件,可能拿到不一致的快照。用.backup命令或者 Python 的conn.backup()方法可以保证一致性。
迁移方面,如果后续要换到 PostgreSQL 或 Elasticsearch,建议先把 JSONL 作为中间格式。JSONL 是纯文本,任何数据库都能导入,而且方便用git做版本管理(虽然大文件不适合 git,但可以按年份拆分)。
6.3 一个具体技巧:用触发器自动维护 FTS 索引
手动rebuild索引在批量导入时很快,但增量更新时每次都要重建就太慢了。SQLite 支持在documents表上建触发器,自动同步 FTS 索引:
CREATE TRIGGER documents_ai AFTER INSERT ON documents BEGIN INSERT INTO docs_fts(rowid, content, author) VALUES (new.rowid, new.content, new.author); END; CREATE TRIGGER documents_ad AFTER DELETE ON documents BEGIN INSERT INTO docs_fts(docs_fts, rowid, content, author) VALUES ('delete', old.rowid, old.content, old.author); END; CREATE TRIGGER documents_au AFTER UPDATE ON documents BEGIN INSERT INTO docs_fts(docs_fts, rowid, content, author) VALUES ('delete', old.rowid, old.content, old.author); INSERT INTO docs_fts(rowid, content, author) VALUES (new.rowid, new.content, new.author); END;这三个触发器分别处理插入、删除和更新。注意 FTS5 的删除操作需要先插入一条'delete'记录,再插入新记录,不能直接DELETE。触发器会增加写入开销,但在增量场景下比全量rebuild划算得多。我一般会在批量导入时先禁用触发器,导入完再启用,避免逐条触发拖慢速度。
这套管线跑下来,从原始 HTML 到可检索的语料库,核心代码不超过 300 行,但每一步的参数和边界都需要根据实际数据调整。我自己的习惯是:每处理一批新数据,先跑 100 条样本,人工检查清洗结果和检索效果,确认无误再全量跑。这个「先抽样再全量」的习惯帮我省过很多次后悔药。希望帮到你。
本文还有配套的精品资源,点击获取