"""Read the shipped DOM observers without navigation, persistence or proofs.""" from copy import deepcopy import json from pathlib import Path import subprocess import pytest from backend.browser import tools URL = 'https://www.xiaohongshu.com/explore/' + 'a' * 24 FIXTURE = Path(__file__).with_name('reply_dom_fixture_d41.cjs') def node(identity='', classes='', text='', *children, tag='div', **attrs): return {'tag': tag, 'attrs': {'id': identity, 'class': classes, **attrs}, 'text': text, 'children': list(children)} def content(identity, text='PRIVATE-QUOTE', *children): return node(identity+'-content', 'content', '', node(identity+'-text', 'note-text', text, *children)) def speaker(identity, person, own=None, *, kind='reply', extra=(), **attrs): author = node(identity+'-author', 'name', 'PRIVATE-SAME-NAME', tag='a', href='https://www.xiaohongshu.com/user/profile/' + person*24) return node(identity, 'comment-item' if kind == 'comment' else 'comment-item-sub', '', node('', 'author-wrapper', '', author), own or content(identity), *extra, **{'data-comment-id': identity+'-key', **attrs}) def target(*, href=True, identity='c', user_attribute=False): attrs = {'href': 'https://www.xiaohongshu.com/user/profile/'+identity*24} if href else {} if user_attribute: attrs['data-target-user-id'] = identity*24 return content('child', '回复 ', node('target', 'user-name', 'PRIVATE-SAME-NAME', tag='a' if href else 'span', **attrs), node('', '', ': PRIVATE-CHILD-QUOTE')) def thread(*replies, identity='thread'): return node(identity, 'parent-comment', '', speaker(identity+'-root', 'b', kind='comment'), node(identity+'-replies', 'reply-container', '', *replies)) def tree(*groups): return node('noteContainer', '', '', node('detail-desc', '', 'PRIVATE-NOTE'), *groups) def find(value, identity): if value.get('attrs', {}).get('id') == identity: return value for child in value.get('children', []): found = find(child, identity) if found: return found return None class Page: def __init__(self, value, mutate=None): self.tree, self.mutate = deepcopy(value), mutate self.url = URL self.calls = [] self.source_calls = {} async def get_url(self): return self.url async def evaluate(self, code, arg): assert code in {tools._INSPECT_SOURCE_CANDIDATES, tools._OBSERVE_SOURCE, tools._OBSERVE_RELATION_RECHECK} self.calls.append((code, arg)) if code == tools._OBSERVE_SOURCE: assert isinstance(arg, str), 'preflight must never supply a native proof identity' self.source_calls[arg] = self.source_calls.get(arg, 0)+1 if self.mutate: self.mutate(self, code, arg) response = subprocess.run(['node', str(FIXTURE)], text=True, capture_output=True, check=True, timeout=5, input=json.dumps({'code': code, 'arg': arg, 'tree': self.tree}, ensure_ascii=False)) return response.stdout class Session: def __init__(self, page): self.page = page async def get_current_page(self): return self.page @pytest.fixture def isolated(monkeypatch, tmp_path): task_id = 'reply_preflight_d44' tools.set_prospecting_context(task_id, artifacts={ 'messages': [{'role': 'user', 'text': '只预检第一组讨论,不保存、不点击'}], 'sources': [{'source_id': 'old-note', 'source_type': 'note', 'note_id': 'a'*24, 'source_selector': '#detail-desc', 'content': 'PRIVATE-NOTE'}, {'source_id': 'old-child', 'source_type': 'reply', 'note_id': 'a'*24, 'source_selector': '#child-content', 'content': 'PRIVATE-CHILD-QUOTE', 'parent_source_id': None}], 'source_observations': [{'operation': 'old-observation'}], 'operation_evidence': [{'id': 'old-fact'}], 'knowledge_event_ids': ['old-knowledge'], }) tools._reply_target_proofs[task_id] = {'child-key': {'run_id': 'old-run', 'signature': 'old', 'profile_id': 'c'*24}} tools._prospecting_run_context[task_id] = {'run_id': 'new-run', 'goal_version': 2} def forbidden(*_args, **_kwargs): raise AssertionError('read-only preflight must not write facts, navigate or use proofs') for name in ('_persist_evidence', '_operation_evidence', '_save_source', '_write_json_atomic', '_apply_reply_target_proof', 'task_doc_dir'): monkeypatch.setattr(tools, name, forbidden) before = deepcopy((tools._task_artifacts, tools._reply_target_proofs, tools._prospecting_run_context, tools._pending_blocker, tools._pending_question)) yield task_id assert before == (tools._task_artifacts, tools._reply_target_proofs, tools._prospecting_run_context, tools._pending_blocker, tools._pending_question) assert list(tmp_path.iterdir()) == [] tools.clear_task_context(task_id) async def inspect(value, task_id, mutate=None, maximum=5): page = Page(value, mutate) result = await tools._inspect_reply_relations(Session(page), maximum, task_id) serialized = json.dumps(result) assert 'PRIVATE' not in serialized and 'https://' not in serialized and 'profile_id' not in serialized assert result['note_id'] == 'a'*24 for identity in ('a'*24, 'b'*24, 'c'*24, 'd'*24, 'e'*24): assert identity not in json.dumps({key: value for key, value in result.items() if key != 'note_id'}) return result, page def child_row(result): return next(row for row in result['candidates'] if row['source_selector'] == '#child-content') @pytest.mark.parametrize('mode', ['href', 'user_attribute', 'comment_id']) async def test_real_unique_identity_or_explicit_comment_ref_qualifies_without_saving(mode, isolated): own = target(href=mode == 'href', user_attribute=mode == 'user_attribute') child = speaker('child', 'd', own, **({'data-parent-comment-id': 'parent-key'} if mode == 'comment_id' else {})) result, _ = await inspect(tree(thread(speaker('parent', 'c'), child)), isolated) row = child_row(result) assert row['classification'] == 'unique_dom_deep_reply' and row['eligible'] is True assert row['parent_is_reply'] is True and row['parent_relation_observed'] is True assert row['parent_source_selector'] == '#parent-content' and row['root_source_selector'] == '#thread-root-content' assert row['native_navigation_confirmed'] is False assert row['parent_resolution'] == ('explicit_comment_id' if mode == 'comment_id' else 'unique_target_profile') async def test_actual_nested_parent_reply_has_structural_evidence(isolated): child = speaker('child', 'd') parent = speaker('parent', 'c', extra=[child]) root = speaker('root', 'b', kind='comment', extra=[parent]) result, _ = await inspect(tree(root), isolated) row = child_row(result) assert row['classification'] == 'unique_dom_deep_reply' assert row['parent_resolution'] == 'structural_parent' and row['parent_is_reply'] is True async def test_identical_names_and_two_actual_comments_by_target_identity_remain_ambiguous(isolated): result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('another', 'c'), speaker('child', 'd', target()))), isolated) row = child_row(result) assert row['classification'] == 'ambiguous' and not row['eligible'] assert row['relation_diagnostics']['matching_parent_comments'] == 2 assert row['parent_source_selector'] == '' async def test_plain_span_is_unproved_even_with_an_existing_native_proof_cache(isolated): result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target(href=False)))), isolated) row = child_row(result) assert row['classification'] == 'needs_native_identity' and row['observation_stable'] is True assert row['relation_diagnostics']['target_identities'] == 0 assert row['relation_diagnostics']['target_profile_links'] == 0 assert not row['eligible'] and not row['native_navigation_confirmed'] @pytest.mark.parametrize('change', ['source_key', 'quote', 'rendering', 'identity', 'url']) async def test_source_key_quote_rendering_identity_or_url_change_is_unknown(change, isolated): def mutate(page, code, selector): if code != tools._OBSERVE_SOURCE or selector != '#child-content' or page.source_calls[selector] != 2: return if change == 'source_key': find(page.tree, 'child')['attrs']['data-comment-id'] = 'changed-key' elif change == 'quote': find(page.tree, 'child-text')['text'] = 'CHANGED-QUOTE' elif change == 'rendering': find(page.tree, 'child-content')['rect'] = {'left': 0, 'right': 300, 'top': 700, 'bottom': 735, 'width': 300, 'height': 35} elif change == 'identity': # Same nickname, target counts, parent key and selector; the actual # target data-* identity changes together with its observed author. find(page.tree, 'target')['attrs']['data-target-user-id'] = 'e'*24 find(page.tree, 'parent-author')['attrs']['href'] = 'https://www.xiaohongshu.com/user/profile/'+'e'*24 else: page.url = URL.replace('a'*24, 'f'*24) result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target(href=False, user_attribute=True)))), isolated, mutate) row = child_row(result) assert row['classification'] == 'unknown' and row['observation_stable'] is False and not row['eligible'] async def test_changed_candidate_excerpt_before_first_observation_is_unknown(isolated): def mutate(page, code, selector): if code == tools._OBSERVE_SOURCE and selector == '#child-content' and page.source_calls[selector] == 1: find(page.tree, 'child-text')['text'] = 'DIFFERENT-QUOTE' result, _ = await inspect(tree(thread(speaker('parent', 'c'), speaker('child', 'd', target()))), isolated, mutate) assert child_row(result)['classification'] == 'unknown' async def test_parent_moved_to_another_thread_does_not_qualify_old_child_observation(isolated): def mutate(page, code, selector): if (code == tools._OBSERVE_SOURCE and selector == '#parent-content' and page.source_calls.get('#child-content') == 1): old = find(page.tree, 'thread-replies')['children'] parent = old.pop(next(index for index, row in enumerate(old) if row['attrs']['id'] == 'parent')) find(page.tree, 'other-replies')['children'].append(parent) value = tree(thread(speaker('parent', 'c'), speaker('child', 'd', target())), thread(identity='other')) result, _ = await inspect(value, isolated, mutate) assert child_row(result)['classification'] == 'unknown' and not child_row(result)['eligible'] async def test_direct_root_reply_is_not_a_qualified_reply_to_reply_sample(isolated): result, _ = await inspect(tree(thread(speaker('child', 'd'))), isolated) row = child_row(result) assert row['parent_is_reply'] is False and row['parent_resolution'] == 'thread_root' assert not row['eligible'] and row['classification'] == 'unknown' async def test_later_candidate_mutation_invalidates_an_earlier_qualified_row(isolated): def mutate(page, code, selector): if code == tools._OBSERVE_SOURCE and selector == '#later-content' and page.source_calls[selector] == 1: find(page.tree, 'child')['attrs']['data-comment-id'] = 'new-child-key' value = tree(thread(speaker('parent', 'c'), speaker('child', 'd', target()), speaker('later', 'e'))) result, _ = await inspect(value, isolated, mutate) assert child_row(result)['classification'] == 'unknown' and not child_row(result)['eligible'] async def test_explicit_first_group_does_not_preflight_another_group(isolated): value = tree(thread(speaker('first', 'c')), thread(speaker('parent', 'c'), speaker('child', 'd', target()), identity='other')) result, _ = await inspect(value, isolated) assert all(row['source_selector'] != '#child-content' for row in result['candidates']) assert result['eligible_count'] == 0 @pytest.mark.parametrize('maximum', [0, 6, True, '2']) async def test_batch_bound_is_strict_before_reading(maximum): with pytest.raises(ValueError, match='1–5'): await tools._inspect_reply_relations(object(), maximum) async def test_ordinary_inspector_still_records_by_default_but_internal_no_record_does_not(monkeypatch): tid = 'preflight_inspector_compatibility' tools.set_prospecting_context(tid) writes = [] monkeypatch.setattr(tools, '_persist_evidence', lambda task: writes.append(task)) page = Page(tree(thread(speaker('child', 'd')))) try: pure = await tools._inspect_source_candidates(Session(page), task_id=tid, record_observation=False) assert 'source_observations' not in tools._task_artifacts[tid] and not writes normal = await tools._inspect_source_candidates(Session(page), task_id=tid) assert normal == pure assert writes == [tid] and tools._task_artifacts[tid]['source_observations'][0]['operation'] == 'inspect_source_candidates' finally: tools.clear_task_context(tid) def test_registered_preflight_accepts_only_a_bounded_count_not_selectors_or_identity(monkeypatch): monkeypatch.setattr(tools.settings, 'reply_relation_preflight_enabled', True) registry = tools.build_prospecting_tools('preflight_registration').registry.registry.actions action = registry['inspect_reply_relations'] assert set(action.param_model.model_fields) == {'max_replies'} assert '不自动调用resolver' in action.description assert 'unique_dom_deep_reply' in action.description