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:
@@ -601,13 +601,19 @@ def _parse_timeline_xml(content_xml):
|
|||||||
|
|
||||||
|
|
||||||
def _load_comments(conn):
|
def _load_comments(conn):
|
||||||
"""加载 SnsMessage_tmp3 评论/点赞,按 feed_id 分组"""
|
"""加载 SnsMessage_tmp3 评论/点赞,按 feed_id 分组。
|
||||||
|
|
||||||
|
`del_status != 0` 表示对方撤回该互动 —— 微信本地不真删,只设删除标记,
|
||||||
|
不过滤会把已撤回的点赞 / 评论也导出。COALESCE 兜底老 schema 缺列时
|
||||||
|
`NULL` 视作 0。
|
||||||
|
"""
|
||||||
comments = {}
|
comments = {}
|
||||||
try:
|
try:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT feed_id, create_time, type, from_username, from_nickname,"
|
"SELECT feed_id, create_time, type, from_username, from_nickname,"
|
||||||
" to_username, to_nickname, content"
|
" to_username, to_nickname, content"
|
||||||
" FROM SnsMessage_tmp3 ORDER BY create_time"
|
" FROM SnsMessage_tmp3 WHERE COALESCE(del_status, 0) = 0"
|
||||||
|
" ORDER BY create_time"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for feed_id, ctime, ctype, from_u, from_n, to_u, to_n, content in rows:
|
for feed_id, ctime, ctype, from_u, from_n, to_u, to_n, content in rows:
|
||||||
if feed_id not in comments:
|
if feed_id not in comments:
|
||||||
|
|||||||
@@ -205,5 +205,114 @@ class SecurityAndLimitsTests(unittest.TestCase):
|
|||||||
self.assertIsNone(export_sns._parse_timeline_xml(big))
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user