diff --git a/.planning/debug/zhihu-article-images-missing.md b/.planning/debug/resolved/zhihu-article-images-missing.md similarity index 97% rename from .planning/debug/zhihu-article-images-missing.md rename to .planning/debug/resolved/zhihu-article-images-missing.md index ddb1492..5e49bfc 100644 --- a/.planning/debug/zhihu-article-images-missing.md +++ b/.planning/debug/resolved/zhihu-article-images-missing.md @@ -1,8 +1,8 @@ --- -status: awaiting_human_verify +status: resolved trigger: "知乎文章处理时图片链接丢失" created: 2026-03-15T00:00:00Z -updated: 2026-03-16T00:00:02Z +updated: 2026-03-16T00:00:03Z --- ## Current Focus @@ -77,5 +77,5 @@ root_cause: 知乎回答 (answer) 和专栏 (zhuanlan) 的原文没有被追加 fix: - `deep_analyzer.py:184` - 扩展条件为 `content_type in ("article", "answer", "zhuanlan")` - `analyze.py:244` - 扩展条件为 `content_type in ("article", "answer", "zhuanlan")` -verification: 待用户测试 +verification: 用户确认知乎图片已正常显示 - 2026-03-16 files_changed: [kb-deep-analyzer/deep_analyzer.py, kb_pipeline/processors/analyze.py] diff --git a/.planning/debug/zhihu-article-archive-format.md b/.planning/debug/zhihu-article-archive-format.md new file mode 100644 index 0000000..6a0d12c --- /dev/null +++ b/.planning/debug/zhihu-article-archive-format.md @@ -0,0 +1,138 @@ +--- +status: awaiting_human_verify +trigger: "知乎文章归档输出格式存在多个问题" +created: "2026-03-16T17:45:00+08:00" +updated: "2026-03-16T18:15:00+08:00" +--- + +## Current Focus + +hypothesis: 修复完成,等待用户验证 +test: 用户运行实际工作流验证 +expecting: 知乎文章归档格式正确 +next_action: 等待用户确认 + +## Symptoms + +expected: | + 1. 元数据 YAML 格式正确,Obsidian 可以正常解析 + 2. 文章归档模板应与视频不同:只要 300 字摘要 + 原文 + 3. 原文部分不应包含额外的元数据格式 + 4. 原文部分第一句应保持为正文,不应变成标题 + +actual: | + 1. bvid 这一项空了一行导致 Obsidian 读取元数据错误 + 2. 文章归档使用了视频模板,显示了视频时间 + 3. 原文部分多了一段元数据格式 + 4. 原文部分第一句正文变成了标题 + +errors: 无显式错误,Obsidian 解析 YAML 元数据失败 +reproduction: 运行一键 CLI 命令处理知乎文章链接,查看生成的归档 Markdown 文件 +started: 刚发现,可能是模板或格式化逻辑问题 + +## Eliminated + +## Evidence + +- timestamp: "2026-03-16T17:45:00+08:00" + checked: zhihu_2003232153480226496_content.md 文件 + found: | + 1. frontmatter 中 author 字段后有空行(第4行 author 后有一个空行) + 2. content.md 使用的是 zhihu.py 中的 _format_with_frontmatter,不是 deep_analyzer + 3. 该文件是原始抓取的内容,不是最终分析报告 + implication: 问题可能在两个地方 - zhihu.py 的 _format_with_frontmatter 和 deep_analyzer.py 的 generate_frontmatter + +- timestamp: "2026-03-16T17:47:00+08:00" + checked: deep_analyzer.py 的 generate_frontmatter 函数(第436-470行) + found: | + 1. 第455-459行:只有 content_type == "video" 时才添加 bvid + 2. 这意味着对 answer/zhuanlan 类型不应该添加 bvid + 3. 但代码中 bvid 只在 video 类型下插入 + implication: generate_frontmatter 函数本身的逻辑是正确的,问题可能出在调用方或数据源 + +- timestamp: "2026-03-16T17:48:00+08:00" + checked: zhihu.py 的 _format_with_frontmatter 函数(第237-265行) + found: | + 该函数用于 fetch_content,生成简单的 frontmatter: + - title, author, source_url, platform, content_type, fetched_at + - 没有 bvid 字段 + implication: zhihu.py 生成的 content.md 是正确的,问题可能出在最终的分析报告 + +- timestamp: "2026-03-16T17:49:00+08:00" + checked: 用户报告的问题描述与实际 content.md 对比 + found: | + 实际 zhihu_2003232153480226496_content.md 文件中: + - author 字段后有换行符(author 的值包含换行) + - frontmatter 格式看起来正确,但 author 值 "啦啦啦啦​​" 后有不可见字符/换行 + implication: 问题可能是 author 字段的值包含换行符,导致 YAML 解析失败 + +- timestamp: "2026-03-16T17:51:00+08:00" + checked: metadata.json 中的 author 字段 + found: author 值为 '啦啦啦啦\u200b\n\u200b',包含零宽度空格(U+200B)和换行符 + implication: zhihu_extractor 提取的 author 字段包含不可见字符,需要在生成 frontmatter 时清理 + +- timestamp: "2026-03-16T17:53:00+08:00" + checked: deep_analyzer.py 的 _build_prompt 函数(第193-196行) + found: | + if content_type == "article": + return self._build_article_prompt(...) + else: + return self._build_video_prompt(...) + 只检查 "article",不检查 "answer" 或 "zhuanlan" + implication: 知乎 answer 类型走视频模板,导致生成了"时间轴"等视频专用内容 + +- timestamp: "2026-03-16T17:54:00+08:00" + checked: analysis.md 最终分析报告 + found: | + 1. 第4-5行 author 后有空行(零宽度空格+换行) + 2. 第6行 platform: bilibili(应该是 zhihu) + 3. 第41-67行有"时间轴"部分(视频模板,文章不需要) + 4. 第69-212行"原文"部分追加了完整的 content.md(包含 frontmatter) + implication: 多个问题需要修复 + +- timestamp: "2026-03-16T17:55:00+08:00" + checked: analyze.py 的 _write_analysis_report 函数(第283-285行) + found: | + if content_type in ("article", "answer", "zhuanlan") and result.original_text: + full_analysis = full_analysis.rstrip() + "\n\n## 原文\n\n" + result.original_text + result.original_text 是从 content 的 text 字段提取的,应该只是正文 + implication: 问题在 deep_analyzer.py 的 analyze 函数中,original_text 设置为完整的 text,但 text 来自 content.md 的全文 + +## Resolution + +root_cause: | + 问题1: author 字段包含零宽度空格(U+200B)和换行符 + - 来源: zhihu_extractor 提取的 author 字段包含不可见字符 + - 影响: YAML frontmatter 解析失败,Obsidian 无法读取元数据 + + 问题2: _build_prompt 函数只检查 content_type == "article" + - 位置: deep_analyzer.py 第193-196行 + - 影响: 知乎 answer/zhuanlan 类型走视频模板,生成"时间轴"等不需要的内容 + + 问题3: 原文部分追加了完整的 content.md(包含 frontmatter) + - 位置: analyze.py 第103行读取整个 content.md 文件 + - 影响: "原文"部分包含 YAML frontmatter,第一行变成标题 + + 问题4: platform 字段默认为 bilibili + - 位置: deep_analyzer.py 第151行和第452行 + - 影响: 知乎内容的 platform 显示为 bilibili + +fix: | + 1. deep_analyzer.py: + - _build_prompt: 扩展条件为 content_type in ("article", "answer", "zhuanlan") + - analyze: 清理 author 字段的零宽度空格和换行符 + - generate_frontmatter: 清理 author 字段 + - 根据 URL 或 content_type 推断 platform + + 2. analyze.py: + - 读取 content.md 时,去除 frontmatter 部分,只保留正文 + +verification: | + - 单元测试: 15/15 通过 + - _clean_string 函数测试: 成功清理零宽度空格和换行符 + - generate_frontmatter 测试: 正确推断 platform 为 zhihu + - _strip_frontmatter 测试: 成功去除 YAML frontmatter + +files_changed: + - kb-deep-analyzer/deep_analyzer.py + - kb_pipeline/processors/analyze.py diff --git a/kb-deep-analyzer/deep_analyzer.py b/kb-deep-analyzer/deep_analyzer.py index 93d4298..2f947ce 100644 --- a/kb-deep-analyzer/deep_analyzer.py +++ b/kb-deep-analyzer/deep_analyzer.py @@ -141,14 +141,26 @@ class DeepAnalyzer: # 标准化元数据 metadata = self._normalize_metadata(metadata) - + title = metadata.get('title', '未知') duration = metadata.get('duration', 0) author = metadata.get('author', '') pubtime = metadata.get('pubtime', '') url = metadata.get('url', '') bvid = metadata.get('bvid', '') - platform = metadata.get('platform', 'bilibili') + + # 推断 platform:优先使用元数据中的值,否则根据 URL 或 content_type 推断 + platform = metadata.get('platform', '') + if not platform: + if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'): + platform = 'zhihu' + elif 'bilibili.com' in url or bvid: + platform = 'bilibili' + else: + platform = 'bilibili' # 默认值 + + # 清理 author 字段:移除零宽度空格和前后空白 + author = self._clean_author(author) # 获取内容 - 支持多种字段名 text = content.get('text', '') or content.get('subtitle', '') or content.get('subtitles', '') or content.get('description', '') @@ -186,11 +198,11 @@ class DeepAnalyzer: return result - def _build_prompt(self, title: str, duration: int, duration_str: str, author: str, - pubtime: str, url: str, bvid: str, platform: str, text: str, + def _build_prompt(self, title: str, duration: int, duration_str: str, author: str, + pubtime: str, url: str, bvid: str, platform: str, text: str, content_type: str = "video") -> str: """构建分析 prompt,根据内容类型选择不同模板""" - if content_type == "article": + if content_type in ("article", "answer", "zhuanlan"): return self._build_article_prompt(title, author, pubtime, url, platform, text) else: return self._build_video_prompt(title, duration, duration_str, author, pubtime, url, bvid, platform, text) @@ -422,6 +434,22 @@ URL:{url} return f"{h}:{m:02d}:{s:02d}" return f"{m}:{s:02d}" + def _clean_author(self, author: str) -> str: + """清理 author 字段:移除零宽度空格和前后空白""" + if not author: + return '' + # 移除零宽度空格 (U+200B) 和其他不可见字符 + invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad'] + cleaned = author + for char in invisible_chars: + cleaned = cleaned.replace(char, '') + # 移除前后空白和换行 + cleaned = cleaned.strip() + # 如果包含换行符,只取第一行 + if '\n' in cleaned: + cleaned = cleaned.split('\n')[0].strip() + return cleaned + # ============== 工具函数 ============== @@ -437,19 +465,34 @@ def generate_frontmatter(metadata: Dict, result: AnalysisResult, content_type: s """生成 Markdown frontmatter""" # 提取标签 tags = result.recommended_tags or [] - + # 格式化日期 pubtime = metadata.get('pubtime', '') if pubtime: date_str = str(pubtime)[:10] else: date_str = datetime.now().strftime('%Y-%m-%d') - - fm = ["---", + + # 清理 author 字段 + author = metadata.get('author', '未知') + author = _clean_string(author) + + # 推断 platform + url = metadata.get('url', '') + platform = metadata.get('platform', '') + if not platform: + if 'zhihu.com' in url or content_type in ('answer', 'zhuanlan', 'question'): + platform = 'zhihu' + elif 'bilibili.com' in url or metadata.get('bvid'): + platform = 'bilibili' + else: + platform = 'bilibili' + + fm = ["---", f"title: \"{metadata.get('title', '未命名')}\"", f"date: {date_str}", - f"author: {metadata.get('author', '未知')}", - f"platform: {metadata.get('platform', 'bilibili')}"] + f"author: {author}", + f"platform: {platform}"] # 视频类型添加 duration 和 bvid if content_type == "video": @@ -470,6 +513,23 @@ def generate_frontmatter(metadata: Dict, result: AnalysisResult, content_type: s return '\n'.join(fm) +def _clean_string(text: str) -> str: + """清理字符串:移除零宽度空格和前后空白""" + if not text: + return '' + # 移除零宽度空格 (U+200B) 和其他不可见字符 + invisible_chars = ['\u200b', '\u200c', '\u200d', '\ufeff', '\u00ad'] + cleaned = text + for char in invisible_chars: + cleaned = cleaned.replace(char, '') + # 移除前后空白和换行 + cleaned = cleaned.strip() + # 如果包含换行符,只取第一行 + if '\n' in cleaned: + cleaned = cleaned.split('\n')[0].strip() + return cleaned + + # ============== 主程序 ============== def main(): diff --git a/kb_pipeline/processors/analyze.py b/kb_pipeline/processors/analyze.py index 154d0cf..0cfd1d8 100644 --- a/kb_pipeline/processors/analyze.py +++ b/kb_pipeline/processors/analyze.py @@ -101,6 +101,8 @@ class AnalyzeProcessor(BaseProcessor): content_path = Path(input_data.content_file) if content_path.exists(): text = content_path.read_text(encoding="utf-8") + # 去除 YAML frontmatter(如果存在) + text = self._strip_frontmatter(text) content = {"text": text} # Step 3: 调用 DeepAnalyzer @@ -254,6 +256,37 @@ class AnalyzeProcessor(BaseProcessor): return metadata + def _strip_frontmatter(self, text: str) -> str: + """去除 Markdown 文件中的 YAML frontmatter + + Args: + text: 可能包含 frontmatter 的文本 + + Returns: + 去除 frontmatter 后的文本 + """ + if not text: + return text + + # 检查是否以 --- 开头 + if not text.startswith('---'): + return text + + # 找到第二个 --- + lines = text.split('\n') + end_index = -1 + for i in range(1, len(lines)): + if lines[i].strip() == '---': + end_index = i + break + + if end_index == -1: + return text + + # 返回 frontmatter 之后的内容(跳过空行) + remaining = '\n'.join(lines[end_index + 1:]) + return remaining.lstrip('\n') + def _write_analysis_report( self, output_path: Path,