一个备份脚本的持续"修复"——博客园 OpenAPI 实践中遇到的四个坑

背景

我使用 Hermes Agent(一个开源的 AI 助手框架)管理了一个每日定时任务,自动备份博客园的所有随笔到本地 Markdown 文件。一开始觉得是个很简单的需求——调个 API、拉列表、下载正文、存文件,30 行脚本搞定的事。

结果一路踩了四个坑,每一轮修复都暴露出一个新的隐蔽问题。

坑一:为什么每次只拉了 10 条?

现象: 备份脚本运行后,本地永远只有 10 个文件。但博客上明明有 17 篇。

排查: 查看分页逻辑,伪代码大概是这样:

for page in range(1, 100):
    posts = api_get(f'/api/blogs/{blogApp}/posts?pageIndex={page}')
    if not posts:
        break
    all_posts.extend(posts)
    if len(posts) < 20:    # ← 判断最后一页
        break

这个 if len(posts) < 20 是检查「如果返回数不足 20,说明是最后一页」。

但实际上,博客园 OpenAPI 的 /api/blogs/{blogApp}/posts 接口默认 pageSize=10。第一页返回了 10 条,10 < 20 = True,脚本以为这就到头了——只拉了第一页。

修复: 请求参数加 &pageSize=100,同时跳出条件改为 len(posts) < 100

posts = api_get(f'/api/blogs/{blogApp}/posts?pageIndex={page}&pageSize=100')
...
if len(posts) < 100:
    break

坑二:同日修改为什么检测不到?

现象: 脚本有一个「增量备份」逻辑——对比本地备份日期和线上日期,线上更新了就重新下载。但用户告诉我:我编辑了文章再保存,它不动。

排查: 发现脚本在比较日期时做了 [:10] 截断:

online_date = post.get('PostDate', '')[:10]  # "2026-05-28"
local_date = existing[pid]                    # "2026-05-28"
if online_date > local_date:
    return True, 'updated'

API 返回的 PostDate 格式是 2026-05-28T16:03:00——有完整时间粒度。但 [:10] 一截,只剩 2026-05-28,等于只比「天」。上午发、下午改,时间戳变了,但天数没变,永远检测不到。

修复: 去掉两处 [:10],存/比完整时间戳。

坑三:编辑后 PostDate 根本不更新!

现象: 用户确认:他手动编辑了一篇文章再保存,PostDate 字段没变。那就算时间戳精度再高也没用——字段本身不变。

排查: 查了一圈博客园的 API 文档和实际返回值:

  • OpenAPI/api/blogs/{blogApp}/posts 返回的日期字段只有 PostDate
  • MetaWebloggetPost 返回的日期字段只有 dateCreated
  • 两者都没有 updateDatelastModifieddateModified

博客园的 API 不提供"最后修改时间"这个信息。

修复: 既然没有时间戳可依赖,那就用内容指纹。把检测策略从「时间对比」改为「SHA256 内容 hash 对比」:

# 每次下载文章的 body HTML,计算 SHA256
new_hash = hashlib.sha256(html_body.encode('utf-8')).hexdigest()

# 与本地 frontmatter 中存储的 body_hash 对比
if new_hash != stored['hash']:
    return True, 'updated'  # 内容变了

代价是每次运行都要下载所有文章的正文字段(17 篇约 15 秒),但换来的是任何修改都能被检测,不管修改发生在什么时候。

坑四:格式变更带来的重复文件

现象: 把修复后的脚本跑了一遍,预期是「全部跳过(内容没变)」,结果只跳过了不到一半,大半还是被标记为 updated。

排查: 查看备份目录,发现了 34 个 .md 文件(预期 17 个)。原来脚本经历了两次格式变更:

  1. 第一次:文件名从 2026-05-28_标题.md 改为 2026-05-28T16:03:00_标题.md(完整时间戳)
  2. 第二次:frontmatter 新增 body_hash 字段

每次格式变更,脚本都写入了新文件,但没删除旧文件。目录里同一篇文章有两份备份,scan_existing_backups() 按文件名扫描时,后读到的旧文件(无 body_hash)覆盖了先读到的新文件(有 body_hash),导致 hash 对比失效。

修复: 清理了旧文件,并在 scan_existing_backups() 中增加了去重逻辑——同一 postid 出现多次时,优先保留有 body_hash 的记录。

彩蛋坑:老文章 API 返回空 body

现象: 大家应该注意到了"第 5 个坑"的标题暗示。有一篇 2022 年的欢迎帖,备份出来只有 frontmatter,正文为空。

排查: OpenAPI /api/blogposts/{id}/body 对该文章返回了空字符串 "",但网页版 <div id="cnblogs_post_body"> 内有正常内容。这是博客园 API 对早期文章的兼容问题——部分旧文 body 端点的确不返回数据。

修复:fetch_body_html() 中加了 fallback——当 API 返回空 body 时,自动抓取网页版提取正文:

if html.strip():
    return html
# Fallback: scrape webpage
page_url = f'https://www.cnblogs.com/{blogApp}/p/{post_id}.html'
page_html = urllib.urlopen(page_url).read()
match = re.search(
    r'<div id="cnblogs_post_body"[^>]*>(.*?)</div>\s*<div[^>]*id="(?:MySignature|blog_post_info_block)',
    page_html, re.DOTALL
)

最终的备份策略

1. 拉取全部文章列表(1 个 API 调用)
2. 逐篇下载 body HTML(N 个 API 调用,约 N × 1s)
   - 优先走 OpenAPI body 端点
   - 为空时 fallback 到网页抓取
3. 计算 SHA256,与本地 frontmatter 的 body_hash 对比
4. hash 一致 → 跳过;不一致 → 重新备份并将新 hash 写入 frontmatter

优点:不依赖任何时间戳,内容变了就备份,不受 API 是否提供 updateDate 的限制。

写在最后

这次经历让我感触比较深的是:

  1. 不要假设 API 字段的语义PostDate 这个名字听起来像是"最后修改时间",实际上它是"创建时间"且编辑后不变。
  2. 分页边界条件一定要测试。不传 pageSize 默认 10 条、跳出条件写死 20——这两个看似无关的细节组合在一起,造成了隐蔽的 bug。
  3. 文件格式变更要考虑兼容。新增字段时,旧文件还在目录里,扫描逻辑要能正确处理混合状态。
  4. 内容 hash 是终极兜底。当 API 不提供可靠的修改时间戳时,内容指纹是最值得信赖的方案。
posted @ 2026-05-29 11:22  getmoon  阅读(9)  评论(0)    收藏  举报