From cd85587f00085be8441d33f2bf06f5866ed82b37 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sat, 10 Oct 2026 15:48:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(privacy):=20=E5=85=B3=E6=8E=89=E6=98=B5?= =?UTF-8?q?=E7=A7=B0=E8=84=B1=E6=95=8F=20=E2=80=94=E2=80=94=20=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E6=9C=89=E6=8D=9F=EF=BC=8C=E6=92=9E=E5=90=8D=E5=B0=B1?= =?UTF-8?q?=E5=88=86=E4=B8=8D=E5=87=BA=E5=8D=9A=E4=B8=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求:评论栏/作品栏要能分清是哪个博主。修好分组字段之后名字仍带星号,因为上游作为 教学版默认对昵称做中间脱敏(首尾各留 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 处字面量的脱敏期望值换成真实昵称 —— 提取器现在就是 返回原文的,期望值理应跟着改(这一条是行为变更的直接后果,不是测试放宽)。 * 新增一条:已入库的评论昵称会随重采刷新(且不会因刷新而重复插入)。 --- UPSTREAM.md | 3 ++- api/monitor/ingest.py | 12 ++++++++--- config/base_config.py | 8 ++++++++ tests/test_douyin_no_user_info.py | 11 ++++++++++ tests/test_kuaishou_no_user_info.py | 13 ++++++++++++ tests/test_monitor_ingest.py | 32 +++++++++++++++++++++++++++++ tests/test_no_user_info.py | 26 +++++++++++++++++++++-- tests/test_tieba_extractor.py | 16 +++++++-------- tests/test_weibo_no_user_info.py | 14 +++++++++++++ tools/user_hash.py | 8 ++++++++ 10 files changed, 129 insertions(+), 14 deletions(-) diff --git a/UPSTREAM.md b/UPSTREAM.md index d1064a2..e5b8780 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -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 修复(建议回馈上游) diff --git a/api/monitor/ingest.py b/api/monitor/ingest.py index 5a605e7..91db70f 100644 --- a/api/monitor/ingest.py +++ b/api/monitor/ingest.py @@ -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")) diff --git a/config/base_config.py b/config/base_config.py index 0c8c06c..681e067 100644 --- a/config/base_config.py +++ b/config/base_config.py @@ -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 协议进行控制 diff --git a/tests/test_douyin_no_user_info.py b/tests/test_douyin_no_user_info.py index 33ef54b..f26a6e9 100644 --- a/tests/test_douyin_no_user_info.py +++ b/tests/test_douyin_no_user_info.py @@ -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", diff --git a/tests/test_kuaishou_no_user_info.py b/tests/test_kuaishou_no_user_info.py index 9061251..11d7022 100644 --- a/tests/test_kuaishou_no_user_info.py +++ b/tests/test_kuaishou_no_user_info.py @@ -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"} diff --git a/tests/test_monitor_ingest.py b/tests/test_monitor_ingest.py index 5bd618c..52ef451 100644 --- a/tests/test_monitor_ingest.py +++ b/tests/test_monitor_ingest.py @@ -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 + ) diff --git a/tests/test_no_user_info.py b/tests/test_no_user_info.py index 4314a74..89e68a6 100644 --- a/tests/test_no_user_info.py +++ b/tests/test_no_user_info.py @@ -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 diff --git a/tests/test_tieba_extractor.py b/tests/test_tieba_extractor.py index fbc69c2..2e36447 100644 --- a/tests/test_tieba_extractor.py +++ b/tests/test_tieba_extractor.py @@ -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 等可定位真人字段 diff --git a/tests/test_weibo_no_user_info.py b/tests/test_weibo_no_user_info.py index ff5bdaa..67cd946 100644 --- a/tests/test_weibo_no_user_info.py +++ b/tests/test_weibo_no_user_info.py @@ -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 = "微博达人" diff --git a/tools/user_hash.py b/tools/user_hash.py index 89b314b..747f01a 100644 --- a/tools/user_hash.py +++ b/tools/user_hash.py @@ -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: