# 59_CSDN博客自动写流程文章

当博客文章逐渐增多以后,手工维护目录会遇到几个典型问题:文章链接带有分享参数、标题与链接错配、自定义编号重复、旧文章遗漏,以及每次新增文章后都要重新整理目录。

本文介绍一个实际可运行的 Python 方案:从 CSDN 作者公开文章列表中获取真实标题和 article ID,以 SQLite 保存数据和任务状态,按照固定知识体系分类,最后生成可以直接粘贴到 CSDN Markdown 编辑器的可点击目录。

本文最后提供完整的单文件源码。该版本只使用 Python 标准库,不需要额外安装第三方依赖。

可以交给CODEX

c 复制代码
# Codex 项目任务:CSDN 博客目录自动整理系统 V3.0

## 一、角色与最终目标

你现在是一名高级 Python 工程师、Web 爬虫工程师、嵌入式技术知识分类工程师和 Markdown 文档工程师。

我要你为我的 CSDN 博客开发一套长期可维护的:

**博客文章自动采集 + 智能分类 + 编号核查 + Markdown 目录生成 + 断点续跑系统。**

我的博客主页:

https://blog.csdn.net/qq_73850061

我当前的博客目录文章:

https://blog.csdn.net/qq_73850061/article/details/159771200

项目目录中可能存在:

- `博客目录框架.md`:原始目录。
- 以前生成的新版目录或文章清单。
- SQLite 数据库和历史进度文件。
- 其他 Codex 已生成的工程文件。

请先检查工作目录中实际存在的文件,不要假设所有文件一定存在。

### 最终目标

1. 自动发现我的 CSDN 博客中所有公开可访问的文章。
2. 获取每篇文章的真实标题、URL、自定义文章编号及必要的摘要。
3. 动态识别文章最大编号,不允许固定为147或任何其他数字。
4. 检查从0到实际最大编号之间的编号连续性。
5. 找出缺失编号、重复编号、无编号文章及目录遗漏。
6. 将文章整理进固定的五大系统、22个二级目录。
7. 将每篇文章生成可以直接点击跳转到原文的 Markdown 超链接。
8. 最终生成 `博客目录框架_新版.md`。
9. 支持任务中断、额度不足、关闭终端后的断点续跑。
10. 支持将来新增文章后自动增量更新目录。

最终结果必须可以直接复制到 CSDN Markdown 编辑器中。

**我只需要博客目录和文章链接,不需要重新编写文章正文,不需要开发网站,也不需要自动发布文章。**

---

# 二、固定目录架构

## 最高级别约束

必须保留下面五大系统。

**只能存在五个一级分类,编号固定为0~4。**

二级分类固定为22个。

无论博客文章增加到多少篇,都不能擅自新增第六个一级分类。

## 0、[Tool] ------ 工具与工程效率

### 0.0、学习资源与知识管理

包括:

写作工具、学习方法、知识管理、文件整理、资源网站、技术资料收集、工程学习经验。

### 0.1、开发环境与工程工具

包括:

Git、VS Code、Keil、开发插件、Shell、环境搭建、开发辅助工具、命令行工具。

### 0.2、AI 工具与 Agent 工作流

包括:

Codex、MCP、Skills、AI Agent、AI辅助编程、智能代理、自动化工作流、AI工具链。

### 0.3、工程调试与问题排查

包括:

BUG调试、SWD调试、开发环境故障、工具链排查、工程异常分析、故障恢复。

## 1、[Skeletal_System] 骨骼 ------ 机械结构与运动学

### 1.0、机械机构与传动设计

包括:

齿轮机构、凸轮机构、四杆机构、连杆机构、机械传动、运动机构设计。

### 1.1、机械系统设计与工程分析

包括:

机械系统设计、机械项目分析、工程可行性、机械结构综合设计、机器人机械结构。

## 2、[Neural_System] 神经 ------ 电子电路与硬件系统

### 2.0、电路基础与电子元器件

包括:

电路分析、阻抗、基尔霍夫定律、电子元器件、MOSFET、IGBT、硬件原理图设计。

### 2.1、电源电路与功率管理

包括:

Buck、Boost、反激电源、PWR、电源管理、电源稳定性、供电故障。

### 2.2、传感器与数据采集

包括:

ADC、传感器、模拟采集、信号采样、传感器接口、采集电路。

### 2.3、硬件通信接口与总线

包括:

UART、I2C、I2S、SPI、QSPI、CAN、USB、SD卡、硬件接口与总线时序。

### 2.4、硬件驱动与显示器件

包括:

LED、RGB灯、数码管、动态扫描、PWM驱动、LCD显示接口、硬件显示驱动。

## 3、[Thought_System] 思维 ------ 软件编程与嵌入式系统

### 3.0、C 语言基础与进阶

包括:

指针、数组、结构体、联合体、宏、预处理、C语言数据类型、关键字、内存对齐、函数。

### 3.1、数据结构与算法

包括:

链表、栈、队列、树、图、哈希表、排序、查找、集合算法、时间复杂度。

### 3.2、编程思想与软件架构

包括:

状态机、程序设计模式、轮询、事件驱动、API设计、代码封装、模块设计、软件架构。

### 3.3、MCU 体系结构与底层原理

包括:

MCU架构、寄存器、内存映射、SysTick、NVIC、EXTI、DMA、CMSIS、内核底层机制。

### 3.4、编译构建与固件管理

包括:

GCC、CMake、Makefile、编译链接、ELF、BIN、HEX、objcopy、Bootloader、OTA、固件构建。

### 3.5、操作系统与并发机制

包括:

Linux、FreeRTOS、进程、线程、任务调度、信号量、互斥锁、自旋锁、原子操作、内核机制。

### 3.6、网络通信与协议栈

包括:

TCP、UDP、MQTT、WiFi、BLE、Raw Link、网络通信、无线协议、协议栈。

### 3.7、图形界面与音视频系统

包括:

LVGL、GUI Guider、图形界面、帧缓冲、屏幕撕裂、图像处理、图传、音频、视频编解码。

## 4、[Control_System] 调控 ------ 控制理论与工程数学

### 4.0、工程数学与数学建模

包括:

矩阵、泰勒级数、正态分布、数学建模、工程数学、数学函数。

### 4.1、信号处理与数学变换

包括:

傅里叶变换、拉普拉斯变换、Z变换、复数域、复变函数、频域分析。

### 4.2、自动控制理论与工程应用

包括:

PID控制、反馈控制、稳定性分析、经典控制、现代控制、电机控制、运动控制。

---

# 三、文章分类原则

请严格遵守:

1. 一级分类不能改变。
2. 二级分类不能擅自增加或删除。
3. 每篇文章默认只归入一个二级分类。
4. 按照文章主要技术内容进行分类。
5. 不按照文章发布时间分类。
6. 不按照学习阶段分类。
7. 不因为旧目录中的位置而机械地继承错误分类。
8. 优先根据标题和摘要判断。
9. 标题和摘要不足以判断时,才进一步查看必要的文章正文。
10. 无法确定的文章进入人工审核队列,不得编造分类依据。
11. 已经人工确认的分类不能被程序自动覆盖。
12. 跨学科文章按照主要解决的问题确定分类。
13. 旧目录中的第六类 `[Project]` 不再保留,其文章根据技术主题归入上述五类。

分类示例:

- `C语言指针` → 3.0
- `链表` → 3.1
- `状态机` → 3.2
- `NVIC中断` → 3.3
- `CMake构建` → 3.4
- `FreeRTOS` → 3.5
- `MQTT协议` → 3.6
- `LVGL移植` → 3.7
- `反激电源` → 2.1
- `SPI硬件接口` → 2.3
- `齿轮机构` → 1.0
- `傅里叶变换` → 4.1

分类重点是技术实质,不只是关键词。例如带有"SPI"的文章,如果主要内容是LCD软件帧缓冲管理,应按主要内容进行判断,而不是直接归入硬件接口。

---

# 四、文章编号动态识别系统(重点)

## 4.1 绝对不允许固定最大编号

以前博客目录中的最大文章编号曾经是147。

但是147只能视为历史信息。

以后文章编号可能是:

- 148
- 200
- 300
- 500
- 1000
- 甚至更大

因此:

**程序不能将147写成最大编号上限。**

必须根据真实发现的数据动态计算。

## 4.2 自定义编号与CSDN文章ID的区别

例如:

文章标题:

