From 6aba3a73c1470680def2bdffb23d77b2d5c68122 Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Mon, 12 Oct 2026 16:54:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(monitor):=20=E5=85=B3=E6=8E=89=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E6=A0=A1=E9=AA=8C=20=E2=80=94=E2=80=94=20=E8=AE=A4?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E6=9D=A5=E7=9A=84=E7=9B=AE=E6=A0=87=E5=8E=9F?= =?UTF-8?q?=E6=A0=B7=E6=94=B6=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前新建任务粘目标时是白名单校验:链接形状不匹配平台的规则就拒收(「无法从链接中 解析出博主主页 ID」/「无法识别的目标」),而 v.douyin.com 那种分享短链是**明确** 拒绝的。平台一改链接形状,用户粘一个完全正确的链接也只能干瞪眼。 现在认不出来的一律原样存下(截到 128 字符,external_id 是 varchar(128),而带参数的 完整链接常常 130+)。只留两件不能省的事: * 空值仍然拒收 —— 空目标连库都写不进去; * **不许跨平台误抠** —— 小红书的正则不会从抖音链接里抠出一个 id。那是「看起来正常 但完全错误」的目标,比原样存更糟。这条有测试钉着。 代价(已知并接受):认不出的值会变成永远抓不到东西的目标。好在不静默 —— 跑完一轮 ingest 会发现「一条都没抓到」并报出来,而且会先排除登录问题。 顺带删掉 short_link_hosts:现在没人读它了,留着会让下一个人以为短链还是被拒的。 --- api/monitor/adapters.py | 5 --- api/monitor/service.py | 42 ++++++++++++------ tests/test_monitor_api.py | 91 +++++++++++++++++++++++++++------------ 3 files changed, 92 insertions(+), 46 deletions(-) diff --git a/api/monitor/adapters.py b/api/monitor/adapters.py index e6659f0..0f9ee29 100644 --- a/api/monitor/adapters.py +++ b/api/monitor/adapters.py @@ -82,9 +82,6 @@ class PlatformAdapter: # 也允许直接粘贴裸 id —— 但两边的 id 形状不同,所以分开。 creator_bare_re: Pattern note_bare_re: Pattern - # 短链(v.douyin.com 这种)无法在不发请求的情况下还原出 id,解析时单独报错, - # 好过存一个聚不出目标的值进去。 - short_link_hosts: Tuple[str, ...] note_fields: Mapping[str, str] comment_fields: Mapping[str, str] @@ -155,7 +152,6 @@ XHS = PlatformAdapter( ), creator_bare_re=_XHS_BARE_RE, note_bare_re=_XHS_BARE_RE, - short_link_hosts=(), note_fields={ "note_id": "note_id", "title": "title", @@ -204,7 +200,6 @@ DY = PlatformAdapter( # 又不会把 19 位的作品号误当成博主。 creator_bare_re=re.compile(r"^[A-Za-z0-9_-]{20,128}$"), note_bare_re=re.compile(r"^\d{8,25}$"), - short_link_hosts=("v.douyin.com",), note_fields={ "note_id": "aweme_id", "title": "title", diff --git a/api/monitor/service.py b/api/monitor/service.py index c395aa4..3acf0b5 100644 --- a/api/monitor/service.py +++ b/api/monitor/service.py @@ -96,9 +96,20 @@ def parse_target_input( Storing the id separately from the token is what keeps a long-running task alive: tokens expire, ids do not. - URL shapes are platform-specific and come from ``adapters``. A platform with - no adapter is rejected here as well as at task creation -- parsing a Douyin - link as if it were a Xiaohongshu one would be worse than refusing it. + **认不出来的一律原样存下,不报错。**(2026-10-12 按用户要求关掉了链路校验。) + 以前这里是白名单:链接不匹配平台的形状就拒收。问题是平台一改链接形状,用户粘一个 + 完全正确的链接也只能干瞪眼 —— 而拒收换来的那点保护,代价是「任务建不出来」。 + + 现在只保留两件不能省的事: + + * **空值仍然拒收** —— 空目标连库都写不进去; + * **不许跨平台误抠** —— 小红书的正则不会从抖音链接里抠出一个 id 来。那会造出一个 + **看起来正常但完全错误**的目标,比原样存下来糟得多。 + + 平台没有适配器仍然拒收:那不是校验链接,是「这个平台我们根本采不了」。 + + 代价(已知并接受):认不出的值会变成一个永远抓不到东西的目标。好在那不会静默 —— + 跑完一轮 ingest 就会发现「一条都没抓到」并报出来。 """ if not adapters.has_adapter(platform): raise TargetParseError(f"暂不支持解析该平台({platform})的目标链接") @@ -109,26 +120,29 @@ def parse_target_input( raise TargetParseError("Empty target") creator_mode = mode == MODE_CREATOR - expected = "博主主页" if creator_mode else "笔记" external_id = "" if raw.startswith("http") or "/" in raw: - if any(host in raw for host in spec.short_link_hosts): - # 短链要联网跳一次才知道指向谁,而这里没有网络可跳。明确拒绝好过存一个 - # 解析不出 id 的值 —— 那会变成一个永远抓不到东西、还不报错的任务。 - raise TargetParseError(f"{expected}短链无法解析,请粘贴完整链接:{raw}") patterns = spec.creator_url_res if creator_mode else spec.note_url_res for pattern in patterns: match = pattern.search(raw) if match: external_id = match.group(1) break - if not external_id: - raise TargetParseError(f"无法从链接中解析出{expected} ID:{raw}") - elif (spec.creator_bare_re if creator_mode else spec.note_bare_re).match(raw): - external_id = raw - else: - raise TargetParseError(f"无法识别的目标:{raw}") + + # **认不出来就原样存,不拦。**(2026-10-12 按用户要求关掉校验。) + # + # 代价必须写在明处:`external_id` 是采集时**唯一**拿去请求的东西,所以一个认不出的 + # 值会变成一个「永远抓不到东西」的目标。好在不会静默 —— 那一轮跑完 ingest 会发现 + # 「一条都没抓到」并报出来,而且会先排除登录问题(其他任务正常时提示检查这个目标)。 + # + # 之所以能接受:解析规则是**平台相关的白名单**(比如抖音博主链接必须含 + # `douyin.com/user/`,`v.douyin.com` 那种分享短链现在也放行),平台一改形状就会把 + # 正常链接拦在外面 —— 而拦在门外时用户只能干瞪眼,放进来至少还有得查。 + if not external_id: + # `external_id` 是 varchar(128),而带参数的完整链接常常就 130+ 字符 + # (抖音 sec_uid 本身 66 位)。不截断的话这一条会直接写库失败。 + external_id = raw[:128] params = parse_qs(urlparse(raw).query) if raw.startswith("http") else {} return { diff --git a/tests/test_monitor_api.py b/tests/test_monitor_api.py index 09568f3..0219b0c 100644 --- a/tests/test_monitor_api.py +++ b/tests/test_monitor_api.py @@ -70,13 +70,33 @@ class TestParseTargetInput: assert parsed["external_id"] == "6aa3d827000000002802c5c8" assert parsed["xsec_token"] == "" - def test_creator_url_rejected_in_note_mode(self): - with pytest.raises(TargetParseError): - parse_target_input(CREATOR_URL, "note") + def test_a_creator_url_in_note_mode_falls_through_to_the_raw_value(self): + """粘错类型的链接**不再被拦**(2026-10-12 关掉了校验)。 - def test_garbage_is_rejected(self): + 代价是明摆着的:以前它会告诉你「这是博主链接,但任务要的是笔记」,现在它照单 + 全收,存进去的是一个抓不到东西的 id。那个代价落在采集那一轮 —— ingest 会发现 + 「一条都没抓到」并报出来,而不是静默通过。 + """ + parsed = parse_target_input(CREATOR_URL, "note") + assert parsed["external_id"] == CREATOR_URL[:128] + + def test_garbage_is_stored_as_is(self): + parsed = parse_target_input("not a url at all !!", "creator") + assert parsed["external_id"] == "not a url at all !!" + + def test_empty_input_is_still_rejected(self): + """「关掉校验」放的是**认不出来的链接**,不是「什么都没粘」。空目标连库都写不进去。""" with pytest.raises(TargetParseError): - parse_target_input("not a url at all !!", "creator") + parse_target_input(" ", "creator") + + def test_a_too_long_value_is_truncated_to_fit_the_column(self): + """`external_id` 是 varchar(128),而带参数的完整链接常常就 130+ 字符 + (抖音的 sec_uid 本身 66 位)。不截断的话这一条会直接写库失败。""" + long_url = "https://www.douyin.com/root/search/python?aid=b733a3b0&" + "x" * 200 + + parsed = parse_target_input(long_url, "note", "dy") + + assert len(parsed["external_id"]) == 128 # --- 抖音 ------------------------------------------------------------- # 链接形态由平台决定,所以每一个都要显式带上 "dy"。 @@ -116,21 +136,16 @@ class TestParseTargetInput: assert parsed["external_id"] == sec_uid def test_douyin_bare_sec_uid_beyond_the_xhs_length_cap(self): - """裸 id 的长度上限必须按平台分开。 + """长 sec_uid 两个平台都收得下 —— 身份校验已经关掉了。 - 小红书那条规则封顶 64 字符,而 sec_user_id 长过 64 是常态(实测样本 55, - 但字段本身是变长的)。共用一条规则的话,长一点的 sec_uid 会被直接拒掉 —— - 对用户来说就是「粘贴了一个完全正确的链接却报无法识别」。 + 以前小红书那条规则封顶 64 字符,而 sec_user_id 长过 64 是常态,于是「粘贴了一个 + 完全正确的链接却报无法识别」。现在两个平台都原样收下。 """ sec_uid = "MS4wLjABAAAA" + "aB3dEf6hIj9lMn2pQr5tUv8xYz1" * 3 assert len(sec_uid) > 64 - parsed = parse_target_input(sec_uid, "creator", "dy") - assert parsed["external_id"] == sec_uid - - # 同一条 id 拿小红书规则来解析会被拒 —— 这正是两条规则必须分开的原因。 - with pytest.raises(TargetParseError): - parse_target_input(sec_uid, "creator", "xhs") + assert parse_target_input(sec_uid, "creator", "dy")["external_id"] == sec_uid + assert parse_target_input(sec_uid, "creator", "xhs")["external_id"] == sec_uid def test_douyin_bare_video_id(self): parsed = parse_target_input("7525082444551310602", "note", "dy") @@ -138,21 +153,27 @@ class TestParseTargetInput: # 抖音不需要 xsec_token —— 和小红书不同,裸链接就能用。 assert parsed["xsec_token"] == "" - def test_douyin_short_link_is_rejected_with_a_reason(self): - """短链要联网跳一次才知道指向谁。明确拒绝好过存一个永远抓不到东西的目标。""" - with pytest.raises(TargetParseError) as excinfo: - parse_target_input("https://v.douyin.com/drIPtQ_WPWY/", "note", "dy") - assert "短链" in str(excinfo.value) + def test_douyin_short_link_is_accepted_but_will_not_fetch(self): + """分享短链现在放行,但存进去的是短码本身 —— 那个目标抓不到东西。 + + 以前这里是明确拒绝的(「短链要联网跳一次才知道指向谁」)。关掉校验之后不拦了, + 代价转移到了采集那一轮。要真正支持短链得联网跟一次跳转解出 sec_uid,那是另一件事。 + """ + parsed = parse_target_input("https://v.douyin.com/drIPtQ_WPWY/", "note", "dy") + + assert parsed["external_id"] == "https://v.douyin.com/drIPtQ_WPWY/" def test_a_douyin_link_is_not_parsed_with_xhs_rules(self): - with pytest.raises(TargetParseError): - parse_target_input( - "https://www.douyin.com/video/7525082444551310602", "note", "xhs" - ) + """**这条仍然重要**:关掉的是「拦截」,不是「辨别」。 + + 小红书的正则不该从抖音链接里抠出一个 id 来 —— 那会是一个**看起来正常但完全 + 错误**的目标,比原样存下来糟得多。 + """ + url = "https://www.douyin.com/video/7525082444551310602" + assert parse_target_input(url, "note", "xhs")["external_id"] == url def test_an_xhs_link_is_not_parsed_for_douyin(self): - with pytest.raises(TargetParseError): - parse_target_input(NOTE_URL, "note", "dy") + assert parse_target_input(NOTE_URL, "note", "dy")["external_id"] == NOTE_URL def test_a_platform_without_an_adapter_is_rejected(self): with pytest.raises(TargetParseError): @@ -230,11 +251,27 @@ class TestTaskCrud: assert listing.json()["tasks"][0]["target_count"] == 1 @pytest.mark.asyncio - async def test_invalid_target_returns_400(self, client): + async def test_an_unrecognised_target_is_accepted_as_is(self, client): + """认不出来的目标不再被拒(2026-10-12 关掉了链接校验)。 + + 存的是原值 —— 采集那一轮会抓不到东西并报出来,而不是静默通过。 + """ response = await client.post( "/api/monitor/tasks", json={"name": "bad", "mode": "creator", "targets": ["!!! nonsense !!!"]}, ) + + assert response.status_code == 201 + tasks = (await client.get("/api/monitor/tasks")).json()["tasks"] + assert tasks[0]["targets"][0]["external_id"] == "!!! nonsense !!!" + + @pytest.mark.asyncio + async def test_an_empty_target_is_still_rejected(self, client): + """空目标连库都写不进去,这个仍然要拦。""" + response = await client.post( + "/api/monitor/tasks", + json={"name": "bad", "mode": "creator", "targets": [" "]}, + ) assert response.status_code == 400 @pytest.mark.asyncio