"""User-authorized preflight scope, independent of model/runtime state.""" import pytest from backend.browser.preflight_policy import PreflightPolicy, compile_preflight_policy, instruction_text, note_id_from_url from backend.config import settings @pytest.fixture(autouse=True) def enable_reply_relation_preflight(monkeypatch): monkeypatch.setattr(settings, 'reply_relation_preflight_enabled', True) A = '6adaf8a6000000001901d400' B = '6a7f04b8000000003302f624' C = '6ad9c5e1000000001400c800' URL = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token=fixture-private' ACTUAL_REQUEST = ( '在小红书搜索“杭州装修”,本轮只做二级回复关系的只读预检,不找客户人数。最多查看2篇不同的新笔记,' f'排除ID为{A}和{B}的已测笔记。每篇只预检第一组当前可见讨论,最多5条回复;' '评论未进视口时沿真实评论入口推进。使用 inspect_reply_relations 检查是否有稳定且唯一的直接父为另一条回复的DOM依据。' '只报告每篇checked_count、eligible_count和不合格原因,不保存正文或任何来源,不保存知识,不展开折叠回复,' '不点击回复对象名字,不调用resolve_reply_target,不分析名单。发现eligible=true就保留页面并报告该样本可进入后续取证,' '本轮仍不取证;两篇都没有合格样本就说明未展开范围并结束,不重复尝试,不把缺少依据当成没有评论。' '不关注、不点赞、不收藏、不发评论、不私信。' ) ACTUAL_SECOND_REQUEST = ( '独立做一轮只读预检,只访问下面这个此前已实际观察到的笔记入口,不搜索、不换笔记。' '最多1篇,只检查第一组当前可见讨论,每次最多5条回复。' f'严格排除ID为{A}和{B}的旧笔记。\n[实际入口省略]\n' '先沿真实评论入口进入评论区,再使用 inspect_reply_relations。' '若首组只有根评论或回复仍折叠,就按实际可见范围报告;不展开折叠回复,不点击回复对象名字,' '不调用resolve_reply_target。不保存正文或来源,不保存知识,不分析客户名单,' '不关注、不点赞、不收藏、不发评论、不私信。' '只报告程序预检的篇数、checked_count、eligible_count和未读原因;' '没有合格样本也正常结束,不能凭昵称或顺序补关系。有eligible=true也仅保留页面,本轮不自动进入取证。' ) @pytest.mark.parametrize('message', [ '这轮只预检,不保存任何来源。最多浏览2篇新笔记。', '先仅预检,不保存原话,至多阅读两篇笔记。', '只做关系预检,不调用save_source。上限为2篇。', '仅进行回复关系预检,禁止保存来源,最多2篇候选笔记。', '只读预检,不保存正文或来源,最多2篇。', '独立做一轮只读预检,不保存来源,最多2篇。', ]) def test_explicit_read_only_preflight_has_only_the_actual_user_note_limit(message): policy = compile_preflight_policy(message, max_notes=99) assert policy.active is True and policy.max_notes == 2 assert policy.excluded_note_ids == () @pytest.mark.parametrize('message', [ '预检一下关系,再保存来源。最多2篇。', '只预检关系,稍后保存来源。', '不保存来源,最多2篇。', '只预检,正常保存来源。', '不要只做关系预检,直接按正常流程保存来源。', '只做关系预检,不保存;取消预检,改成正常取证并保存。', '只预检,不保存;最后要保存来源。', '“只预检,不保存,最多2篇”是引用,实际正常取证。', '「只做关系预检,不保存」只是示例,正常整理。', '```text\n只预检,不保存,最多2篇\n```\n正常整理。', '> 只预检,不保存,最多2篇\n正常取证。', '比如只预检、不保存;实际先讨论方案。', '先解释只预检、不保存是什么意思,暂不操作。', '只预检关系,不保存经验。', '只预检关系,不保存截图。', '只预检关系,不保存知识。', '正常找客户,每篇只预检第一组,不保存评论原话。', '只读讨论,不保存来源,最多1篇。', '独立做一轮只读预检,不保存知识。', '独立做一轮只读预检,之后保存来源。', '不保存来源,独立做一轮只读预检;先预检后保存来源。', '不保存来源,独立做一轮只读预检;先预检再保存来源。', '不保存来源,独立做一轮只读预检;先预检,稍后保存来源。', '独立做一轮只读预检,不保存来源;取消只读预检。', '不做只读预检,不保存来源。', '不进行只读预检,不保存来源。', '不要独立做一轮只读预检,不保存来源。', '“独立做一轮只读预检,不保存来源”是引用,先讨论方案。', '比如独立做一轮只读预检、不保存来源;实际先讨论方案。', '正常找客户,每篇做一轮只读预检,不保存来源。', ]) def test_ordinary_evidence_negation_quotes_examples_and_later_cancellation_are_inactive(message): assert compile_preflight_policy(message, max_notes=2) == PreflightPolicy() def test_no_default_or_model_invented_note_count_is_added(): assert compile_preflight_policy('只预检,不保存来源。', max_notes=2).max_notes is None assert compile_preflight_policy('只预检,不保存来源,最多检查5条可见回复。', max_notes=2).max_notes is None assert compile_preflight_policy('只预检,不保存,最多2到3篇笔记。').max_notes is None assert compile_preflight_policy('只预检,不保存,最多2篇左右。').max_notes is None assert compile_preflight_policy('只预检,不保存,最多2篇,篇数不限。').max_notes is None def test_actual_d44_user_scope_is_grounded_without_relying_on_model_brief(): policy = compile_preflight_policy(ACTUAL_REQUEST, max_notes=9) assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=2) def test_actual_second_d44_read_only_preflight_keeps_user_limit_and_exclusions(): policy = compile_preflight_policy(ACTUAL_SECOND_REQUEST, max_notes=9) assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=1) def test_full_second_goal_with_fictional_entry_url_has_exactly_two_exclusions(): entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=fictional&xsec_source=fixture' full = ACTUAL_SECOND_REQUEST.replace('[实际入口省略]', entry) policy = compile_preflight_policy(full, max_notes=1) assert policy == PreflightPolicy(active=True, excluded_note_ids=(A, B), max_notes=1) assert C not in policy.excluded_note_ids @pytest.mark.parametrize('boundary', ['。\n', ';\n', ';\n', '!\n', '?\n', '\n']) def test_inline_exclusion_never_continues_across_a_hard_sentence_or_bare_newline(boundary): entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=fictional' policy = compile_preflight_policy(f'只读预检,不保存来源,排除旧笔记{A}和{B}{boundary}{entry}') assert policy.excluded_note_ids == (A, B) @pytest.mark.parametrize('ending', ['\n\n', '\n实际入口:', '\n当前页面:', '\n请访问', '\n先沿评论入口进入评论区\n']) def test_explicit_multiline_list_ends_before_a_new_entry_or_action(ending): entry = 'https://www.xiaohongshu.com/explore/'+C goal = f'只读预检,不保存来源。排除以下笔记:\n"{A}"\n`{B}`{ending}{entry}' assert compile_preflight_policy(goal).excluded_note_ids == (A, B) def test_declared_multiline_url_list_keeps_queries_atomic_and_does_not_collect_query_ids(): first = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token='+C+'&label=用户&rule=最多9篇' second = 'https://www.xiaohongshu.com/explore/'+B+'?query=排除'+C goal = f'只读预检,不保存来源,最多1篇。排除以下笔记:\n{first}\n{second}' policy = compile_preflight_policy(goal) assert policy.excluded_note_ids == (A, B) and policy.max_notes == 1 @pytest.mark.parametrize('header', ['不要排除以下笔记:', '无需排除以下笔记:', '“排除以下笔记:”', '> 排除以下笔记:']) def test_negated_or_quoted_header_cannot_open_an_exclusion_list(header): assert compile_preflight_policy(f'只读预检,不保存来源。\n{header}\n{A}\n{B}').excluded_note_ids == () def test_list_cancellation_and_soft_comma_continuation_stay_explicit(): assert compile_preflight_policy(f'只读预检,不保存来源。排除{A},{B},实际入口:{C}').excluded_note_ids == (A, B) assert compile_preflight_policy(f'只读预检,不保存来源。排除以下笔记:\n{A}\n{B}\n不排除{B}\n{C}').excluded_note_ids == (A,) assert compile_preflight_policy(f'只读预检,不保存来源。排除{A};{B}').excluded_note_ids == (A,) def test_url_directive_words_cannot_enable_preflight_save_ban_exclusion_or_note_cap(): url = 'https://www.xiaohongshu.com/explore/'+C+'?mode=只读预检&save=不保存来源&scope=排除'+A+'&cap=最多9篇' assert compile_preflight_policy(url, max_notes=9) == PreflightPolicy() policy = compile_preflight_policy('只读预检,不保存来源。实际入口:'+url) assert policy == PreflightPolicy(active=True) bounded = compile_preflight_policy(f'只读预检,不保存来源,最多1篇,排除{A}。实际入口:'+url) assert bounded == PreflightPolicy(active=True, excluded_note_ids=(A,), max_notes=1) punctuation_query = 'https://www.xiaohongshu.com/explore/'+C+'?data=sample,只读预检,不保存来源;排除'+A+',最多9篇' assert compile_preflight_policy(punctuation_query) == PreflightPolicy() assert compile_preflight_policy('只读预检,不保存来源。'+punctuation_query) == PreflightPolicy(active=True) def test_url_terminal_sentence_mark_ends_exclusion_even_with_a_query(): excluded = 'https://www.xiaohongshu.com/explore/'+A+'?xsec_token=fictional' entry = 'https://www.xiaohongshu.com/explore/'+C+'?xsec_token=entry' assert compile_preflight_policy(f'只读预检,不保存来源。排除{excluded}。\n{entry}').excluded_note_ids == (A,) def test_note_id_in_url_path_or_query_is_data_only_until_explicitly_bound(): url = 'https://www.xiaohongshu.com/explore/'+C+'?note='+B+'&hint=排除'+A assert compile_preflight_policy('只读预检,不保存来源。'+url).excluded_note_ids == () assert compile_preflight_policy('只读预检,不保存来源。排除笔记 '+url).excluded_note_ids == (C,) quoted = '“'+url+',只读预检,不保存来源”是引用' assert compile_preflight_policy(quoted) == PreflightPolicy() def test_real_explicit_exclusions_bind_only_note_identities_and_can_be_cancelled(): policy = compile_preflight_policy(f'只做只读关系预检,不保存来源,排除D42旧笔记 {A}、D43旧笔记 {B}。最多2篇新笔记。') assert policy.excluded_note_ids == (A, B) assert policy.max_notes == 2 changed = compile_preflight_policy(f'只预检,不保存,排除笔记 {A} 和 {B},不要排除笔记 {B}。') assert changed.excluded_note_ids == (A,) def test_excluded_note_urls_and_formatted_constants_do_not_turn_whole_quotes_into_instructions(): policy = compile_preflight_policy(f'只预检,不保存,排除笔记 `{A}` 和 `{URL}`。') assert policy.excluded_note_ids == (A,) ignored = compile_preflight_policy(f'只预检,不保存。“排除笔记 {A}”是引用,只检查真实新页面。') assert ignored.excluded_note_ids == () assert URL in instruction_text(f'排除笔记 `{URL}`') def test_raw_profile_ids_and_unsupported_links_are_not_note_exclusions(): policy = compile_preflight_policy(f'只预检,不保存,排除用户 {A};跳过 https://www.xiaohongshu.com/user/profile/{B};排除 https://evil.example/explore/{C}。') assert policy.excluded_note_ids == () assert compile_preflight_policy(f'只预检,不保存,排除 {A}f。').excluded_note_ids == () def test_later_explicit_include_or_reset_removes_excluded_notes(): assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};允许打开笔记 {A}。').excluded_note_ids == () assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};排除列表改为 {B}。').excluded_note_ids == (B,) assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};不排除任何笔记。').excluded_note_ids == () assert compile_preflight_policy(f'只预检,不保存,不需要排除笔记 {A}。').excluded_note_ids == () assert compile_preflight_policy(f'只预检,不保存,排除笔记 {A};不排除笔记 {A}。').excluded_note_ids == () assert compile_preflight_policy(f'只预检,不保存,不需要打开笔记 {A}。').excluded_note_ids == (A,) @pytest.mark.parametrize('url,expected', [ (URL, A), ('https://xiaohongshu.com/discovery/item/'+A+'/', A), ('https://www.xiaohongshu.com:443/explore/'+A, A), ('http://www.xiaohongshu.com/explore/'+A, None), ('https://www.xiaohongshu.com:9443/explore/'+A, None), ('https://user@www.xiaohongshu.com/explore/'+A, None), ('https://www.xiaohongshu.com.evil.example/explore/'+A, None), ('https://www.xiaohongshu.com/user/profile/'+A, None), ('app://ui/index.html', None), ('https://www.xiaohongshu.com/explore/'+A+'f', None), ]) def test_note_identity_is_read_only_from_an_actual_valid_note_url(url, expected): assert note_id_from_url(url) == expected