`147_LVGL 移植实战`

自定义文章编号是:

`147`

文章链接:

`https://blog.csdn.net/qq_73850061/article/details/168872675`

其中:

`168872675`

是CSDN的文章ID,而不是我的文章编号。

二者必须分别保存。

CSDN文章ID是唯一标识文章的主要依据。

标题中的数字才用于连续性检查。

## 4.3 自动提取自定义编号

需要兼容不同标题格式,例如:

`147_文章标题`

`#147_文章标题`

`147 - 文章标题`

`147、文章标题`

`147 文章标题`

注意:

- 只提取文章标题开头的明确编号。
- 不要把正文数字当成编号。
- 不要把文章标题中间的技术参数当成编号。
- 不要把文章ID当成编号。
- 编号不存在时标记为空。
- 不明确时标记需要审核。
- 支持编号为0的文章。

例如:

`39_从工程角度分析:0_钢铁侠战甲的制造可行性`

应提取编号39,而不是标题中间的0。

## 4.4 动态计算范围

每次运行检查:

1. 获取所有已发现的文章。
2. 合并本地已有记录与本次抓取结果。
3. 按真实文章ID去重。
4. 提取所有有效的自定义编号。
5. 计算最小编号。
6. 计算最大编号。
7. 检查异常编号,避免错误解析造成虚假最大值。
8. 从0到最大编号执行连续性检查。

逻辑示例:

