DEV.to API局限的调试经历
分享评论API处理中遇到的嵌套回复检测失败问题。特定场景经验,通用性有限。
分享评论API处理中遇到的嵌套回复检测失败问题。特定场景经验,通用性有限。
这个仓库为我的 DEV.to 文章运行一个小的评论回复管道。DEV.to 的 API 无法为普通账户密钥发布评论或反应(POST /api/comments 返回 404,POST /api/reactions 返回 401——在之前的运行中已验证),所以回复被草拟到一个 markdown 文件中,然后手动粘贴。两个函数承载了整个流程:pending() 决定什么仍需要草稿,audit() 检查已经草拟的回复是否真的出现在了实际网站上,因为粘贴步骤是手动的,发生在这个管道无法看到的地方。
两个函数都遍历相同类型的数据——一个 DEV.to 评论及其嵌套回复。我今天回到 reply_comments.py,本来期望寻找完全不同的东西,却发现这两个函数的树遍历方式不同。
pending() 调用 needs_reply(),该函数在 2026-07-26 重写过,修复了一个 bug,该 bug 只检查"我是否在这个线程的某处回复过",而不是"我是否最近回复过"。修复后的版本递归整个子树,找到具有最新时间戳的消息:
def latest_message(comment):
"""The most recently created message anywhere in this comment's subtree."""
latest = comment
for c in comment["children"]:
candidate = latest_message(c)
if candidate["created_at"] > latest["created_at"]:
latest = candidate
return latest
def needs_reply(comment):
return latest_message(comment)["user"]["username"] != ME
这是正确的,从构造上就是递归的——latest_message 对每个子评论都调用自己,所以无论一条回复嵌套多深,都能找到它。
audit() 做的事情看起来平行,但实际上不是:
for c in api(f"/comments?a_id={a['id']}"):
if c["id_code"] not in drafted_codes:
continue
if not any(ch["user"]["username"] == ME for ch in c["children"]):
unposted.append({...})
c["children"] 只包含根评论 c 的直接回复。如果我直接在原始评论下粘贴我的回复,这就有效——我的回复显示为直接子节点,any(...) 找到它,audit() 正确地说"已发布"。但这不是真实线程唯一的形状。如果有人回复我的回复,然后我再次回复,我的第二条消息是 c 的孙子节点,嵌套两级深——这是任何来回对话的完全正常形状。audit() 永远不会看超过第一级,所以它根本看不到那条消息。
在假设这是真实 bug 而不仅仅是一个不太可能的边界情况之前,我构建了精确的树形结构并针对真实函数运行它:
fake_thread = {
"id_code": "abc12", "user": {"username": "someuser"}, "created_at": "2026-07-20T08:00:00Z",
"children": [{
"id_code": "abc13", "user": {"username": "someuser"}, "created_at": "2026-07-20T09:00:00Z",
"children": [{"id_code": "abc14", "user": {"username": ME},
"created_at": "2026-07-21T10:00:00Z", "children": []}],
}],
}
someuser 的根评论、他们自己的后续评论作为直接子节点,以及我对该后续评论的回复作为孙子节点——id_code abc12 被标记为已草拟。针对未修改的 audit() 运行这个:
{
"drafted": 1,
"never_posted": [
{"id_code": "abc12", "article": "Test Article",
"comment_url": "https://dev.to/enjoy_kumawat/comment/abc12"}
]
}
被标记为未发布,尽管树中有我的回复,只是比函数检查的地方深一级。针对相同的结构运行 needs_reply() 返回 False——正确地识别我已经回复过。相同的数据,同一个文件中的两个函数,两个不同的答案。
audit() 不是决定是否草拟回复的东西——那是 pending(),它已经被修复过了。audit() 是手动粘贴步骤后面的安全网,是应该捕捉"我草拟了这条然后忘记实际上去粘贴它"的东西。这里的假正例不会丢失回复。它告诉我回复丢失了,而实际上没有,这意味着重新检查已经处理过的线程,如果这发生得足够频繁,它会训练我停止信任 audit() 的输出——这违背了拥有它的整个目的,因为它存在的全部原因是手动步骤在其他方面对管道是不可见的。
与 latest_message 相同的形状,进行递归而不是在一级停止:
def replied_anywhere_in_subtree(comment):
"""True if ME appears anywhere below this comment, at any depth."""
return any(
ch["user"]["username"] == ME or replied_anywhere_in_subtree(ch)
for ch in comment["children"]
)
并在 audit() 中用它替换了单级的 any(...) 检查。针对修复后的代码重新运行上面的精确重现:
{
"drafted": 1,
"never_posted": []
}
正确地为空。我还向文件现有的 --selftest 块中添加了一个覆盖两级深嵌套回复的情况,因为这个文件之前唯一的测试覆盖是针对 needs_reply()——audit() 没有任何覆盖,这很可能是为什么相同的树遍历错误进入一个函数而另一个函数在代码审查中没有捕捉到它,无论是自发的还是以其他方式,因为没有测试会以任何方式失败。
需要 needs_reply() 重写是因为一个不同的 bug——检查"曾经回复过"而不是"最近回复过"。修复那个 bug 需要递归,因为在树中找到最新消息意味着访问整个树。audit() 提出了一个听起来更简单的问题——"我是否回复过,在任何地方"——而一个听起来更简单的问题正是那种得到单级检查的东西,看起来足够直到你实际绘制一个三级深的对话并手工跟踪它。一旦其中一个函数被修复,没有什么强制两个函数之间的并排比较;它们只是碰巧遍历相同类型的树,一个正确,一个不是,相距四十多行。