Files
MediaCrawler/tests/test_kuaishou_no_user_info.py
T
butubb cd85587f00
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
feat(privacy): 关掉昵称脱敏 —— 脱敏有损,撞名就分不出博主
需求:评论栏/作品栏要能分清是哪个博主。修好分组字段之后名字仍带星号,因为上游作为
教学版默认对昵称做中间脱敏(首尾各留 1 字,中间星号)。这个脱敏是**有损**的:

  「张三」和「张四」都变成「张*」
  「小明老师」和「小刚老师」都变成「小***师」

而本仓库的用途是监控一批公开创作者账号,分清谁是谁正是这一层要干的事。所以关掉它。

* config/base_config.py 新增 MASK_NICKNAME = False(和 INJECT_ALL_COOKIES 一样是个
  开关,不是删代码 —— 改回 True 就恢复上游行为)。
* tools/user_hash.py 的 mask_nickname 读这个开关,关闭时原样返回。读的是模块属性而
  不是导入值,测试才能 monkeypatch。脱敏实现本身一字未动。
* 顺带修一个数据陈旧问题:评论是去重后直接 continue 的,昵称只在首次入库时写一次,
  于是开关一改(或评论者改名)老评论永远停在旧值 —— 而重采是唯一能拿到新值的途径。
  现在已存在的评论会跟着刷新昵称(作品那边的 creator_name 早就是这么做的)。
* anonymous 的 creator_hash 保持不变:那是分组用的稳定键,不是显示名。

测试:
* 三个隐私套件 + weibo 的 autouse fixture 强制把开关打开 —— 它们验的是**脱敏机制
  本身**,机制仍然必须正确,所以显式打开来测,而不是让它们随部署配置漂。
* test_mask_and_hash_tools 改成两个方向都覆盖(开着脱敏 / 关着脱敏)。
* test_tieba_extractor.py 里 8 处字面量的脱敏期望值换成真实昵称 —— 提取器现在就是
  返回原文的,期望值理应跟着改(这一条是行为变更的直接后果,不是测试放宽)。
* 新增一条:已入库的评论昵称会随重采刷新(且不会因刷新而重复插入)。
2026-10-10 15:48:15 +08:00