```python
numbers = {
    article.original_number
    for article in articles
    if article.original_number is not None
}

if numbers:
    max_number = max(numbers)
    min_number = min(numbers)
    expected = set(range(0, max_number + 1))
    missing_numbers = sorted(expected - numbers)
else:
    max_number = None
    min_number = None
    missing_numbers = []

这里的 articles 必须是已按真实文章ID去重后的文章记录。

程序还需另外检查重复编号和编号异常。

如果所有文章尚未发现完整,则报告:

当前已发现最大编号

不得宣称这是博客实际最终最大编号。

4.5 动态检查遗漏与重复

必须检查:

  • 缺失编号
  • 重复编号
  • 无编号文章
  • 不规范编号
  • 重复文章ID
  • 一个编号对应多篇文章
  • 同一篇文章被多个目录重复收录
  • CSDN存在但Markdown没有收录的文章
  • Markdown记录了但CSDN暂时无法验证的文章

此前曾经发现以下疑似缺失编号:

59、60、61、62、73、89

以及编号78存在重复的情况。

这些只能作为历史核查线索,不能将它们固定为永久缺失编号。

每次必须重新计算完整的缺失编号列表。

4.6 严禁自动重新编号

如果发现编号缺失或重复:

  • 只输出检查结果。
  • 不得擅自修改原文章标题。
  • 不得为了连续而重新编号。
  • 不得将缺失编号自动分配给其他文章。
  • 不得直接编辑CSDN平台上的文章。

需要我人工处理时,在报告中提出建议即可。

4.7 动态编号报告

输出:

data/numbering_report.md

包含:

  • 数据采集是否完整
  • 当前最小编号
  • 当前最大编号
  • 总文章数量
  • 有编号文章数量
  • 无编号文章数量
  • 缺失编号数量及列表
  • 重复编号及对应文章
  • 编号解析异常
  • 尚未收录的文章
  • 下一篇建议编号

下一篇建议编号:

当前最大编号 + 1

但只作为建议,不直接修改文章。


五、爬虫数据采集

5.1 数据来源

优先级:

  1. 已有SQLite数据库。
  2. 当前项目中的旧版Markdown。
  3. 当前项目中的其他文章列表文件。
  4. CSDN博客主页公开文章列表。
  5. 必要时进入文章详情页。

项目中的旧目录文件可能已经包含真实URL,应优先利用,避免重复请求。

5.2 自动发现全部公开文章

博客地址:

https://blog.csdn.net/qq_73850061

要求:

  1. 自动发现文章列表。
  2. 正确处理多页数据。
  3. 不允许仅抓取第一页就认为任务完成。
  4. 收集所有能够正常访问的公开文章。
  5. 自动提取文章真实URL。
  6. 自动提取真实标题。
  7. 尽量获取摘要。
  8. 仅在有必要时读取文章正文。
  9. 使用CSDN article ID进行去重。
  10. 对已经保存的文章进行增量更新。

如果无法确认所有分页已经遍历完成,应在状态中明确记录数据发现未完成。

5.3 链接标准化

生成的文章链接必须是:

https://blog.csdn.net/qq_73850061/article/details/文章ID

删除无意义的追踪参数,例如:

  • fromshare
  • sharetype
  • sharerId
  • sharesource
  • sharefrom
  • spm

不允许自己猜测文章ID。

不允许根据标题中的编号推导文章ID。

不允许生成虚假的文章链接。

5.4 爬虫技术方案

使用Python,建议Python 3.11或兼容版本。

可以使用:

  • requests/httpx
  • BeautifulSoup
  • lxml
  • SQLite
  • Playwright(只有在普通HTML无法正确获取公开文章列表时才使用)

优先选择依赖少、稳定、容易维护的实现方式。

5.5 网络安全与容错

要求:

  • 合理限制请求频率。
  • 设置请求超时。
  • 为临时网络错误设置有限次数重试。
  • 使用退避策略。
  • 遵守网站公开访问规则。
  • 不绕过验证码、权限校验或反爬访问限制。
  • 遇到403、429等访问限制时保存状态并暂停相关抓取。
  • 网络失败不应造成已经保存的数据丢失。

对于暂时访问失败的文章,应记录为待重新验证。

不要仅因为单次请求失败,就判定文章不存在。


六、数据库设计与数据一致性

使用SQLite作为核心数据存储。

每篇文章至少记录:

text 复制代码
article_id
original_number
title
url
published_at
summary
category
subcategory
classification_reason
confidence
manual_override
source
crawl_status
link_status
last_checked_at

字段含义:

  • article_id:CSDN平台文章ID,必须唯一。
  • original_number:标题中的自定义编号,可以为空。
  • title:文章实际标题。
  • url:真实文章链接。
  • category:一级分类。
  • subcategory:二级分类。
  • manual_override:是否已经经过人工审核。
  • crawl_status:文章抓取状态。
  • link_status:链接验证状态。

要求:

  1. article ID具有唯一约束。
  2. 同一个article ID只能存在一条主文章记录。
  3. 自定义编号允许重复,但必须检测并报告。
  4. 同一文章不会因为标题变化而产生重复记录。
  5. 数据库保存文章的最新有效信息。
  6. 人工确认分类优先于自动分类。
  7. 分类失败不能导致原始文章记录丢失。
  8. 保留必要的数据来源与更新时间。

七、最终博客目录Markdown格式

7.1 最终输出文件

必须生成:

博客目录框架_新版.md

这是我要复制到CSDN博客中的文件。

最终文件必须是Markdown,而不是网页或HTML应用。

7.2 实际展示效果

我希望CSDN编辑器右侧显示:

五个一级分类,22个二级分类。

每个二级分类下是蓝色可点击的文章标题。

点击即可进入对应CSDN文章。

示例:

markdown 复制代码
# 博客目录

## 0、[Tool] ------ 工具与工程效率

### 0.0、学习资源与知识管理

- [🛠️28_关于交叉学科的学习方法](https://blog.csdn.net/qq_73850061/article/details/159851127)
- [🛠️27_写文章的工具](https://blog.csdn.net/qq_73850061/article/details/159683358)

### 0.1、开发环境与工程工具

(这里由程序填入真实文章)

## 1、[Skeletal_System] 骨骼 ------ 机械结构与运动学

### 1.0、机械机构与传动设计

- [🦴46_凸轮机构的设计方法](https://blog.csdn.net/qq_73850061/article/details/159893334)
- [🦴45_齿轮机构的设计方法](https://blog.csdn.net/qq_73850061/article/details/159893244)
- [🦴44_平面四杆机构](https://blog.csdn.net/qq_73850061/article/details/159893189)

以上仅为格式示例。

实际生成时:

  • 必须填入真实文章。
  • 不得输出示例占位内容。
  • 不得生成没有真实URL的文章链接。

7.3 Markdown排版规则

  1. 文件标题使用一级标题 #。
  2. 五大系统使用二级标题 ##。
  3. 22个二级目录使用三级标题 ###。
  4. 每篇文章使用Markdown列表超链接。
  5. 保留原始文章标题。
  6. 保留标题中的原始文章编号。
  7. 不得强制重新编号。
  8. 可以保留当前目录中使用的Emoji。
  9. 每个分类内按自定义文章编号降序排列。
  10. 没有编号的文章放在分类末尾。
  11. 不输出文章正文。
  12. 不输出文章摘要。
  13. 不输出爬虫日志。
  14. 不输出程序配置。
  15. 不输出AI分析过程。
  16. 不允许同一文章重复出现。
  17. 文章标题中包含Markdown特殊字符时,应正确转义,避免破坏链接。
  18. 保留旧目录中有价值的外部资源链接。
  19. 外部资源与本站博客文章适当分组。
  20. 所有文章链接必须使用真实、规范的URL。

7.4 链接验证

必须检查:

  • URL格式正确。
  • article ID与数据库一致。
  • 链接指向对应文章。
  • 没有错误地指向其他文章。
  • 没有 xxxxxx 等占位链接。
  • 没有重复的CSDN文章链接。
  • 能否确认文章真实存在。

无法验证时:

  • 记录异常。
  • 保留对应数据库数据。
  • 放入待审核报告。
  • 不编造验证结果。

八、断点续跑和额度不足保护(最高优先级)

这个项目不要求在一次Codex会话中完成。

我可能会出现:

  • Codex额度用完。
  • Token不足。
  • 上下文窗口耗尽。
  • 电脑关机。
  • 终端退出。
  • 网络中断。
  • Python程序意外崩溃。
  • 第二天重新开启Codex。

因此要求:

项目必须能够从本地保存的状态继续运行,而不是依靠AI记忆。

8.1 实时持久化

每成功处理一篇文章:

立即保存到SQLite数据库。

每处理完一页:

更新爬取任务进度。

每完成一个阶段:

更新状态报告。

关键文件采用安全写入方式,避免中断后损坏。

8.2 建立STATE.md

必须在项目根目录维护:

STATE.md

包含:

  1. 项目总目标。
  2. 当前阶段。
  3. 已完成的功能。
  4. 已采集文章数量。
  5. 已分类文章数量。
  6. 已验证链接数量。
  7. 未分类文章数量。
  8. 当前已发现最大编号。
  9. 当前缺失编号。
  10. 当前重复编号。
  11. 目前存在的问题。
  12. 下一次需要执行的任务。
  13. 下一次执行的命令。
  14. 最新一次执行结果。

不得把147写成永久最大编号。

最大编号必须随数据自动更新。

8.3 建立progress.json

保存机器可读的进度:

  • 已完成的采集任务。
  • 待处理任务。
  • 失败任务。
  • 分类进度。
  • 链接检查进度。
  • 最后成功处理位置。
  • 当前运行阶段。

8.4 防止重复运行

重复执行时:

  1. 不重复添加已有文章。
  2. 不覆盖人工分类。
  3. 不清空已有数据库。
  4. 不重新抓取所有成功文章。
  5. 不丢失已经收集的链接。
  6. 自动继续尚未完成的任务。
  7. 允许单独重试失败文章。
  8. 允许增量检查新文章。

8.5 额度不足时的处理

不要为了在一次对话中完成所有工作而一次性进行大量分析。

优先保存数据,再扩大任务。

如果无法一次完成:

  • 保存已经抓取的文章。
  • 保存已分类的文章。
  • 保存已核验的链接。
  • 更新编号报告。
  • 更新STATE.md。
  • 保存已生成的Markdown预览。
  • 记录下一次启动方式。

如果会话突然终止,已经成功落盘的数据必须仍然可用。

注意:无法预知Codex会话什么时候被平台强制中断,因此要靠每篇文章保存事务和阶段检查点实现恢复,而不是只在最后保存一次。

8.6 预览与正式文件

采集未完成时,也可以生成:

博客目录框架_预览.md

作为当前阶段成果。

只有达到项目规定的数据完整性和链接验证要求时,才生成或更新正式文件:

博客目录框架_新版.md

如果存在未解决问题,应生成审核报告并明确说明,不得伪装成所有工作都已完成。


九、项目结构

建议创建:

text 复制代码
BlogCatalog/
│
├── 博客目录框架.md
├── 博客目录框架_预览.md
├── 博客目录框架_新版.md
│
├── README.md
├── STATE.md
├── requirements.txt
│
├── config/
│   └── categories.json
│
├── data/
│   ├── articles.db
│   ├── progress.json
│   ├── numbering_report.md
│   ├── review_needed.csv
│   └── validation_report.md
│
├── src/
│   ├── crawler.py
│   ├── parser.py
│   ├── classifier.py
│   ├── numbering.py
│   ├── generator.py
│   └── main.py
│
└── tests/

如果已有部分项目文件,优先检查并复用,不要无故重复创建。

旧目录不得覆盖。

生成新版目录时,应当安全保存,并保留必要的历史备份。


十、命令行接口

要求支持Windows。

实现下面的功能:

powershell 复制代码
python -m src.main --import
python -m src.main --crawl
python -m src.main --classify
python -m src.main --check-numbers
python -m src.main --validate
python -m src.main --generate
python -m src.main --resume
python -m src.main --update
python -m src.main --status

分别表示:

--import

读取旧Markdown和已有文章清单,导入数据库。

--crawl

从CSDN博客发现文章并抓取必要信息。

--classify

分类所有尚未分类的文章。

--check-numbers

动态计算最大编号、缺失编号、重复编号和无编号文章。

--validate

检查文章数据库、真实链接、分类完整性和Markdown生成结果。

--generate

生成目录文件。

--resume

从上一次保存的位置继续。

--update

重新检查博客是否有新增文章。

发现新文章后:

  • 保存文章信息。
  • 更新最大编号。
  • 重新计算缺失编号。
  • 执行分类。
  • 重新生成目录。

--status

显示:

  • 已发现文章总数
  • 已分类数量
  • 待分类数量
  • 链接验证情况
  • 当前最大编号
  • 缺失编号数量
  • 重复编号数量
  • 本次任务进度
  • 是否有等待人工审核的内容

注意:

这些命令都必须真正实现,不能只写在README中。


十一、数据检查与测试

请设计自动化测试,至少覆盖以下场景。

11.1 动态最大编号

测试最大编号为:

  • 147
  • 200
  • 500
  • 1000

程序应正确计算最大编号和缺失编号。

不得因为编号超过147而失效。

11.2 编号重复

模拟两篇文章使用相同编号,但拥有不同CSDN article ID。

应该保留两篇文章,并报告编号冲突。

11.3 没有编号

模拟一篇文章没有自定义编号。

应该保留文章,并归入无编号文章记录。

11.4 链接重复

模拟同一article ID出现两次。

最终数据库只能保留一条主记录。

11.5 网络中断

模拟抓取一半后终止。

再次启动时应从本地状态继续,不得清空原有成果。

11.6 分类正确性

必须保证:

  • 只有五个一级分类。
  • 只有22个二级分类。
  • 已确认的文章每篇只属于一个二级分类。
  • 人工分类不会被自动覆盖。

11.7 Markdown格式

生成后验证:

  • Markdown链接格式正确。
  • 标题中的特殊字符不会破坏链接。
  • 不包含无效占位符。
  • 没有重复文章ID。
  • 输出目录编号顺序正确。
  • 可在普通Markdown预览器中正确解析。

十二、项目执行顺序

请不要只写计划,要实际实现。

第一阶段:项目初始化

  1. 检查当前目录。
  2. 寻找旧Markdown和现有数据库。
  3. 读取已有目录。
  4. 创建项目结构。
  5. 创建SQLite数据表。
  6. 导入现有文章信息。
  7. 建立STATE.md和进度文件。
  8. 保存初始成果。

第二阶段:爬虫开发

  1. 实现CSDN文章列表采集。
  2. 先用少量文章验证解析逻辑。
  3. 验证分页。
  4. 验证去重。
  5. 验证异常处理。
  6. 验证断点续跑。
  7. 测试成功后再抓取其他文章。

第三阶段:编号核查

  1. 动态提取自定义编号。
  2. 计算当前最大编号。
  3. 检查缺失编号。
  4. 检查重复编号。
  5. 检查无编号文章。
  6. 对照CSDN文章列表查漏。
  7. 输出numbering_report.md。

第四阶段:智能分类

  1. 加载固定的22个二级分类。
  2. 根据标题和摘要分类。
  3. 必要时进一步读取文章内容。
  4. 保存分类结果。
  5. 生成待审核列表。
  6. 不覆盖人工确认结果。

第五阶段:Markdown生成

  1. 按五大系统分组。
  2. 按22个二级目录分组。
  3. 每个分类内按编号降序排序。
  4. 生成文章Markdown超链接。
  5. 保留外部资源。
  6. 输出预览文件。
  7. 满足完成条件后输出正式新版Markdown。

第六阶段:完整性验证

检查:

  • 文章发现是否完成。
  • 是否有遗漏。
  • 是否有重复文章。
  • 是否有重复编号。
  • 是否有错误链接。
  • 是否只有五大一级分类。
  • 是否包含全部22个二级目录。
  • Markdown是否正确生成。
  • 是否有未处理的人工审核项目。

第七阶段:增量更新能力

实现后续直接运行:

powershell 复制代码
python -m src.main --update

即可发现新文章,更新数据并生成新目录。

未来最大编号无论增加到多少,都应该正常运行。


十三、项目状态与Codex工作规范

整个开发过程遵循以下原则:

1. 不依赖一次会话完成

任务较多,可以分阶段完成。

不得因为本次会话无法完成所有工作就丢弃当前成果。

2. 每个阶段都应该可恢复

任何已经完成的工作,必须落盘保存。

3. 不要重新分析所有文章

已经分类且无变化的文章可以直接复用结果。

4. 不要过度消耗Token

优先编写可复用的Python程序。

尽量使用规则、数据库和局部分析。

只有分类不明确时才使用复杂的AI推理。

5. 不要擅自修改我的CSDN博客

只能读取公开信息。

不要登录并修改原文章。

不要删除文章。

不要修改文章标题。

不要自动发布新版目录。

最终由我手动复制Markdown并发布。

6. 必须提供可查看的成果

每个阶段结束时,输出:

  • 已完成内容
  • 已保存文件
  • 当前采集文章数量
  • 当前发现的最大编号
  • 存在的问题
  • 下一次续跑命令

7. 工作重点

优先级从高到低:

  1. 已有数据不丢失。
  2. 文章URL正确。
  3. 动态编号检查正确。
  4. 五大系统和22个二级目录正确。
  5. Markdown超链接正确。
  6. 支持断点续跑。
  7. 增量更新方便。
  8. 优化运行效率。

十四、新Codex会话恢复指令

为了应对Codex额度不足或上下文耗尽,请在项目README中保存以下恢复提示词:

请继续当前的CSDN博客目录整理项目。

首先阅读README.md、STATE.md、data/progress.json和现有源代码。

恢复数据库中的文章信息及历史分类结果。

不要从头重新开发。

不要重复采集已经成功完成的文章。

不要覆盖人工确认的分类。

动态识别当前文章最大编号,不能写死147。

检查当前未完成的任务,从上一次保存的位置继续。

每完成一批任务都立即保存。

持续更新Markdown目录和编号检查报告。

本次任务结束前更新STATE.md。

请继续执行实际工作,而不是只重新制定计划。


十五、最终验收条件

项目完成时,必须满足以下条件:

  • 已检查当前博客全部可发现的公开文章分页。
  • 已将文章信息保存在本地数据库。
  • 文章真实URL与文章ID一致。
  • 最大文章编号为动态计算结果。
  • 不存在写死147的限制。
  • 已生成缺失编号报告。
  • 已生成重复编号报告。
  • 已检查无编号文章。
  • 所有可确认的文章已分类或明确列入审核。
  • 目录只有五个一级系统。
  • 目录包含22个二级分类。
  • 每篇已收录文章使用真实Markdown超链接。
  • 相同article ID不会重复收录。
  • 旧目录没有被破坏。
  • 生成的Markdown可直接粘贴到CSDN。
  • 已实现断点续跑。
  • 已实现增量更新。
  • README包含运行说明。
  • STATE.md包含实际完成进度。
  • 自动化测试通过。
  • 无法确认的内容已明确列入审核报告。

对于未通过的检查项,必须说明原因,不得虚假标记为已完成。


十六、现在开始执行

请立即执行,不要只向我讲解方案。

首先:

  1. 检查当前工作目录中已有的文件。
  2. 读取博客目录及历史数据。
  3. 创建可持久化的文章数据库。
  4. 建立断点续跑机制。
  5. 开发基础爬虫并用少量文章测试。
  6. 动态识别现有文章编号,不使用固定最大值。
  7. 逐步采集、分类和检查文章。
  8. 持续生成Markdown目录预览。
  9. 每阶段保存进度。
  10. 在符合验收条件后生成 博客目录框架_新版.md。

特别强调:

  • 保留五大系统。
  • 保留22个二级分类。
  • 最大文章编号完全动态计算。
  • 不遗漏能够确认的公开文章。
  • 真实链接一键跳转。
  • 不自动修改博客。
  • 不自动改编号。
  • 不依赖一次会话完成。
  • 随时保存工作。
  • 额度不够时,已经完成的成果必须能够保留并继续使用。

请从项目文件检查开始,直接开展实际开发与执行工作。

复制代码
# 一、项目要解决的问题

## 1. 手工目录为什么容易出错

常见问题主要包括:

- 分享链接包含 `fromshare`、`sharerId` 等追踪参数。
- 自定义文章编号与 CSDN article ID 被混为一谈。
- 同一个 article ID 被错误地分配给两个标题。
- 文章标题已经修改,但旧目录仍保存旧标题。
- 某些目录项只有 `xxxxxx` 等占位地址。
- 任务中断后,只能从头重新抓取。
- 新文章发布后,无法自动增量更新目录。

## 2. 最终输出格式

每篇文章使用标准 Markdown 链接:

```markdown
- [⚡63_电子元器件中三大开关](https://blog.csdn.net/qq_73850061/article/details/160320098)

这里的 63 是作者自定义编号,160320098 才是 CSDN 平台的 article ID。

article ID 是文章身份的唯一依据。自定义编号可以缺失或重复,不能拿它推测 URL。

二、系统总体设计

1. 数据处理流程

整个工作流可以概括为:

text 复制代码
读取旧目录
    ↓
提取已有标题与真实链接
    ↓
请求 CSDN 公开文章列表 API
    ↓
按 article ID 去重并写入 SQLite
    ↓
根据标题、摘要和标签分类
    ↓
生成 Markdown 目录
    ↓
检查分类数量、重复链接和遗漏文章

2. 为什么选择 SQLite

SQLite 非常适合个人博客目录项目:

  • 不需要安装数据库服务。
  • 一个文件就能保存全部文章数据。
  • article ID 可以直接设置为主键。
  • 支持 ON CONFLICT,方便实现幂等更新。
  • 每处理一篇文章就能立即提交,不怕中途退出。
  • 后续可以方便地查询未分类、重复或失效记录。

3. 为什么还需要 progress.json

数据库保存的是文章数据,progress.json 保存的是任务执行位置,例如:

json 复制代码
{
  "status": "crawling",
  "last_completed_page": 1,
  "page_size": 100,
  "expected_total": 144,
  "saved_articles": 100,
  "updated_at": "2026-10-23T17:24:00+08:00"
}

程序再次启动时,可以读取最后完成的页码,从下一页继续。

三、文章发现与永久链接

1. 使用公开列表而不是猜测地址

CSDN 作者主页会调用公开文章列表接口。接口返回的数据中包含:

  • articleId
  • title
  • description
  • url
  • postTime
  • tags

只要从公开列表读取 article ID,就可以构造简洁永久地址:

python 复制代码
def canonical_url(username: str, article_id: int) -> str:
    return f"https://blog.csdn.net/{username}/article/details/{article_id}"

这样可以自动去掉所有分享和追踪参数。

2. 分页终止条件

爬虫不能只抓第一页,也不能无限请求。正确的终止条件是:

  1. 当前页没有返回文章。
  2. 已发现数量达到接口给出的 total。
  3. 遇到 403 或 429,立即停止,不绕过访问控制。

3. 有限重试与退避

网络超时可以重试,但必须限制次数。本文源码最多重试 3 次,并使用指数退避:

text 复制代码
第一次失败:等待 1 秒
第二次失败:等待 2 秒
第三次失败:报告错误并停止

四、数据持久化与断点续跑

1. article ID 作为主键

数据库表的核心约束如下:

sql 复制代码
article_id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE

即使文章标题后来发生变化,只要 article ID 不变,就不会重复插入。

2. 幂等更新

所谓幂等,是指程序重复运行多次,结果仍然只有一篇文章,而不是不断产生重复数据。

SQLite 可以使用:

sql 复制代码
INSERT INTO articles(...)
VALUES (...)
ON CONFLICT(article_id) DO UPDATE SET
    title=excluded.title,
    url=excluded.url,
    summary=excluded.summary;

3. 原子写入进度文件

直接覆盖 JSON 文件时,如果程序突然退出,文件可能只写了一半。更安全的方法是:

  1. 先写入临时文件。
  2. 写入完成后调用 os.replace()。
  3. 用完整临时文件原子替换旧文件。

这样能够避免进度文件损坏。

五、文章分类方法

1. 分类依据

分类时依次使用三类信息:

  1. 标题,权重最高。
  2. 摘要,用于补充标题信息。
  3. 标签,只作为辅助证据。

例如:

关键词 推荐分类
指针、结构体、volatile C 语言基础与进阶
链表、栈、队列、哈希 数据结构与算法
SysTick、NVIC、DMA、CMSIS MCU 体系结构与底层原理
GCC、CMake、ELF、OTA 编译构建与固件管理
FreeRTOS、线程、互斥锁 操作系统与并发机制
TCP、UDP、MQTT、BLE 网络通信与协议栈
LVGL、GUI、音频、图传 图形界面与音视频系统

2. 为什么不能只看标题

有些标题非常模糊,例如"某些新技术"。遇到这类文章时,应当读取摘要;摘要仍然不足时,才读取正文的必要部分。

不能为了完成分类而猜测。确实无法判断时,应当将其标记为 needs_review。

3. 低置信度记录

源码会为分类保存:

  • subcategory
  • classification_reason
  • confidence

没有足够关键词证据的文章不会进入最终目录,而是保留在数据库中等待人工复核。

六、Markdown生成与校验

1. 标题中的特殊字符

文章标题可能包含 [、] 或反斜杠。直接放进 Markdown 链接文本可能破坏语法,因此需要转义:

python 复制代码
def escape_label(text: str) -> str:
    return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")

2. 排序规则

同一分类下按照自定义编号降序排列。没有编号的文章放在分类末尾。

重复的自定义编号允许保留,因为它们可能是两篇不同文章;真正需要保证唯一的是 article ID。

3. 最终校验项目

生成后至少检查:

  • 是否只有规定的一级分类。
  • 是否包含全部二级分类。
  • 每个 article ID 是否只出现一次。
  • 数据库中的公开文章是否全部进入目录。
  • URL 是否为永久地址。
  • 是否仍有 xxxxxx 或分享追踪参数。

七、完整可运行源码

1. 单文件版本说明

下面的代码将抓取、SQLite 持久化、规则分类、Markdown 生成和校验整合到一个文件中,适合直接学习和运行。

建议保存为:

text 复制代码
csdn_blog_catalog.py

2. Python源码

python 复制代码
from __future__ import annotations

import argparse
import json
import os
import re
import sqlite3
import time
from datetime import datetime
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen


API_URL = "https://blog.csdn.net/community/home-api/v1/get-business-list"

CATEGORY_TREE = {
    "0": (
        "[Tool] ------ 工具与工程效率",
        {
            "0.0": "学习资源与知识管理",
            "0.1": "开发环境与工程工具",
            "0.2": "AI 工具与 Agent 工作流",
            "0.3": "工程调试与问题排查",
        },
    ),
    "1": (
        "[Skeletal_System] 骨骼 ------ 机械结构与运动学",
        {
            "1.0": "机械机构与传动设计",
            "1.1": "机械系统设计与工程分析",
        },
    ),
    "2": (
        "[Neural_System] 神经 ------ 电子电路与硬件系统",
        {
            "2.0": "电路基础与电子元器件",
            "2.1": "电源电路与功率管理",
            "2.2": "传感器与数据采集",
            "2.3": "硬件通信接口与总线",
            "2.4": "硬件驱动与显示器件",
        },
    ),
    "3": (
        "[Thought_System] 思维 ------ 软件编程与嵌入式系统",
        {
            "3.0": "C 语言基础与进阶",
            "3.1": "数据结构与算法",
            "3.2": "编程思想与软件架构",
            "3.3": "MCU 体系结构与底层原理",
            "3.4": "编译构建与固件管理",
            "3.5": "操作系统与并发机制",
            "3.6": "网络通信与协议栈",
            "3.7": "图形界面与音视频系统",
        },
    ),
    "4": (
        "[Control_System] 调控 ------ 控制理论与工程数学",
        {
            "4.0": "工程数学与数学建模",
            "4.1": "信号处理与数学变换",
            "4.2": "自动控制理论与工程应用",
        },
    ),
}

EMOJI = {"0": "🛠️", "1": "🦴", "2": "⚡", "3": "👉", "4": "🎯"}

RULES = {
    "0.0": ("学习方法", "知识管理", "博客目录", "写文章", "资源"),
    "0.1": ("vs code", "vscode", "windows", "git", "插件", "工具", "环境"),
    "0.2": ("codex", "chatgpt", "ai代理", "agent", "mcp", "skills"),
    "0.3": ("调试", "debug", "bug", "排查", "死机", "故障", "勘误"),
    "1.0": ("齿轮", "凸轮", "四杆", "机械机构", "传动", "曲柄"),
    "1.1": ("机械系统", "机械设计", "制造可行性", "战甲"),
    "2.0": ("电路分析", "基尔霍夫", "阻抗", "电子元器件", "耐压"),
    "2.1": ("电源", "buck", "boost", "反激", "pwr", "功率管理"),
    "2.2": ("adc", "sensor", "传感器", "数据采集"),
    "2.3": ("spi", "qspi", "i2c", "i2s", "can", "串口", "usb", "sd卡"),
    "2.4": ("数码管", "级联芯片", "rgb灯", "pwm", "显示器件"),
    "3.0": ("c语言", "指针", "结构体", "联合体", "scanf", "volatile", "预处理"),
    "3.1": ("链表", "数据结构", "排序", "查找算法", "栈", "队列", "哈希", "复杂度"),
    "3.2": ("状态机", "api", "设计模式", "软件架构", "库函数"),
    "3.3": ("systick", "cmsis", "nvic", "exti", "dma", "mcu", "寄存器"),
    "3.4": ("gcc", "cmake", "makefile", "编译", "构建", "elf", "objcopy", "固件", "ota"),
    "3.5": ("freertos", "linux", "进程", "线程", "互斥锁", "自旋锁", "原子锁"),
    "3.6": ("tcp", "udp", "mqtt", "ble", "蓝牙协议", "wifi", "协议栈"),
    "3.7": ("lvgl", "gui", "ui", "屏幕", "图传", "mp4", "音频", "摄像头"),
    "4.0": ("矩阵", "复变函数", "正态分布", "relu", "泰勒", "数学建模"),
    "4.1": ("傅里叶", "拉普拉斯", "拉氏变换", "z变换", "信号处理", "复数域"),
    "4.2": ("pid", "自动控制", "控制算法", "控制理论"),
}


def now_local() -> str:
    return datetime.now().astimezone().isoformat(timespec="seconds")


def canonical_url(username: str, article_id: int) -> str:
    return f"https://blog.csdn.net/{username}/article/details/{article_id}"


def atomic_write_json(path: Path, payload: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_suffix(path.suffix + ".tmp")
    temporary.write_text(
        json.dumps(payload, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    os.replace(temporary, path)


def connect_database(path: Path) -> sqlite3.Connection:
    path.parent.mkdir(parents=True, exist_ok=True)
    connection = sqlite3.connect(path)
    connection.row_factory = sqlite3.Row
    connection.executescript(
        """
        CREATE TABLE IF NOT EXISTS articles (
            article_id INTEGER PRIMARY KEY,
            original_number INTEGER,
            title TEXT NOT NULL,
            url TEXT NOT NULL UNIQUE,
            published_at TEXT,
            summary TEXT,
            tags TEXT,
            category TEXT,
            subcategory TEXT,
            classification_reason TEXT,
            confidence REAL,
            crawl_status TEXT NOT NULL,
            last_checked_at TEXT NOT NULL,
            is_public INTEGER NOT NULL DEFAULT 1
        );
        """
    )
    connection.commit()
    return connection


def fetch_page(
    username: str,
    page: int,
    page_size: int,
    timeout: float = 20.0,
) -> tuple[list[dict], int]:
    query = urlencode(
        {
            "page": page,
            "size": page_size,
            "businessType": "blog",
            "orderby": "",
            "noMore": "false",
            "username": username,
        }
    )
    headers = {
        "User-Agent": (
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
            "AppleWebKit/537.36 (KHTML, like Gecko) "
            "Chrome/141.0.0.0 Safari/537.36"
        ),
        "Referer": f"https://blog.csdn.net/{username}",
        "Accept": "application/json, text/plain, */*",
    }
    request = Request(f"{API_URL}?{query}", headers=headers)
    last_error = None

    for attempt in range(3):
        try:
            with urlopen(request, timeout=timeout) as response:
                payload = json.loads(response.read().decode("utf-8"))
            if payload.get("code") != 200:
                raise RuntimeError(payload.get("message") or "CSDN API 返回异常")
            data = payload.get("data") or {}
            return list(data.get("list") or []), int(data.get("total") or 0)
        except HTTPError as error:
            if error.code in {403, 429}:
                raise RuntimeError(
                    f"访问受限,HTTP {error.code},程序已停止"
                ) from error
            last_error = error
        except (URLError, TimeoutError, json.JSONDecodeError) as error:
            last_error = error
        if attempt < 2:
            time.sleep(2**attempt)

    raise RuntimeError(f"请求失败:{last_error}")


def parse_original_number(title: str) -> int | None:
    cleaned = re.sub(r"^#+\s*", "", title.strip())
    match = re.match(r"^(\d+)\s*[_、]", cleaned)
    return int(match.group(1)) if match else None


def clean_display_title(title: str) -> str:
    title = title.strip().replace("**", "")
    title = re.sub(r"^#+\s*", "", title)
    return re.sub(r"\s+", " ", title).strip()


def escape_label(title: str) -> str:
    return (
        clean_display_title(title)
        .replace("\\", "\\\\")
        .replace("[", "\\[")
        .replace("]", "\\]")
    )


def classify_article(article: dict) -> tuple[str | None, str, float]:
    title = str(article.get("title") or "").lower()
    summary = str(article.get("summary") or "").lower()
    tags = str(article.get("tags") or "").lower()
    scores = {}
    evidence = {}

    for subcategory, keywords in RULES.items():
        score = 0.0
        matched = []
        for keyword in keywords:
            title_hits = title.count(keyword)
            summary_hits = min(summary.count(keyword), 2)
            tag_hits = min(tags.count(keyword), 1)
            keyword_score = title_hits * 5 + summary_hits * 1.25 + tag_hits * 0.5
            if keyword_score:
                score += keyword_score
                matched.append(keyword)
        scores[subcategory] = score
        evidence[subcategory] = matched

    best = max(scores, key=scores.get)
    best_score = scores[best]
    ranked = sorted(scores.values(), reverse=True)
    margin = best_score - ranked[1]

    if best_score < 1.25:
        return None, "标题、摘要和标签均无足够分类证据", 0.0

    confidence = min(0.99, 0.55 + best_score / 25 + max(margin, 0) / 40)
    reason = "匹配关键词:" + "、".join(evidence[best][:6])
    return best, reason, round(confidence, 2)


def save_article(
    connection: sqlite3.Connection,
    username: str,
    item: dict,
    crawl_run_at: str,
) -> None:
    article_id = int(item["articleId"])
    title = str(item.get("title") or "").strip()
    tags = item.get("tags") or []
    if not isinstance(tags, list):
        tags = [str(tags)]
    article = {
        "title": title,
        "summary": item.get("description") or "",
        "tags": " ".join(str(tag) for tag in tags),
    }
    subcategory, reason, confidence = classify_article(article)
    category = subcategory.split(".", 1)[0] if subcategory else None

    connection.execute(
        """
        INSERT INTO articles(
            article_id, original_number, title, url, published_at,
            summary, tags, category, subcategory,
            classification_reason, confidence, crawl_status,
            last_checked_at, is_public
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'verified_listed', ?, 1)
        ON CONFLICT(article_id) DO UPDATE SET
            original_number=excluded.original_number,
            title=excluded.title,
            url=excluded.url,
            published_at=excluded.published_at,
            summary=excluded.summary,
            tags=excluded.tags,
            category=excluded.category,
            subcategory=excluded.subcategory,
            classification_reason=excluded.classification_reason,
            confidence=excluded.confidence,
            crawl_status='verified_listed',
            last_checked_at=excluded.last_checked_at,
            is_public=1
        """,
        (
            article_id,
            parse_original_number(title),
            title,
            canonical_url(username, article_id),
            item.get("postTime"),
            item.get("description") or "",
            json.dumps(tags, ensure_ascii=False),
            category,
            subcategory,
            reason,
            confidence,
            crawl_run_at,
        ),
    )
    connection.commit()


def crawl(
    connection: sqlite3.Connection,
    username: str,
    data_dir: Path,
    page_size: int = 100,
    delay: float = 1.0,
) -> None:
    progress_path = data_dir / "progress.json"
    progress = {}
    if progress_path.exists():
        progress = json.loads(progress_path.read_text(encoding="utf-8"))

    can_resume = (
        progress.get("status") in {"crawling", "partial", "failed"}
        and progress.get("page_size") == page_size
        and progress.get("crawl_run_at")
    )
    if can_resume:
        crawl_run_at = progress["crawl_run_at"]
        page = int(progress.get("last_completed_page") or 0) + 1
        expected_total = int(progress.get("expected_total") or 0)
        discovered = connection.execute(
            "SELECT COUNT(*) FROM articles WHERE last_checked_at=?",
            (crawl_run_at,),
        ).fetchone()[0]
    else:
        crawl_run_at = now_local()
        page = 1
        discovered = 0
        expected_total = 0

    try:
        while True:
            articles, expected_total = fetch_page(username, page, page_size)
            if not articles:
                break

            for item in articles:
                save_article(connection, username, item, crawl_run_at)
                discovered += 1

            atomic_write_json(
                progress_path,
                {
                    "status": "crawling",
                    "last_completed_page": page,
                    "page_size": page_size,
                    "expected_total": expected_total,
                    "saved_articles": discovered,
                    "crawl_run_at": crawl_run_at,
                    "updated_at": now_local(),
                },
            )

            if discovered >= expected_total:
                break
            page += 1
            time.sleep(delay)
    except Exception as error:
        atomic_write_json(
            progress_path,
            {
                "status": "failed",
                "last_completed_page": page - 1,
                "page_size": page_size,
                "expected_total": expected_total,
                "saved_articles": discovered,
                "crawl_run_at": crawl_run_at,
                "error": str(error),
                "updated_at": now_local(),
            },
        )
        raise

    current_count = connection.execute(
        "SELECT COUNT(*) FROM articles WHERE last_checked_at=?",
        (crawl_run_at,),
    ).fetchone()[0]

    complete = expected_total > 0 and current_count >= expected_total
    if complete:
        connection.execute(
            """
            UPDATE articles
            SET crawl_status='not_listed', is_public=0
            WHERE crawl_status='verified_listed' AND last_checked_at != ?
            """,
            (crawl_run_at,),
        )
        connection.commit()

    atomic_write_json(
        progress_path,
        {
            "status": "complete" if complete else "partial",
            "last_completed_page": page,
            "page_size": page_size,
            "expected_total": expected_total,
            "saved_articles": current_count,
            "crawl_run_at": crawl_run_at,
            "updated_at": now_local(),
        },
    )


def generate_markdown(
    connection: sqlite3.Connection,
    output_path: Path,
) -> int:
    rows = connection.execute(
        """
        SELECT * FROM articles
        WHERE is_public=1
          AND crawl_status='verified_listed'
          AND subcategory IS NOT NULL
        ORDER BY subcategory,
                 original_number IS NULL,
                 original_number DESC,
                 published_at DESC,
                 article_id DESC
        """
    ).fetchall()

    grouped = {}
    for row in rows:
        grouped.setdefault(row["subcategory"], []).append(row)

    lines = ["# 博客目录", ""]
    for top_code, (top_name, subcategories) in CATEGORY_TREE.items():
        lines.extend([f"## {top_code}、{top_name}", ""])
        for sub_code, sub_name in subcategories.items():
            lines.extend([f"### {sub_code}、{sub_name}", ""])
            for row in grouped.get(sub_code, []):
                label = escape_label(row["title"])
                lines.append(
                    f"- [{EMOJI[top_code]}{label}]({row['url']})"
                )
            lines.append("")

    output_path.write_text(
        "\n".join(lines).rstrip() + "\n",
        encoding="utf-8",
    )
    return len(rows)


def validate_markdown(
    connection: sqlite3.Connection,
    output_path: Path,
) -> dict:
    text = output_path.read_text(encoding="utf-8")
    output_ids = [
        int(value)
        for value in re.findall(r"/article/details/(\d+)\)", text)
    ]
    database_ids = {
        row[0]
        for row in connection.execute(
            """
            SELECT article_id FROM articles
            WHERE is_public=1
              AND crawl_status='verified_listed'
              AND subcategory IS NOT NULL
            """
        )
    }
    duplicate_ids = sorted(
        article_id
        for article_id in set(output_ids)
        if output_ids.count(article_id) > 1
    )
    top_categories = re.findall(r"^## (\d)、", text, re.MULTILINE)
    subcategories = re.findall(r"^### (\d+\.\d+)、", text, re.MULTILINE)

    result = {
        "output_articles": len(output_ids),
        "top_categories": len(top_categories),
        "subcategories": len(subcategories),
        "duplicate_article_ids": duplicate_ids,
        "missing_from_output": sorted(database_ids - set(output_ids)),
        "unknown_in_output": sorted(set(output_ids) - database_ids),
        "has_tracking_parameters": any(
            word in text for word in ("fromshare", "sharerId")
        ),
        "has_placeholder_url": "xxxxxx" in text,
    }
    result["passed"] = (
        result["top_categories"] == 5
        and result["subcategories"] == 22
        and not result["duplicate_article_ids"]
        and not result["missing_from_output"]
        and not result["unknown_in_output"]
        and not result["has_tracking_parameters"]
        and not result["has_placeholder_url"]
    )
    return result


def show_status(connection: sqlite3.Connection) -> dict:
    row = connection.execute(
        """
        SELECT
            COUNT(*) AS total,
            SUM(crawl_status='verified_listed') AS verified,
            SUM(crawl_status='verified_listed' AND subcategory IS NOT NULL)
                AS classified,
            SUM(crawl_status='verified_listed' AND subcategory IS NULL)
                AS needs_review
        FROM articles
        """
    ).fetchone()
    return dict(row)


def main() -> int:
    parser = argparse.ArgumentParser(
        description="CSDN 博客目录采集、分类与 Markdown 生成"
    )
    parser.add_argument("--username", default="qq_73850061")
    parser.add_argument("--crawl", action="store_true")
    parser.add_argument("--generate", action="store_true")
    parser.add_argument("--validate", action="store_true")
    parser.add_argument("--update", action="store_true")
    parser.add_argument("--status", action="store_true")
    parser.add_argument("--page-size", type=int, default=100)
    args = parser.parse_args()

    root = Path(__file__).resolve().parent
    data_dir = root / "data"
    database_path = data_dir / "articles.db"
    output_path = root / "博客目录框架_新版.md"
    connection = connect_database(database_path)

    if args.crawl or args.update:
        crawl(connection, args.username, data_dir, args.page_size)
    if args.generate or args.update:
        count = generate_markdown(connection, output_path)
        print(f"已生成 {count} 篇文章:{output_path}")
    if args.validate or args.update:
        result = validate_markdown(connection, output_path)
        print(json.dumps(result, ensure_ascii=False, indent=2))
    if args.status:
        print(json.dumps(show_status(connection), ensure_ascii=False, indent=2))

    connection.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

八、运行方法

1. 检查Python版本

建议使用 Python 3.11 或更高版本:

powershell 复制代码
python --version

2. 一次完成抓取、生成和校验

powershell 复制代码
python csdn_blog_catalog.py --update --username qq_73850061

3. 分阶段执行

powershell 复制代码
python csdn_blog_catalog.py --crawl
python csdn_blog_catalog.py --generate
python csdn_blog_catalog.py --validate
python csdn_blog_catalog.py --status

九、源码中的关键知识点

1. Python标准库网络请求

本文使用 urllib.request 发起请求,主要知识包括:

  • Request 设置请求头。
  • urlencode 生成查询参数。
  • urlopen 设置超时。
  • HTTPError 区分 403、429 和其他错误。
  • URLError 处理网络异常。

2. SQLite事务与唯一约束

article ID 主键和 URL 唯一约束共同保证数据身份正确。ON CONFLICT 用于同步标题、摘要和分类结果。

每保存一篇文章就执行一次 commit(),虽然极限性能不是最高,但更符合个人数据采集任务对可靠性的要求。

3. 正则表达式

正则表达式主要用于:

  • 从文章标题提取自定义编号。
  • 清理标题开头的 Markdown #。
  • 从最终目录提取 article ID。
  • 统计一级和二级目录数量。

4. 可恢复任务设计

可恢复任务不能依赖聊天记录或内存变量,必须把状态写入磁盘。

本文同时使用:

  • SQLite 保存长期文章数据。
  • JSON 保存当前分页进度。
  • 临时文件加 os.replace() 保证状态原子更新。
  • 幂等 SQL 保证重复运行不会产生重复文章。

十、工程实践中的注意事项

1. 不要猜测文章URL

自定义编号不连续,并不代表爬虫漏抓。文章可能被删除、改号、设为私密,或者从未发布。

只有公开列表返回的 article ID 才能进入最终目录。

2. 不要绕过访问控制

遇到 403、429、验证码或登录限制时,应保存当前进度并停止。不要尝试绕过访问控制,也不要进行高频请求。

3. 不要把低置信分类当成正确结果

规则分类适合处理标题明确的文章,但不能代替人工判断。标题模糊时,应记录分类依据和置信度,并单独复核。

4. 不要覆盖原目录

旧目录是重要的种子数据和审计依据。正确做法是生成新文件,例如:

text 复制代码
博客目录框架.md
博客目录框架_新版.md

十一、总结

这套方案的重点并不只是"把网页抓下来",而是建立一个可以长期维护的数据工作流:

  • 用公开 article ID 确认文章身份。
  • 用永久 URL 消除分享参数。
  • 用 SQLite 防止重复并持续保存。
  • 用进度文件支持任务恢复。
  • 用标题、摘要和标签完成初步分类。
  • 用 Markdown 生成可直接发布的目录。
  • 用校验程序检查重复、遗漏和虚假链接。

当新文章发布后,只需要重新执行 --update,程序就能读取最新公开列表、更新数据库并重新生成目录。

真正可靠的爬虫,不是请求速度有多快,而是数据身份正确、过程可恢复、结果可验证。

十二、附录:本篇博客使用的写作提示词

1. 写作提示词原文

下面保留本篇文章使用的写作要求。文章序号根据当前目录最大编号 147 顺延为 148。

text 复制代码
你的这个爬虫脚本写得非常好,请写一篇有关这些知识的博客,同时把你写的这些源码包含进博客里。

文章序号就是:148。

# 扩:标题的格式

编号样式(强制执行)

- 一级:`一、`(中文数字加顿号)(强制执行)
- 二级:`1.`(阿拉伯数字加英文句点)(强制执行)
- 三级:`(1)`(带括号的阿拉伯数字)(强制执行)
- 四级:`a.`(小写字母加英文句点)(强制执行)
- 五级:`i.`(小写罗马数字加英文句点)(强制执行)

## 罗马数字 1-50 完整对照表

| 范围 | 规律 |
|------|------|
| 1-10 | `I` `II` `III` `IV` `V` `VI` `VII` `VIII` `IX` `X` |
| 11-20 | `XI` `XII` `XIII` `XIV` `XV` `XVI` `XVII` `XVIII` `XIX` `XX` |
| 21-30 | `XXI` `XXII` `XXIII` `XXIV` `XXV` `XXVI` `XXVII` `XXVIII` `XXIX` `XXX` |
| 31-40 | `XXXI` `XXXII` `XXXIII` `XXXIV` `XXXV` `XXXVI` `XXXVII` `XXXVIII` `XXXIX` `XL` |
| 41-50 | `XLI` `XLII` `XLIII` `XLIV` `XLV` `XLVI` `XLVII` `XLVIII` `XLIX` `L` |

- 六级:`⓵`(带圈数字符号)

### 带圈数字符号 1-20

#### ①~⑳

| 数字 | 符号 | 数字 | 符号 | 数字 | 符号 | 数字 | 符号 |
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| 1    | `①`  | 6    | `⑥`  | 11   | `⑪`  | 16   | `⑯`  |
| 2    | `②`  | 7    | `⑦`  | 12   | `⑫`  | 17   | `⑰`  |
| 3    | `③`  | 8    | `⑧`  | 13   | `⑬`  | 18   | `⑱`  |
| 4    | `④`  | 9    | `⑨`  | 14   | `⑭`  | 19   | `⑲`  |
| 5    | `⑤`  | 10   | `⑩`  | 15   | `⑮`  | 20   | `⑳`  |

以下是模板。这个模板重点说明了文章标题本身不计入正文的一级章节编号。

# 标题(标题也是一级标题)

# 1. 核心源文件(Source 根目录)

这些文件是 FreeRTOS 内核的通用实现,与具体硬件无关,因此放在 `Source` 根目录下,所有移植版本共用。

| 文件 | 作用 | 为什么放在这里 |
|------|------|----------------|
| `tasks.c` | 任务管理:创建、删除、延时、调度、优先级管理 | 内核最核心的功能,与硬件无关 |
| `list.c` | 链表数据结构实现,内核内部使用(就绪链表、延时链表等) | 纯软件数据结构,与硬件无关 |
| `queue.c` | 队列、信号量、互斥量的实现 | 内核同步机制,与硬件无关 |
| `timers.c` | 软件定时器实现 | 依赖 tick,但本身逻辑与硬件无关 |
| `event_groups.c` | 事件组实现 | 内核同步机制,与硬件无关 |
| `croutine.c` | 协程(Co-routine)实现(可选,已较少使用) | 与硬件无关 |
| `stream_buffer.c` | 流缓冲区(Stream Buffer)实现 | 与硬件无关 |
| `message_buffer.c` | 消息缓冲区(Message Buffer)实现(基于流缓冲区) | 与硬件无关 |

> 为什么这些文件放在 Source 根目录?
>
> 因为它们只依赖 C 语言和 FreeRTOS 内部接口,不直接操作寄存器或特定硬件。放在根目录可以避免重复,所有工程共享同一份代码。

# 2. 移植相关文件(portable 目录)

`portable` 目录下按 `[compiler]/[architecture]` 分子目录,例如 `RVDS/ARM_CM3`。

| 文件 | 作用 | 为什么放在这个子目录 |
|------|------|----------------------|
| `port.c` | 实现硬件相关的底层操作:上下文切换、启动第一个任务、SysTick 中断处理、临界区进入/退出 | 这些操作依赖具体 CPU 架构和编译器,必须为每种组合单独实现 |
| `portmacro.h` | 定义硬件相关的数据类型(如 `TickType_t`、`BaseType_t`)、临界区宏、上下文切换相关的宏 | 同 `port.c`,与架构和编译器强相关 |

> 为什么按 `[compiler]/[architecture]` 分目录?
>
> 不同编译器(RVDS、IAR、GCC)的内联汇编、符号修饰、数据对齐等特性不同;不同架构(Cortex-M3、M4、RISC-V)的寄存器和指令集不同。分目录可以清晰隔离这些差异。

# 3. 内存管理文件(portable\MemMang)

| 文件 | 作用 | 为什么放在 portable 下 |
|------|------|------------------------|
| `heap_1.c` | 只分配不释放,适用于简单应用 | 内存分配策略依赖应用场景,用户可以根据需求选择或自定义 |
| `heap_2.c` | 支持分配和释放,但不合并相邻空闲块 | 同上 |
| `heap_3.c` | 包装标准 `malloc/free`,线程安全 | 同上 |
| `heap_4.c` | 支持分配、释放,并合并相邻空闲块(最常用) | 同上 |
| `heap_5.c` | 同 `heap_4`,但支持多个非连续内存区域 | 同上 |

> 为什么放在 `portable` 目录?
>
> 内存管理方案与硬件内存布局、编译器特性、应用需求相关,因此被设计为可替换组件。`portable` 目录的本意就是"用户可以提供自己的实现"。

# 4. 头文件目录

## 4.1 `Source\include`

包含所有内核公开的头文件:

| 头文件 | 作用 |
|--------|------|
| `FreeRTOS.h` | 总包含文件,引入其他头文件 |
| `task.h` | 任务管理 API 声明 |
| `queue.h` | 队列、信号量、互斥量 API 声明 |
| `semphr.h` | 信号量相关宏和函数声明 |
| `timers.h` | 软件定时器 API 声明 |
| `event_groups.h` | 事件组 API 声明 |
| `stream_buffer.h` | 流缓冲区 API 声明 |
| `message_buffer.h` | 消息缓冲区 API 声明 |
| `projdefs.h` | 通用宏定义(如 `pdTRUE`、`pdFALSE`) |
| `portable.h` | 移植层接口声明 |
| `list.h` | 链表结构声明(内核内部使用) |
| `stack_macros.h` | 栈检查相关宏 |

## 4.2 `portable\[compiler]\[architecture]`

包含 `portmacro.h`,定义硬件相关数据类型和宏,是所有源文件需要包含的移植头文件。

## 4.3 `Core\Inc`(或工程配置目录)

包含 `FreeRTOSConfig.h`,这是用户配置文件,用于裁剪和配置内核功能(如是否启用软件定时器、tick 频率、堆大小等)。

> 为什么 `FreeRTOSConfig.h` 放在工程目录而不是 Source 目录?
>
> 因为它是工程特定的配置,每个工程可以有不同的配置,不应修改内核源文件。放在工程目录可以独立于内核源码进行管理。

# 5. 入口函数与启动流程

入口函数通常位于 `main.c` 中,与 STM32CubeMX 生成的代码结合:

```c
osKernelInitialize();   // 初始化 FreeRTOS 内核(创建空闲任务、初始化链表等)
MX_FREERTOS_Init();     // 创建用户任务(由 CubeMX 生成,位于 freertos.c)
osKernelStart();        // 启动调度器,开始任务调度
```

- `osKernelInitialize()` 和 `osKernelStart()` 是 CMSIS-RTOS 封装,底层调用 FreeRTOS 的 `vTaskStartScheduler()` 等函数。
- `freertos.c` 中定义的任务创建代码最终会调用 `xTaskCreate()`。

# 总结:目录划分的设计思想

```text
FreeRTOS/Source
├── include                 # 内核公共头文件(API 声明、内部数据结构)
├── *.c                     # 内核核心实现(与硬件无关)
├── portable
│   ├── [compiler]/[arch]   # 移植层(上下文切换、硬件相关数据类型)
│   └── MemMang             # 可替换的内存管理实现
└── (工程目录)
    ├── FreeRTOSConfig.h    # 工程配置
    └── main.c / freertos.c # 入口和任务创建
```

- 核心代码与移植代码分离:方便支持不同硬件平台。
- 配置与源码分离:`FreeRTOSConfig.h` 放在工程目录,避免修改内核源码。
- 可替换组件独立:内存管理、协程等可选功能放在 `portable` 下,用户可按需选择。

如果需要根据具体文件列表或图片内容逐一解释,可以继续提供图片中的文字或文件名。
相关推荐
传感器与混合集成电路43 分钟前
28V输入又带浪涌怎么办?嵌入式计算机一次电源防护设计要点
嵌入式硬件
味悲1 小时前
Nginx笔记
linux·运维·笔记·nginx
Escalating_xu1 小时前
【Linux 网络编程】传输层协议 UDP:从 8 字节首部到消息边界、缓冲区与 MTU
linux·网络·udp
尼喃1 小时前
双节锂电池充电芯片+保护IC二合一,一块板子搞定充电和放电保护
嵌入式硬件
Lucky_Turtle1 小时前
【Linux】ip命令,路由表相关配置
linux·运维·tcp/ip
冲,干,闯1 小时前
硬件器件-晶振篇
嵌入式硬件
dawnsky.liu1 小时前
在 RHEL 中使用 goose 实现主机运维
linux·运维·aiops
wuyk5552 小时前
模块解耦实战:用 C 语言实现面向对象编程的三个层次
stm32·单片机·嵌入式硬件·性能优化·架构
碧海银沙音频科技研究院2 小时前
基于炬芯ATS308X的BLE 无线方式离线日志传输导出功能实现
java·linux·服务器