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 处字面量的脱敏期望值换成真实昵称 —— 提取器现在就是 返回原文的,期望值理应跟着改(这一条是行为变更的直接后果,不是测试放宽)。 * 新增一条:已入库的评论昵称会随重采刷新(且不会因刷新而重复插入)。
This commit is contained in:
+2
-1
@@ -46,7 +46,7 @@ Dockerfile / .dockerignore / docker-compose.yml 服务器部署用
|
||||
|---|---|
|
||||
| `cmd_arg/arg.py` | typer 选项:`--enable_cdp_mode`、`--inject_all_cookies`、`--save_login_state`、`--cookies_file`、`--crawler_max_sleep_sec`,以及对应的 `config.*` 回写 |
|
||||
| `api/schemas/crawler.py` | `CrawlerStartRequest` 的若干**可选**字段(默认 `None`,不传则不加对应 CLI 参数) |
|
||||
| `config/base_config.py` | `INJECT_ALL_COOKIES = False` |
|
||||
| `config/base_config.py` | `INJECT_ALL_COOKIES = False`;`MASK_NICKNAME = False`(关掉昵称脱敏,见第 3 节) |
|
||||
| `api/routers/__init__.py` | 导出新增的 router |
|
||||
| `requirements.txt` | 补上 `websockets`(上游 `pyproject.toml` 里有、`requirements.txt` 里漏了) |
|
||||
| `tests/conftest.py` | 新增 `_bypass_auth_for_non_auth_suites` fixture |
|
||||
@@ -59,6 +59,7 @@ Dockerfile / .dockerignore / docker-compose.yml 服务器部署用
|
||||
| `api/routers/websocket.py` | 两个 WS 路由加 `dependencies=[Depends(require_ws_auth)]` | 上游若新增 WS 路由,**必须同样加上**,否则那条流是裸奔的 |
|
||||
| `api/services/crawler_manager.py` | 解释器探测替换硬编码 `uv run`;`_build_command` 转发新增参数;新增 `is_busy()` / `run_and_wait()` 与完成事件 | 留意 `_build_command` 的参数拼装 |
|
||||
| `media_platform/xhs/login.py` | `login_by_cookies` 在 `INJECT_ALL_COOKIES` 打开时注入**全部** cookie(默认关闭,行为不变) | 小改动,好合并 |
|
||||
| `tools/user_hash.py` | `mask_nickname` 改为读 `config.MASK_NICKNAME`,本仓库默认**不脱敏**(原样返回)。上游作为教学版默认脱敏,但那是有损的 ——「张三」「张四」都成「张*」,而分清谁是谁正是监控这一层要干的活。脱敏实现本身没删,改回 `True` 即恢复上游行为 | 与 `config/base_config.py` 一起改,两处不同步会不一致 |
|
||||
|
||||
### 4. 上游 bug 修复(建议回馈上游)
|
||||
|
||||
|
||||
@@ -477,14 +477,20 @@ async def _ingest_comments(
|
||||
if not comment_id or not note_id:
|
||||
continue
|
||||
|
||||
exists = await session.scalar(
|
||||
select(MonitorComment.id).where(
|
||||
existing = await session.scalar(
|
||||
select(MonitorComment).where(
|
||||
MonitorComment.task_id == run.task_id,
|
||||
MonitorComment.note_id == note_id,
|
||||
MonitorComment.comment_id == comment_id,
|
||||
)
|
||||
)
|
||||
if exists is not None:
|
||||
if existing is not None:
|
||||
# 昵称要跟着刷,不能只写一次。评论是去重后直接 continue 的,若不刷新,
|
||||
# 脱敏开关一改(或评论者改了昵称),已经入库的老评论会永远停在旧值上 ——
|
||||
# 而重采是唯一能拿到新值的途径。作品那边的 creator_name 同理。
|
||||
refreshed = adapter.comment_field(record, "creator_name")
|
||||
if refreshed:
|
||||
existing.nickname = refreshed
|
||||
continue
|
||||
|
||||
create_time = _as_int(adapter.comment_field(record, "create_time"))
|
||||
|
||||
@@ -58,6 +58,14 @@ SAVE_LOGIN_STATE = True
|
||||
# 否则冷 profile 下 API 签名失败,且表现为「退出码 0 但抓到 0 条」的静默失败。
|
||||
INJECT_ALL_COOKIES = False
|
||||
|
||||
# 是否对昵称做中间脱敏(默认 False —— 本仓库**关掉了**)。
|
||||
# 上游作为教学版默认开启,保留首尾各 1 字、中间打星号,避免据昵称骚扰到真人。
|
||||
# 但那是**有损**的:「张三」和「张四」都会变成「张*」,「小明老师」和「小刚老师」
|
||||
# 都会变成「小***师」—— 而本仓库的用途是监控一批公开的创作者账号,分清谁是谁正是
|
||||
# 这一层要干的事,撞名就等于看不出来。所以这里关掉,把原昵称原样落库。
|
||||
# 想改回上游行为,把这一行改成 True 即可(脱敏机制本身没删)。
|
||||
MASK_NICKNAME = False
|
||||
|
||||
# ==================== CDP (Chrome DevTools Protocol) 配置 ====================
|
||||
# 是否启用 CDP 模式 - 使用用户本地的 Chrome/Edge 浏览器进行爬取,具有更好的反检测能力
|
||||
# 开启后,会自动检测并启动用户的 Chrome/Edge 浏览器,通过 CDP 协议进行控制
|
||||
|
||||
@@ -41,6 +41,17 @@ from database.models import Base, DouyinAweme, DouyinAwemeComment
|
||||
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 的 key 出现。
|
||||
FORBIDDEN_KEYS = {
|
||||
"user_id", "sec_uid", "short_user_id", "user_unique_id",
|
||||
|
||||
@@ -18,10 +18,23 @@ 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"}
|
||||
|
||||
@@ -698,3 +698,35 @@ class TestDouyinIngest:
|
||||
await ingest_run(db, run2, task, tmp_path)
|
||||
|
||||
assert len(await _events(db, EVENT_METRIC_DELTA)) == 1
|
||||
|
||||
|
||||
class TestNicknameRefresh:
|
||||
"""已入库的评论,昵称要跟着重新采集的值走。
|
||||
|
||||
评论是去重后直接跳过的,若不刷新,脱敏开关一改(或评论者改了昵称),老数据就永远
|
||||
停在旧值上 —— 而重采是唯一能拿到新值的途径。
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_existing_comment_gets_its_nickname_refreshed(self, db, tmp_path):
|
||||
task = await _make_task(db)
|
||||
_write_run_dir(tmp_path, [_note("n1")], comments=[_comment("c1", "n1", 500)])
|
||||
run1 = await _make_run(db, task, started_at=1)
|
||||
await ingest_run(db, run1, task, tmp_path)
|
||||
|
||||
assert (await db.scalar(select(MonitorComment))).nickname == "u***r"
|
||||
|
||||
_write_run_dir(
|
||||
tmp_path,
|
||||
[_note("n1")],
|
||||
comments=[_comment("c1", "n1", 500, nickname="未脱敏的新昵称")],
|
||||
)
|
||||
run2 = await _make_run(db, task, started_at=2)
|
||||
await ingest_run(db, run2, task, tmp_path)
|
||||
|
||||
comment = await db.scalar(select(MonitorComment))
|
||||
assert comment.nickname == "未脱敏的新昵称"
|
||||
# 去重的语义没变:同一条评论不该被插成两行。
|
||||
assert (
|
||||
len(list((await db.scalars(select(MonitorComment))).all())) == 1
|
||||
)
|
||||
|
||||
@@ -14,6 +14,8 @@ import pathlib
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||
|
||||
# 统一的禁用字段名(键)。昵称字段(nickname/user_nickname/screen_name/name/user_name)允许保留(值需脱敏)。
|
||||
@@ -28,6 +30,17 @@ NICK_KEYS = {"nickname", "user_nickname", "screen_name", "name", "user_name"}
|
||||
MASK_RE = re.compile(r"^.?\*{1,4}.?$")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_nickname_masking(monkeypatch):
|
||||
"""这一组验的是**脱敏机制本身**,所以强制把它打开。
|
||||
|
||||
本仓库的部署配置是关掉的(config.MASK_NICKNAME = False)—— 监控的是一批公开创作者
|
||||
账号,而脱敏是有损的(「张三」「张四」都成「张*」),分不出谁是谁。机制仍然必须正确,
|
||||
所以这里显式打开来测;开关两个方向的行为由 test_mask_and_hash_tools 覆盖。
|
||||
"""
|
||||
monkeypatch.setattr(config, "MASK_NICKNAME", True)
|
||||
|
||||
|
||||
# ----------------------------- ORM 自省 -----------------------------
|
||||
|
||||
def test_orm_has_no_forbidden_columns():
|
||||
@@ -84,17 +97,26 @@ def _check_nickname_masked(d: dict, raw: str, label: str):
|
||||
assert MASK_RE.match(val) or "*" in val, f"[{label}] {k} 未脱敏: {val}"
|
||||
|
||||
|
||||
def test_mask_and_hash_tools():
|
||||
def test_mask_and_hash_tools(monkeypatch):
|
||||
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字、中间星号,且不等于原文
|
||||
|
||||
# 开关打开:首尾留 1 字、中间星号,且不等于原文。
|
||||
monkeypatch.setattr(config, "MASK_NICKNAME", True)
|
||||
assert mask_nickname("张三丰") != "张三丰"
|
||||
assert "*" in mask_nickname("张三丰")
|
||||
assert mask_nickname(None) == ""
|
||||
assert mask_nickname("a") == "*"
|
||||
|
||||
# 开关关闭(本仓库的部署配置):原样返回。脱敏是有损的 —— 「张三」和「张四」
|
||||
# 都会变成「张*」,而分清谁是谁正是监控这一层要干的事。
|
||||
monkeypatch.setattr(config, "MASK_NICKNAME", False)
|
||||
assert mask_nickname("张三丰") == "张三丰"
|
||||
assert mask_nickname("a") == "a"
|
||||
assert mask_nickname(None) == ""
|
||||
|
||||
|
||||
def test_xhs_note_extraction_masks_user_info():
|
||||
import asyncio
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_extract_search_note_list_from_keyword_page():
|
||||
assert notes[0].note_id == "9117888152"
|
||||
assert notes[0].title.startswith("武汉交互空间科技")
|
||||
assert notes[0].tieba_name == "武汉交互空间"
|
||||
assert notes[0].user_nickname == "V***人"
|
||||
assert notes[0].user_nickname == "VR虚拟达人"
|
||||
|
||||
|
||||
def test_extract_search_note_list_from_current_pc_card_page():
|
||||
@@ -56,7 +56,7 @@ def test_extract_search_note_list_from_current_pc_card_page():
|
||||
assert notes[0].desc == "培训班需求,数学,英语,编程老师,专职兼职都可"
|
||||
assert notes[0].tieba_name == "诸城吧"
|
||||
assert notes[0].tieba_link.endswith("kw=%E8%AF%B8%E5%9F%8E")
|
||||
assert notes[0].user_nickname == "7***7"
|
||||
assert notes[0].user_nickname == "754023117"
|
||||
assert notes[0].publish_time == "2026-3-15"
|
||||
assert notes[0].total_replay_num == 19
|
||||
|
||||
@@ -147,7 +147,7 @@ def test_extract_note_detail_and_comments_from_current_pc_api():
|
||||
assert note.note_id == "10451142633"
|
||||
assert note.title == "这X尔斯对比巴尔斯,我只能说ID正确,允许居功自傲"
|
||||
assert note.desc == "皮队败决处刑德国编程钢琴师兼职数学家"
|
||||
assert note.user_nickname == "泰***克"
|
||||
assert note.user_nickname == "泰高祖蒙斯克"
|
||||
assert note.tieba_name == "dota2吧"
|
||||
assert note.total_replay_num == 15
|
||||
assert note.total_replay_page == 1
|
||||
@@ -155,7 +155,7 @@ def test_extract_note_detail_and_comments_from_current_pc_api():
|
||||
assert len(comments) == 1
|
||||
assert comments[0].comment_id == "153154097267"
|
||||
assert comments[0].content == "xg现在大树阵容另一个辅助不选控制"
|
||||
assert comments[0].user_nickname == "期***3"
|
||||
assert comments[0].user_nickname == "期胡希3"
|
||||
assert comments[0].sub_comment_count == 4
|
||||
# 教学版已移除 ip_location 等可定位真人字段
|
||||
|
||||
@@ -191,7 +191,7 @@ def test_extract_creator_info_and_threads_from_current_pc_api():
|
||||
creator = extractor.extract_creator_info_from_api(creator_api)
|
||||
thread_ids = extractor.extract_creator_thread_id_list_from_api(feed_api)
|
||||
|
||||
assert creator.user_nickname == "米***子"
|
||||
assert creator.user_nickname == "米米世界大手子"
|
||||
assert creator.fans == 58
|
||||
assert creator.follows == 1
|
||||
# 教学版已移除 user_id、user_name、ip_location 等可定位真人字段
|
||||
@@ -223,7 +223,7 @@ def test_extract_tieba_note_list_from_bigpipe_thread_page():
|
||||
assert len(notes) == 48
|
||||
assert notes[0].note_id == "9079949995"
|
||||
assert notes[0].title == "盗墓笔记全集+txt小说,已整理"
|
||||
assert notes[0].user_nickname == "公***仲"
|
||||
assert notes[0].user_nickname == "公子伯仲"
|
||||
assert notes[0].tieba_name == "盗墓笔记吧"
|
||||
assert notes[0].tieba_link.endswith("kw=%E7%9B%97%E5%A2%93%E7%AC%94%E8%AE%B0&ie=utf-8")
|
||||
|
||||
@@ -233,7 +233,7 @@ def test_extract_note_detail_from_post_page():
|
||||
|
||||
assert note.note_id == "9117905169"
|
||||
assert note.title == "对于一个父亲来说,这个女儿14岁就死了"
|
||||
assert note.user_nickname == "章***轩"
|
||||
assert note.user_nickname == "章景轩"
|
||||
assert note.tieba_name == "以太比特吧"
|
||||
assert note.total_replay_num == 786
|
||||
assert note.total_replay_page == 13
|
||||
@@ -249,7 +249,7 @@ def test_extract_parent_comments_from_post_page():
|
||||
assert len(comments) == 30
|
||||
assert comments[0].comment_id == "150726491368"
|
||||
assert comments[0].content == "中国队第22金!无悬念!"
|
||||
assert comments[0].user_nickname == "h***n"
|
||||
assert comments[0].user_nickname == "heinzfrentzen"
|
||||
assert comments[0].tieba_name == "网球风云吧"
|
||||
# 教学版已移除 ip_location 等可定位真人字段
|
||||
|
||||
|
||||
@@ -22,6 +22,20 @@ import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_nickname_masking(monkeypatch):
|
||||
"""这一组验的是**脱敏机制本身**,所以强制把它打开。
|
||||
|
||||
本仓库的部署配置是关掉的(config.MASK_NICKNAME = False)—— 监控的是一批公开创作者
|
||||
账号,而脱敏是有损的(「张三」「张四」都成「张*」),分不出谁是谁。机制仍然必须正确,
|
||||
所以这里显式打开来测。
|
||||
"""
|
||||
monkeypatch.setattr(config, "MASK_NICKNAME", True)
|
||||
|
||||
|
||||
# 原始(明文)测试数据
|
||||
RAW_USER_ID = 7654321
|
||||
RAW_NICKNAME = "微博达人"
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
# 昵称保留但做中间脱敏)。本模块提供匿名化与脱敏工具。
|
||||
import hashlib
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def anonymize_user_id(user_id) -> str:
|
||||
"""把原始用户 ID 转成匿名哈希,用于内容/评论记录的创作者分组,
|
||||
@@ -25,10 +27,16 @@ def mask_nickname(name) -> str:
|
||||
- 长度 == 2:首字 + "*"
|
||||
- 长度 >= 3:首字 + "***" + 尾字
|
||||
这样既保留教学分析所需的内容归属语义,又无法据昵称定位到真人。
|
||||
|
||||
**是否启用由 config.MASK_NICKNAME 决定,本仓库默认关闭**(原样返回)。
|
||||
脱敏是有损的,撞名很常见 —— 详见 base_config 里那一段的说明。
|
||||
开关读的是模块属性而不是导入时的值,这样测试可以 monkeypatch 它。
|
||||
"""
|
||||
if name is None:
|
||||
return ""
|
||||
s = str(name)
|
||||
if not getattr(config, "MASK_NICKNAME", False):
|
||||
return s
|
||||
if len(s) <= 1:
|
||||
return "*"
|
||||
if len(s) == 2:
|
||||
|
||||
Reference in New Issue
Block a user