Files
MediaCrawler/tests/test_no_user_info.py
程序员阿江(Relakkes) 9f4f8bf768 refactor: 教学版移除全平台用户个人信息采集与持久化
- 用户 ID 转为匿名 creator_hash,昵称中间脱敏,IP/头像/主页/签名/性别不再采集
- 覆盖 xhs/weibo/bilibili/douyin/kuaishou/tieba/zhihu 7 个平台
- 删除 7 张 creator 档案 ORM 表,15 张内容/评论表新增 creator_hash 列
- B 站禁用粉丝/关注/联系人列表抓取
- 新增 tools/user_hash.py 与 4 个平台的 mock+SQLite 端到端测试

测试: pytest tests/test_no_user_info.py tests/test_weibo_no_user_info.py tests/test_douyin_no_user_info.py tests/test_kuaishou_no_user_info.py (21 passed)
2026-07-01 13:09:55 +08:00

240 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
教学版回归测试:确保爬取/存储链路不再持久化可定位真人的用户个人信息。
覆盖:
1. ORM 自省 —— database.models 中无禁用列、creator 档案表已删除、内容/评论表含 creator_hash。
2. 提取层 —— 用 mock API/HTML payload 喂各平台提取器,断言输出 dict 不含禁用字段、
不含原始 user_id、昵称已脱敏且不等于原文。
3. 仓库 grep 断言 —— store/ 与 media_platform/ 不再把禁用字段作为存储 dict 的 key。
"""
import re
import subprocess
import pathlib
import pytest
ROOT = pathlib.Path(__file__).resolve().parent.parent
# 统一的禁用字段名(键)。昵称字段(nickname/user_nickname/screen_name/name/user_name)允许保留(值需脱敏)。
FORBIDDEN_KEYS = {
"user_id", "sec_uid", "short_user_id", "user_unique_id", "user_signature",
"avatar", "user_avatar", "face", "sign", "profile_url", "user_link",
"url_token", "user_url_token", "ip_location", "ip_address", "gender", "sex",
"up_id", "fan_id", "up_name", "fan_name", "up_avatar", "fan_avatar",
"up_sign", "fan_sign", "mid",
}
NICK_KEYS = {"nickname", "user_nickname", "screen_name", "name", "user_name"}
MASK_RE = re.compile(r"^.?\*{1,4}.?$")
# ----------------------------- ORM 自省 -----------------------------
def test_orm_has_no_forbidden_columns():
import database.models as m
from sqlalchemy.orm import class_mapper
tables = [c for c in dir(m) if c[0].isupper()
and c not in ("Base", "Column", "Integer", "BigInteger", "String", "Text")]
bad = []
for t in tables:
cols = {c.name for c in class_mapper(getattr(m, t)).columns}
hit = cols & FORBIDDEN_KEYS
if hit:
bad.append((t, sorted(hit)))
assert not bad, f"ORM 仍含禁用列: {bad}"
def test_creator_tables_removed():
import database.models as m
removed = {"XhsCreator", "DyCreator", "WeiboCreator", "TiebaCreator",
"ZhihuCreator", "BilibiliUpInfo", "BilibiliContactInfo"}
for t in removed:
assert not hasattr(m, t), f"creator 档案表 {t} 仍存在"
def test_content_tables_have_creator_hash():
import database.models as m
from sqlalchemy.orm import class_mapper
content_tables = ["XhsNote", "XhsNoteComment", "WeiboNote", "WeiboNoteComment",
"BilibiliVideo", "BilibiliVideoComment", "BilibiliUpDynamic",
"DouyinAweme", "DouyinAwemeComment", "KuaishouVideo",
"KuaishouVideoComment", "TiebaNote", "TiebaComment",
"ZhihuContent", "ZhihuComment"]
for t in content_tables:
cols = {c.name for c in class_mapper(getattr(m, t)).columns}
assert "creator_hash" in cols, f"{t} 缺少 creator_hash 列"
# ----------------------------- 提取层 mock -----------------------------
def _check_no_forbidden_keys(d: dict, label: str):
keys = set(d.keys())
hit = keys & FORBIDDEN_KEYS
assert not hit, f"[{label}] 输出仍含禁用字段键: {hit}"
def _check_nickname_masked(d: dict, raw: str, label: str):
nick_keys = set(d.keys()) & NICK_KEYS
assert nick_keys, f"[{label}] 未保留任何昵称字段(应保留并脱敏)"
for k in nick_keys:
val = d[k]
if val in ("", None):
continue
assert val != raw, f"[{label}] {k} 未脱敏,仍为原文: {val}"
assert MASK_RE.match(val) or "*" in val, f"[{label}] {k} 未脱敏: {val}"
def test_mask_and_hash_tools():
from tools.user_hash import anonymize_user_id, mask_nickname
h = anonymize_user_id("12345")
assert h and h != "12345" and re.fullmatch(r"[0-9a-f]{16}", h)
assert anonymize_user_id(None) == "" and anonymize_user_id("") == ""
# 昵称脱敏首尾留1字、中间星号且不等于原文
assert mask_nickname("张三丰") != "张三丰"
assert "*" in mask_nickname("张三丰")
assert mask_nickname(None) == ""
assert mask_nickname("a") == "*"
def test_xhs_note_extraction_masks_user_info():
import asyncio
import store.xhs as xs
note_item = {
"note_id": "abc",
"type": "normal",
"title": "t",
"desc": "d",
"time": 1,
"last_update_time": 0,
"user": {"user_id": "u123", "nickname": "小红同学", "avatar": "http://x/a.jpg"},
"ip_location": "上海",
"interact_info": {"liked_count": "1", "collected_count": "0",
"comment_count": "0", "share_count": "0"},
"image_list": [], "tag_list": [], "xsec_token": "tok",
}
captured = {}
class FakeStore:
async def store_content(self, content_item):
captured.update(content_item)
orig = xs.XhsStoreFactory.create_store
xs.XhsStoreFactory.create_store = staticmethod(lambda: FakeStore())
try:
asyncio.run(xs.update_xhs_note(note_item))
finally:
xs.XhsStoreFactory.create_store = orig
_check_no_forbidden_keys(captured, "xhs_note")
assert captured.get("creator_hash") != "u123"
_check_nickname_masked(captured, "小红同学", "xhs_note")
def test_tieba_note_extraction_masks_user_info():
from media_platform.tieba.help import TieBaExtractor
api_data = {
"thread": {"id": 1, "title": "tt", "reply_num": 5},
"first_floor": {"tid": 1, "author_id": 9, "time": 1700000000, "content": "c"},
"forum": {"name": "test", "id": 1},
"page": {"total_page": 1},
"user_list": [{"id": 9, "name_show": "贴吧老哥", "name": "lg", "portrait": "p", "ip_address": "北京"}],
}
note = TieBaExtractor().extract_note_detail_from_api(api_data)
d = note.model_dump()
_check_no_forbidden_keys(d, "tieba_note")
assert d.get("creator_hash") # user_link 已转哈希
_check_nickname_masked(d, "贴吧老哥", "tieba_note")
def test_tieba_comment_extraction_masks_user_info():
from media_platform.tieba.help import TieBaExtractor
from model.m_baidu_tieba import TiebaNote
api_data = {
"forum": {"id": 1, "name": "test"},
"post_list": [{"id": 7, "author_id": 9, "time": 1700000000, "content": "c", "sub_post_number": 0}],
"user_list": [{"id": 9, "name_show": "评论员", "name": "py", "portrait": "p", "ip_address": "上海"}],
}
note_detail = TiebaNote(note_id="1", title="t", note_url="u", tieba_name="test", tieba_link="l")
comments = TieBaExtractor().extract_tieba_note_parent_comments_from_api(api_data, note_detail)
assert comments
d = comments[0].model_dump()
_check_no_forbidden_keys(d, "tieba_comment")
_check_nickname_masked(d, "评论员", "tieba_comment")
def test_zhihu_comment_extraction_masks_user_info():
from media_platform.zhihu.help import ZhihuExtractor
from model.m_zhihu import ZhihuContent
comments_raw = [{
"type": "comment", "id": 1, "content": "c", "created_time": 1700000000,
"like_count": 1, "dislike_count": 0, "child_comment_count": 0,
"author": {"id": "z9", "name": "知乎答主", "url_token": "tok", "avatar_url": "http://x/a.jpg"},
"comment_tag": [{"type": "ip_info", "text": "广东"}],
}]
page_content = ZhihuContent(content_id="c1", content_type="answer")
comments = ZhihuExtractor().extract_comments(page_content, comments_raw)
assert comments
d = comments[0].model_dump() if hasattr(comments[0], "model_dump") else vars(comments[0])
_check_no_forbidden_keys(d, "zhihu_comment")
assert "creator_hash" in d and d["creator_hash"]
_check_nickname_masked(d, "知乎答主", "zhihu_comment")
def test_bilibili_video_dict_masks_user_info():
# 直接测 store/bilibili/__init__.py 的拍平逻辑(不触发网络)
import asyncio
from store.bilibili import update_bilibili_video
video_item = {
"View": {
"aid": 100, "title": "t", "desc": "d", "pubdate": 1,
"owner": {"mid": 777, "name": "UP主大人", "face": "http://x/a.jpg"},
"stat": {"like": 1, "view": 2},
"pic": "http://x/cover.jpg",
}
}
# 拦截真实存储:替换工厂返回一个捕获 dict 的假 store
captured = {}
class FakeStore:
async def store_content(self, content_item):
captured.update(content_item)
import store.bilibili as bs
orig = bs.BiliStoreFactory.create_store
bs.BiliStoreFactory.create_store = staticmethod(lambda: FakeStore())
try:
asyncio.get_event_loop().run_until_complete(update_bilibili_video(video_item)) \
if False else asyncio.run(update_bilibili_video(video_item))
finally:
bs.BiliStoreFactory.create_store = orig
_check_no_forbidden_keys(captured, "bili_video")
assert captured.get("creator_hash") != 777
_check_nickname_masked(captured, "UP主大人", "bili_video")
# ----------------------------- 仓库 grep 断言 -----------------------------
def test_store_no_forbidden_dict_keys():
# store/ 下不得把禁用字段作为存储 dict 的 key("field": value 形式)
out = subprocess.run(
["grep", "-rnE", '"(' + "|".join(FORBIDDEN_KEYS) + r')"\s*:', str(ROOT / "store")],
capture_output=True, text=True,
)
# 允许的例外Mongo store_creator 里的 query={"user_id": ...} 已全部改为 pass应为空
assert out.stdout.strip() == "", f"store/ 仍写入禁用字段键:\n{out.stdout}"
def test_store_no_creator_orm_imports():
# 已删除的 creator ORM 表(XhsCreator/DyCreator/...)不得再从 database.models 导入。
# 注意:model/m_*.py 里的同名 pydantic 类是内存类型,允许保留。
out = subprocess.run(
["grep", "-rnE",
r"from database\.models import.*(XhsCreator|DyCreator|WeiboCreator|TiebaCreator|ZhihuCreator|BilibiliUpInfo|BilibiliContactInfo)",
str(ROOT / "store")],
capture_output=True, text=True,
)
assert out.stdout.strip() == "", f"store/ 仍 import 已删除的 creator ORM 表:\n{out.stdout}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])