fix(export_sns): _load_comments 过滤已撤回的点赞/评论 (#120)

微信对撤回的点赞/评论不硬删, 只在 SnsMessage_tmp3 行上打 del_status 标记. 老 _load_comments 直接 SELECT 不带任何 WHERE, 结果导出的 likes/comments 里混着撤回行 — 等于 "对方撤回的赞还能在本地导出里看到", 违反用户预期.

修复: SQL 加 WHERE COALESCE(del_status, 0) = 0
- COALESCE 兜底: 老 schema NULL 视作 0 (保留)
- WHERE 而非 Python 端过滤: 大 db 少传 row
- 函数签名/返回结构不变
- 缺列时仍走现有 try/except 路径, 返回 {} 不崩

测试: LoadCommentsTests 3 case (撤回过滤 / NULL 保留 / 缺列兜底), 全合成 sqlite 无 PII. baseline 17 → 20 全过.

承接 #119 的 SNS 导出可靠性线: #119 修 "老 XML 让整行帖子丢失", 本 PR 修 "互动里混入已撤回 row".
This commit is contained in:
Belugary
2026-05-25 21:23:07 +08:00
committed by GitHub
parent 7ab90dcd20
commit 5208f6f517
2 changed files with 117 additions and 2 deletions

View File

@@ -205,5 +205,114 @@ class SecurityAndLimitsTests(unittest.TestCase):
self.assertIsNone(export_sns._parse_timeline_xml(big))
class LoadCommentsTests(unittest.TestCase):
"""`_load_comments` filters out interactions with `del_status != 0`.
WeChat does not hard-delete a recalled like / comment; it sets a
`del_status` flag. Before this fix `_load_comments` selected all rows
unconditionally, so the export carried recalled interactions.
"""
def _make_db(self, rows, *, with_del_status_col=True):
import os
import sqlite3
import tempfile
fd, path = tempfile.mkstemp(suffix=".db")
os.close(fd)
conn = sqlite3.connect(path)
try:
cols = (
"local_id INTEGER PRIMARY KEY AUTOINCREMENT,"
" feed_id INTEGER, create_time INTEGER, type INTEGER,"
" from_username TEXT, from_nickname TEXT,"
" to_username TEXT, to_nickname TEXT, content TEXT"
)
if with_del_status_col:
cols += ", del_status INTEGER"
conn.execute(f"CREATE TABLE SnsMessage_tmp3 ({cols})")
insert_cols = (
"feed_id, create_time, type, from_username, from_nickname,"
" to_username, to_nickname, content"
)
placeholders = "?, ?, ?, ?, ?, ?, ?, ?"
if with_del_status_col:
insert_cols += ", del_status"
placeholders += ", ?"
conn.executemany(
f"INSERT INTO SnsMessage_tmp3 ({insert_cols}) VALUES ({placeholders})",
rows,
)
conn.commit()
finally:
conn.close()
return path
def test_recalled_interactions_excluded(self):
# del_status=1 → recalled, must be dropped.
rows = [
(100, 1, 1, "wxid_alive", "Alive", "", "", "", 0),
(100, 2, 2, "wxid_recalled", "Recalled", "", "", "撤回的评论", 1),
(100, 3, 1, "wxid_alsoalive", "AlsoAlive", "", "", "", 0),
]
import os
import sqlite3
path = self._make_db(rows)
try:
conn = sqlite3.connect(path)
try:
comments = export_sns._load_comments(conn)
finally:
conn.close()
finally:
os.unlink(path)
self.assertEqual(len(comments[100]), 2)
self.assertEqual({c["from_username"] for c in comments[100]},
{"wxid_alive", "wxid_alsoalive"})
def test_null_del_status_treated_as_kept(self):
# del_status IS NULL (or 0) → keep. COALESCE() backstop.
rows = [
(200, 1, 1, "wxid_a", "A", "", "", "", None),
(200, 2, 1, "wxid_b", "B", "", "", "", 0),
]
import os
import sqlite3
path = self._make_db(rows)
try:
conn = sqlite3.connect(path)
try:
comments = export_sns._load_comments(conn)
finally:
conn.close()
finally:
os.unlink(path)
self.assertEqual(len(comments[200]), 2)
def test_missing_del_status_column_tolerated(self):
# Schemas without del_status: SQL still parses (column referenced only
# inside COALESCE) → expect graceful failure path, not crash.
rows = [
(300, 1, 1, "wxid_x", "X", "", "", ""),
]
import os
import sqlite3
path = self._make_db(rows, with_del_status_col=False)
try:
conn = sqlite3.connect(path)
try:
comments = export_sns._load_comments(conn)
finally:
conn.close()
finally:
os.unlink(path)
# `del_status` doesn't exist → OperationalError caught by the function,
# returns empty dict (matching upstream behavior for malformed schemas).
self.assertEqual(comments, {})
if __name__ == "__main__":
unittest.main()