298 lines
12 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 -*-
"""
教学版回归测试:快手(kuaishou)存储链路不再持久化可定位真人的用户个人信息。
覆盖:
1. update_kuaishou_video —— mock video_item(含 author.id/name/headerUrl + photo 内容字段 + type)
经 FakeStore 捕获,断言捕获 dict 不含禁用键(user_id/avatar/signature/ip_location/gender)、
含 creator_hash(≠原 user_id)、nickname 脱敏(≠原文)。
2. update_ks_video_comment —— V2(snake_case) 与旧 GraphQL(camelCase) 两种 comment_item 格式各测一次。
3. test_kuaishou_store_end_to_end_sqlite —— 端到端:FakeStore 捕获真实 dict ->
KuaishouVideo(**captured) 触发 ORM 列校验 -> 写入内存 SQLite -> 查询回读校验属性。
约束:只新建本测试文件,只读源码不改源码;不依赖网络/登录。
"""
import asyncio
import contextlib
import types
import pytest
import config
import store.kuaishou as ks
from store.kuaishou import update_kuaishou_video, update_ks_video_comment
from tools.user_hash import anonymize_user_id, mask_nickname
@pytest.fixture(autouse=True)
def _force_nickname_masking(monkeypatch):
"""这一组验的是**脱敏机制本身**,所以强制把它打开。
本仓库的部署配置是关掉的(config.MASK_NICKNAME = False)—— 监控的是一批公开创作者
账号,而脱敏是有损的(「张三」「张四」都成「张*」),分不出谁是谁。机制仍然必须正确,
所以这里显式打开来测。
"""
monkeypatch.setattr(config, "MASK_NICKNAME", True)
# 教学版禁用字段(键):一律不得出现在存储 dict 中。
# 昵称字段 nickname 允许保留,但值须脱敏。
FORBIDDEN_KEYS = {"user_id", "avatar", "signature", "ip_location", "gender"}
# ----------------------------- mock 数据 -----------------------------
MOCK_VIDEO_ID = "3xf8e9kq2b7w4"
MOCK_AUTHOR_ID = "ks_author_001"
MOCK_AUTHOR_NAME = "快手达人"
MOCK_CAPTION = "这是一条测试视频,教学版脱敏回归 #测试"
# 评论作者(两种格式用不同 id/昵称,便于分别断言)
MOCK_COMMENT_V2_AUTHOR_ID = "ks_user_888"
MOCK_COMMENT_V2_AUTHOR_NAME = "快手老铁"
MOCK_COMMENT_LEGACY_AUTHOR_ID = "ks_user_777"
MOCK_COMMENT_LEGACY_AUTHOR_NAME = "快乐源泉"
def make_mock_video() -> dict:
"""贴近真实快手结构的 video_item:author 在顶层,photo 含内容字段。
author.headerUrl 与各禁用字段一样不应进入存储 dict。"""
return {
"type": 1,
"photo": {
"id": MOCK_VIDEO_ID,
"caption": MOCK_CAPTION,
"timestamp": 1700000000000,
"coverUrl": "https://p.kuaishou.com/cover/abc.jpg",
"photoUrl": "https://v.kuaishou.com/play/abc.mp4",
"realLikeCount": 12345,
"viewCount": 67890,
},
"author": {
"id": MOCK_AUTHOR_ID,
"name": MOCK_AUTHOR_NAME,
"headerUrl": "https://p.kuaishou.com/header/u001.jpg",
},
}
def make_mock_comment_v2() -> dict:
"""V2 API 格式:snake_case 字段名,comment_id 为 int。"""
return {
"comment_id": 9001,
"timestamp": 1700000001234,
"content": "太搞笑了哈哈哈",
"author_id": MOCK_COMMENT_V2_AUTHOR_ID,
"author_name": MOCK_COMMENT_V2_AUTHOR_NAME,
"headurl": "https://p.kuaishou.com/header/u888.jpg",
"commentCount": 7,
}
def make_mock_comment_legacy() -> dict:
"""旧 GraphQL API 格式:camelCase 字段名。"""
return {
"commentId": 8001,
"timestamp": 1700000005678,
"content": "这条评论来自旧 GraphQL 接口",
"authorId": MOCK_COMMENT_LEGACY_AUTHOR_ID,
"authorName": MOCK_COMMENT_LEGACY_AUTHOR_NAME,
"headurl": "https://p.kuaishou.com/header/u777.jpg",
"subCommentCount": 3,
}
# ----------------------------- FakeStore 捕获 -----------------------------
@contextlib.contextmanager
def _patch_create_store():
"""把 KuaishouStoreFactory.create_store 替换为返回捕获用 FakeStore 的 staticmethod。
FakeStore 把 store_content/store_comment 收到的 dict 原样写入 holder.content / holder.comment。
保存/还原走类 __dict__ 中的 staticmethod 描述符本身,避免把 staticmethod 退化为普通方法
而污染后续测试。"""
holder = types.SimpleNamespace(content={}, comment={})
class FakeStore:
async def store_content(self, content_item):
holder.content.clear()
holder.content.update(content_item)
async def store_comment(self, comment_item):
holder.comment.clear()
holder.comment.update(comment_item)
async def store_creator(self, creator):
pass
orig = ks.KuaishouStoreFactory.__dict__["create_store"]
ks.KuaishouStoreFactory.create_store = staticmethod(lambda: FakeStore())
try:
yield holder
finally:
ks.KuaishouStoreFactory.create_store = orig
def _assert_no_forbidden_keys(d: dict, label: str):
hit = set(d.keys()) & FORBIDDEN_KEYS
assert not hit, f"[{label}] 输出仍含禁用字段键: {hit}"
# ----------------------------- 测试 -----------------------------
def test_kuaishou_video_masks_user_info():
"""video 链路:原始 user_id 转 creator_hash、昵称脱敏、headerUrl/禁用键不落库。"""
with _patch_create_store() as holder:
asyncio.run(update_kuaishou_video(make_mock_video()))
captured = holder.content
assert captured, "FakeStore 未捕获到 content_item"
_assert_no_forbidden_keys(captured, "kuaishou_video")
# 头像字段不应进入存储 dict(author.headerUrl 已被丢弃)
assert "headerUrl" not in captured and "avatar" not in captured
# creator_hash 存在且不等于原始 user_id
assert captured.get("creator_hash") == anonymize_user_id(MOCK_AUTHOR_ID)
assert captured["creator_hash"] != MOCK_AUTHOR_ID
assert captured["creator_hash"] # 非空
# 昵称已脱敏:等于 mask_nickname(原文) 且不等于原文
assert captured.get("nickname") == mask_nickname(MOCK_AUTHOR_NAME)
assert captured["nickname"] != MOCK_AUTHOR_NAME
assert "*" in captured["nickname"]
# 内容字段保留(video_id / desc / title)
assert captured["video_id"] == MOCK_VIDEO_ID
assert captured["desc"] == MOCK_CAPTION
assert captured["title"] == MOCK_CAPTION
# video_type 来自 video_item.type,被 str() 化
assert captured["video_type"] == "1"
# 计数字段被 str() 化
assert captured["liked_count"] == "12345"
assert captured["viewd_count"] == "67890"
def test_kuaishou_comment_v2_masks_user_info():
"""评论 V2(snake_case)格式:author_id/author_name 经匿名+脱敏,headurl 不落库。"""
with _patch_create_store() as holder:
asyncio.run(update_ks_video_comment(MOCK_VIDEO_ID, make_mock_comment_v2()))
captured = holder.comment
assert captured, "FakeStore 未捕获到 comment_item"
_assert_no_forbidden_keys(captured, "kuaishou_comment_v2")
assert "headurl" not in captured and "avatar" not in captured
# comment_id 由 int 转为 str
assert captured["comment_id"] == "9001"
assert captured["video_id"] == MOCK_VIDEO_ID
assert captured["content"] == "太搞笑了哈哈哈"
# V2 用 commentCount
assert captured["sub_comment_count"] == "7"
# creator_hash / 昵称
assert captured["creator_hash"] == anonymize_user_id(MOCK_COMMENT_V2_AUTHOR_ID)
assert captured["creator_hash"] != MOCK_COMMENT_V2_AUTHOR_ID
assert captured["nickname"] == mask_nickname(MOCK_COMMENT_V2_AUTHOR_NAME)
assert captured["nickname"] != MOCK_COMMENT_V2_AUTHOR_NAME
assert "*" in captured["nickname"]
def test_kuaishou_comment_legacy_masks_user_info():
"""评论旧 GraphQL(camelCase)格式:authorId/authorName 经匿名+脱敏,headurl 不落库。"""
with _patch_create_store() as holder:
asyncio.run(update_ks_video_comment(MOCK_VIDEO_ID, make_mock_comment_legacy()))
captured = holder.comment
assert captured, "FakeStore 未捕获到 comment_item"
_assert_no_forbidden_keys(captured, "kuaishou_comment_legacy")
assert "headurl" not in captured and "avatar" not in captured
# commentId 由 int 转为 str
assert captured["comment_id"] == "8001"
assert captured["video_id"] == MOCK_VIDEO_ID
assert captured["content"] == "这条评论来自旧 GraphQL 接口"
# 旧格式用 subCommentCount
assert captured["sub_comment_count"] == "3"
# creator_hash / 昵称
assert captured["creator_hash"] == anonymize_user_id(MOCK_COMMENT_LEGACY_AUTHOR_ID)
assert captured["creator_hash"] != MOCK_COMMENT_LEGACY_AUTHOR_ID
assert captured["nickname"] == mask_nickname(MOCK_COMMENT_LEGACY_AUTHOR_NAME)
assert captured["nickname"] != MOCK_COMMENT_LEGACY_AUTHOR_NAME
assert "*" in captured["nickname"]
def test_kuaishou_store_end_to_end_sqlite():
"""端到端:FakeStore 捕获真实 dict -> KuaishouVideo(**captured) ORM 列校验
-> 写入内存 SQLite -> 查询回读,验证属性正确且无禁用列。
全程在同一个事件循环内完成(aiosqlite 连接绑定事件循环,跨 loop 会报错),
不 patch db_session.get_session,而是直接用自建内存 engine 走 ORM 写入/查询。"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from database.models import Base, KuaishouVideo, KuaishouVideoComment
async def run():
# 内存 SQLite + StaticPool:单连接共享,保证 create_all 与后续读写同一库
engine = create_async_engine("sqlite+aiosqlite://", poolclass=StaticPool)
SessionFactory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with engine.begin() as conn:
await conn.run_sync(
Base.metadata.create_all,
tables=[KuaishouVideo.__table__, KuaishouVideoComment.__table__],
)
# 1) 经 update_kuaishou_video 产生真实存储 dict(FakeStore 捕获)
with _patch_create_store() as holder:
await update_kuaishou_video(make_mock_video())
captured = holder.content
assert captured, "FakeStore 未捕获到 content_item"
# 2) ORM 列校验:captured 的所有 key 必须是 KuaishouVideo 的合法列,
# 否则 KuaishouVideo(**captured) 抛 TypeError(若有禁用/多余键即暴露源码 bug)
valid_cols = {c.name for c in KuaishouVideo.__table__.columns}
assert set(captured.keys()) <= valid_cols, (
f"captured 含非合法列: {set(captured.keys()) - valid_cols}"
)
obj = KuaishouVideo(**captured) # 不抛异常即通过列校验
assert obj.video_id == MOCK_VIDEO_ID
assert obj.creator_hash == anonymize_user_id(MOCK_AUTHOR_ID)
assert obj.creator_hash != MOCK_AUTHOR_ID
assert obj.nickname == mask_nickname(MOCK_AUTHOR_NAME)
assert obj.nickname != MOCK_AUTHOR_NAME
assert obj.desc == MOCK_CAPTION # 内容保留
# 3) 真实写库 + 查询回读
async with SessionFactory() as session:
session.add(obj)
await session.commit()
res = await session.execute(
select(KuaishouVideo).where(KuaishouVideo.video_id == MOCK_VIDEO_ID)
)
row = res.scalar_one()
assert row is not None
assert row.video_id == MOCK_VIDEO_ID
assert row.creator_hash == anonymize_user_id(MOCK_AUTHOR_ID)
assert row.creator_hash != MOCK_AUTHOR_ID
assert row.nickname == mask_nickname(MOCK_AUTHOR_NAME)
assert row.nickname != MOCK_AUTHOR_NAME
# 内容字段保留
assert row.desc == MOCK_CAPTION
assert row.title == MOCK_CAPTION
# ORM 行对象上不应存在任何禁用列属性
for bad in FORBIDDEN_KEYS:
assert not hasattr(row, bad), f"KuaishouVideo 行对象仍含禁用属性: {bad}"
await engine.dispose()
asyncio.run(run())
if __name__ == "__main__":
pytest.main([__file__, "-v"])