343 lines
17 KiB
Python
343 lines
17 KiB
Python
"""Bounded, read-only navigation on local Page observations; no browser/network."""
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from backend.browser import tools
|
|
from backend.prospecting.contracts import ProspectingBrief
|
|
from backend.prospecting.prompts import browser_prompt
|
|
|
|
NOTE = 'a' * 24
|
|
NOTE_URL = 'https://www.xiaohongshu.com/explore/' + NOTE
|
|
PROFILE = 'https://www.xiaohongshu.com/user/profile/' + 'b' * 24
|
|
OTHER_PROFILE = 'https://www.xiaohongshu.com/user/profile/' + 'c' * 24
|
|
|
|
|
|
class NavigationPage:
|
|
def __init__(self):
|
|
self.url = NOTE_URL
|
|
self.position = 0
|
|
self.comments = 0
|
|
self.clicks = []
|
|
self.scrolls = []
|
|
self.comment_entry = '#comment-entry'
|
|
self.scroll_area = '#note-scroll'
|
|
self.click_enters = True
|
|
self.scroll_moves = True
|
|
self.profile_navigates = True
|
|
self.hover = False
|
|
self.profile_header = False
|
|
self.profile_href = PROFILE
|
|
self.element_href = PROFILE
|
|
self.element_count = 1
|
|
self.source_text = '本人正在找装修公司'
|
|
self.source_author = '本机夹具作者'
|
|
self.source_selector = '#body'
|
|
self.profile_selector = '#actual-avatar'
|
|
self.source_type = 'note'
|
|
self.source_key = ''
|
|
self.mouse_calls = 0
|
|
|
|
async def get_url(self):
|
|
return self.url
|
|
|
|
async def evaluate(self, code, options=None):
|
|
if code == tools._OBSERVE_SOURCE:
|
|
value = {'url': self.url, 'text': self.source_text, 'source_type': self.source_type, 'source_key': self.source_key,
|
|
'author': self.source_author, 'author_profile_url': self.profile_href,
|
|
'author_profile_selector': self.profile_selector}
|
|
elif code == tools._OBSERVE_DISCUSSION_NAVIGATION:
|
|
value = {'url': self.url, 'visible_comment_count': self.comments,
|
|
'comment_control_selector': self.comment_entry,
|
|
'scroll': {'selector': self.scroll_area, 'anchor_x': 210, 'anchor_y': 370, 'delta_y': 420} if self.scroll_area else None,
|
|
'signature': json.dumps([self.position, self.comments]),
|
|
'discussion_state': 'visible_comments' if self.comments else 'comments_not_in_view'}
|
|
elif code == tools._OBSERVE_PROFILE_DESTINATION:
|
|
value = {'url': self.url, 'profile_header_visible': self.profile_header, 'hover_card_visible': self.hover}
|
|
elif code == tools._INSPECT_SOURCE_CANDIDATES:
|
|
value = {'url': self.url, 'candidates': [], 'saved_count': 1, 'reply_controls': [],
|
|
'discussion_state': 'visible_comments' if self.comments else 'comments_not_in_view', 'visible_comment_count': self.comments}
|
|
else:
|
|
raise AssertionError('Unrecognised DOM observation, no arbitrary script allowed')
|
|
return json.dumps(value)
|
|
|
|
async def get_elements_by_css_selector(self, selector):
|
|
assert selector in {self.comment_entry, self.scroll_area, self.profile_selector}, 'No guessed selector'
|
|
async def attribute(name):
|
|
assert name == 'href'
|
|
return self.element_href
|
|
async def click():
|
|
self.clicks.append(selector)
|
|
if selector == self.comment_entry:
|
|
if self.click_enters:
|
|
self.comments, self.position = 2, self.position + 1
|
|
elif selector == self.profile_selector:
|
|
if self.profile_navigates:
|
|
self.url, self.profile_header = self.element_href, True
|
|
else:
|
|
self.hover = True
|
|
else:
|
|
raise AssertionError('Scroll region must never be clicked')
|
|
return [SimpleNamespace(click=click, get_attribute=attribute) for _ in range(self.element_count)]
|
|
|
|
@property
|
|
async def mouse(self):
|
|
async def scroll(**kwargs):
|
|
self.scrolls.append(kwargs)
|
|
if self.scroll_moves:
|
|
self.position += 1
|
|
self.comments = 2
|
|
self.mouse_calls += 1
|
|
return SimpleNamespace(scroll=scroll)
|
|
|
|
|
|
class Session:
|
|
def __init__(self, page): self.page = page
|
|
async def get_current_page(self): return self.page
|
|
|
|
|
|
@pytest.fixture
|
|
def context(monkeypatch, tmp_path):
|
|
tid = 'navigation_d38'
|
|
tools.clear_task_context(tid)
|
|
monkeypatch.setattr(tools, 'task_doc_dir', lambda _: tmp_path)
|
|
async def no_delay(_): pass
|
|
monkeypatch.setattr(tools.asyncio, 'sleep', no_delay)
|
|
source = {'source_id': 'source_fixture', 'note_id': NOTE, 'source_type': 'note', 'source_selector': '#body',
|
|
'content': '本人正在找装修公司', 'author': '本机夹具作者', 'author_profile_url': '', 'author_profile_selector': ''}
|
|
tools.set_prospecting_context(tid, artifacts={'sources': [source]})
|
|
yield tid, tmp_path
|
|
tools.clear_task_context(tid)
|
|
|
|
|
|
def source(tid): return tools.get_task_artifacts(tid)['sources'][0]
|
|
|
|
|
|
def test_navigation_tools_accept_object_or_mode_only_and_add_no_outreach_capability(context):
|
|
actions = tools.build_prospecting_tools(context[0]).registry.registry.actions
|
|
assert 'advance_comments' in actions and 'open_source_profile' in actions
|
|
assert set(actions['advance_comments'].param_model.model_fields) == {'mode'}
|
|
assert set(actions['open_source_profile'].param_model.model_fields) == {'source_id'}
|
|
for action in ['perform_authorized_follow', 'send_authorized_message', 'evaluate', 'upload_file']:
|
|
assert action not in actions
|
|
prompt = browser_prompt(ProspectingBrief(target_candidates=1), [])
|
|
assert 'comments_not_in_view' in prompt and 'open_source_profile' in prompt and '小红书号是可选项' in prompt
|
|
|
|
|
|
async def test_saved_body_without_viewport_comments_is_unread_not_empty_or_completed(context):
|
|
page = NavigationPage()
|
|
value = await tools._inspect_source_candidates(Session(page), task_id=context[0])
|
|
assert value['empty_reason'] == 'comments_not_in_view' and not value['comments_complete']
|
|
assert value['saved_sources'] == [{'source_id':'source_fixture','source_type':'note','source_selector':'#body',
|
|
'parent_resolution':'','has_reply_target':False,'relations_checked':False,'author_link_observed':False}]
|
|
assert '尚未进入当前视口' in value['next_action']
|
|
assert not page.clicks and not page.scrolls
|
|
disk = json.loads((context[1] / 'evidence.json').read_text())
|
|
assert disk['source_observations'][0]['discussion_state'] == 'comments_not_in_view'
|
|
|
|
|
|
async def test_enter_clicks_actual_comment_control_once_then_reports_visible_without_claiming_read(context):
|
|
page = NavigationPage()
|
|
result = await tools._advance_comments(context[0], Session(page))
|
|
assert result['status'] == 'comments_visible' and not result['comments_complete']
|
|
assert page.clicks == ['#comment-entry'] and not page.scrolls
|
|
second = await tools._advance_comments(context[0], Session(page))
|
|
assert second['status'] == 'comments_visible' and page.clicks == ['#comment-entry']
|
|
records = json.loads((context[1] / 'evidence.json').read_text())['navigation_attempts']
|
|
assert len(records) == 1 and records[0]['id'] and records[0]['status'] == 'comments_visible'
|
|
assert records[0]['pre_fingerprint'] != records[0]['post_fingerprint']
|
|
assert not tools.get_task_artifacts(context[0]).get('operation_evidence')
|
|
|
|
|
|
async def test_next_scroll_uses_only_current_observed_bounds_and_not_script_click(context):
|
|
page = NavigationPage()
|
|
page.comment_entry = ''
|
|
result = await tools._advance_comments(context[0], Session(page), 'next')
|
|
assert result['status'] == 'scrolled' and not result['comments_complete']
|
|
assert not page.clicks
|
|
assert page.scrolls == [{'x': 210, 'y': 370, 'delta_y': 420}]
|
|
|
|
|
|
async def test_comment_entry_without_effect_does_not_loop_but_allows_distinct_scroll_fallback(context):
|
|
page = NavigationPage()
|
|
page.click_enters = False
|
|
first = await tools._advance_comments(context[0], Session(page))
|
|
assert first['status'] == 'not_moved'
|
|
with pytest.raises(ValueError, match='不要重复'):
|
|
await tools._advance_comments(context[0], Session(page))
|
|
assert len(page.clicks) == 1
|
|
await tools._advance_comments(context[0], Session(page), 'next')
|
|
assert len(page.scrolls) == 1
|
|
|
|
|
|
async def test_scroll_without_new_state_is_not_repeated_and_missing_entry_stays_unread(context):
|
|
page = NavigationPage()
|
|
page.scroll_moves = False
|
|
assert (await tools._advance_comments(context[0], Session(page), 'next'))['status'] == 'not_moved'
|
|
with pytest.raises(ValueError, match='不要重复'):
|
|
await tools._advance_comments(context[0], Session(page), 'next')
|
|
assert len(page.scrolls) == 1
|
|
page.position += 1
|
|
page.comment_entry = page.scroll_area = ''
|
|
assert (await tools._advance_comments(context[0], Session(page)))['status'] == 'inaccessible'
|
|
|
|
|
|
@pytest.mark.parametrize('bad', [-1, 0, 701, '420', None])
|
|
async def test_invalid_scroll_observation_never_becomes_guessed_gesture(context, bad):
|
|
page = NavigationPage()
|
|
native_evaluate = page.evaluate
|
|
async def evaluate(code, options=None):
|
|
raw = json.loads(await native_evaluate(code, options))
|
|
if code == tools._OBSERVE_DISCUSSION_NAVIGATION: raw['scroll']['delta_y'] = bad
|
|
return json.dumps(raw)
|
|
page.evaluate = evaluate
|
|
with pytest.raises(ValueError, match='不能猜坐标'):
|
|
await tools._advance_comments(context[0], Session(page), 'next')
|
|
assert not page.scrolls and not page.clicks
|
|
|
|
|
|
async def test_open_profile_refreshes_existing_source_link_without_rewriting_quote_or_old_fact(context):
|
|
tid = context[0]
|
|
page = NavigationPage()
|
|
original = deepcopy(source(tid))
|
|
old_fact = {'id': 'original_fact', 'details': {'trace': {'run_id': 'old'}}}
|
|
tools._task_artifacts[tid]['operation_evidence'] = [deepcopy(old_fact)]
|
|
result = await tools._open_source_profile(tid, Session(page), original['source_id'])
|
|
assert result['status'] == 'profile_opened' and page.clicks == ['#actual-avatar']
|
|
assert source(tid)['author_profile_url'] == PROFILE and source(tid)['author_profile_selector'] == '#actual-avatar'
|
|
assert source(tid)['content'] == original['content'] and source(tid)['source_id'] == original['source_id']
|
|
assert tools.get_task_artifacts(tid)['operation_evidence'] == [old_fact]
|
|
disk = json.loads((context[1] / 'evidence.json').read_text())
|
|
assert disk['sources'][0]['author_profile_url'] == PROFILE and disk['navigation_attempts'][0]['status'] == 'profile_opened'
|
|
assert 'profile_url' not in result and PROFILE not in json.dumps(result)
|
|
|
|
|
|
async def test_hover_card_stops_repeat_click_until_actual_view_state_changes(context):
|
|
tid = context[0]
|
|
page = NavigationPage()
|
|
page.profile_navigates = False
|
|
assert (await tools._open_source_profile(tid, Session(page), 'source_fixture'))['status'] == 'hover_only'
|
|
with pytest.raises(ValueError, match='不要重复点击'):
|
|
await tools._open_source_profile(tid, Session(page), 'source_fixture')
|
|
assert page.clicks == ['#actual-avatar']
|
|
page.position += 1 # A genuinely different observed viewport can be retried.
|
|
page.profile_navigates = True
|
|
assert (await tools._open_source_profile(tid, Session(page), 'source_fixture'))['status'] == 'profile_opened'
|
|
assert len(page.clicks) == 2
|
|
|
|
|
|
@pytest.mark.parametrize('mutate,match', [
|
|
(lambda p: setattr(p, 'url', 'https://www.xiaohongshu.com/explore/'+'d'*24), '原笔记'),
|
|
(lambda p: setattr(p, 'source_author', '另一位作者'), '作者'),
|
|
(lambda p: setattr(p, 'source_text', '他人的不同原话'), '原话'),
|
|
(lambda p: setattr(p, 'source_type', 'comment'), '层级'),
|
|
(lambda p: setattr(p, 'profile_selector', ''), '唯一可见'),
|
|
(lambda p: setattr(p, 'profile_href', 'https://evil.example/user/profile/'+'b'*24), '小红书'),
|
|
(lambda p: setattr(p, 'profile_href', ''), '小红书'),
|
|
(lambda p: setattr(p, 'element_href', OTHER_PROFILE), '点击前'),
|
|
(lambda p: setattr(p, 'element_count', 2), '入口已经变化'),
|
|
])
|
|
async def test_source_author_navigation_refuses_unproven_or_changed_target_before_click(context, mutate, match):
|
|
page = NavigationPage(); mutate(page)
|
|
with pytest.raises(ValueError, match=match):
|
|
await tools._open_source_profile(context[0], Session(page), 'source_fixture')
|
|
assert not page.clicks
|
|
|
|
|
|
async def test_preexisting_source_identity_cannot_be_reassigned_or_arbitrary_profile_claimed(context):
|
|
tid = context[0]
|
|
source(tid)['author_profile_url'] = OTHER_PROFILE
|
|
page = NavigationPage()
|
|
with pytest.raises(ValueError, match='原关联'):
|
|
await tools._open_source_profile(tid, Session(page), 'source_fixture')
|
|
assert not page.clicks and source(tid)['author_profile_url'] == OTHER_PROFILE
|
|
page.url = PROFILE
|
|
with pytest.raises(ValueError, match='不能按昵称归属'):
|
|
await tools._open_source_profile(tid, Session(page), 'source_fixture')
|
|
|
|
|
|
@pytest.mark.parametrize('actual_key', ['different-comment', ''])
|
|
async def test_same_quote_and_nickname_cannot_reassign_a_different_or_missing_stable_comment_key(context, actual_key):
|
|
tid = context[0]
|
|
source(tid).update(source_type='comment', source_key='original-comment-id')
|
|
original = deepcopy(source(tid))
|
|
page = NavigationPage()
|
|
page.source_type = 'comment'
|
|
page.source_key = actual_key
|
|
with pytest.raises(ValueError, match='稳定来源标识'):
|
|
await tools._open_source_profile(tid, Session(page), 'source_fixture')
|
|
assert source(tid) == original and not page.clicks
|
|
|
|
|
|
async def test_matching_stable_comment_key_can_refresh_the_same_existing_source(context):
|
|
tid = context[0]
|
|
source(tid).update(source_type='comment', source_key='original-comment-id')
|
|
page = NavigationPage()
|
|
page.source_type, page.source_key = 'comment', 'original-comment-id'
|
|
result = await tools._open_source_profile(tid, Session(page), 'source_fixture')
|
|
assert result['status'] == 'profile_opened'
|
|
assert source(tid)['source_key'] == 'original-comment-id' and source(tid)['author_profile_url'] == PROFILE
|
|
|
|
|
|
async def test_post_click_wrong_profile_is_unknown_and_never_records_success(context):
|
|
page = NavigationPage()
|
|
original = page.get_elements_by_css_selector
|
|
async def elements(selector):
|
|
nodes = await original(selector)
|
|
async def click(): page.clicks.append(selector); page.url = OTHER_PROFILE; page.profile_header = True
|
|
nodes[0].click = click
|
|
return nodes
|
|
page.get_elements_by_css_selector = elements
|
|
with pytest.raises(ValueError, match='实际打开的主页'):
|
|
await tools._open_source_profile(context[0], Session(page), 'source_fixture')
|
|
assert tools.get_task_artifacts(context[0])['navigation_attempts'][-1]['status'] == 'unknown'
|
|
assert not tools.get_task_artifacts(context[0]).get('operation_evidence')
|
|
|
|
|
|
def dom_observer(code, *, arg=None, scenario='note'):
|
|
import subprocess
|
|
fixture = Path(__file__).with_name('navigation_dom_fixture_d38.cjs')
|
|
result = subprocess.run(['node', str(fixture)], input=json.dumps({'code': code, 'arg': arg, 'scenario': scenario}),
|
|
text=True, capture_output=True, timeout=5, check=True)
|
|
return json.loads(result.stdout)
|
|
|
|
|
|
def test_actual_observer_reads_unique_note_header_anchor_without_inheriting_comment_person():
|
|
observed = dom_observer(tools._OBSERVE_SOURCE, arg='#detail-desc')
|
|
assert observed['source_type'] == 'note' and observed['author'] == '正文作者'
|
|
assert observed['author_profile_url'] == PROFILE and observed['author_profile_selector'] == '#author-avatar'
|
|
assert '评论本人的需求' not in observed['text']
|
|
|
|
|
|
def test_actual_observer_preserves_comment_owner_and_excludes_nested_reply_author():
|
|
observed = dom_observer(tools._OBSERVE_SOURCE, arg='#comment-content')
|
|
assert observed['source_type'] == 'comment' and observed['author'] == '评论作者'
|
|
assert observed['author_profile_url'] == OTHER_PROFILE and observed['author_profile_selector'] == '#comment-author'
|
|
assert observed['text'] == '评论本人的需求'
|
|
|
|
|
|
def test_actual_observer_rejects_ambiguous_note_header_profile_identities():
|
|
observed = dom_observer(tools._OBSERVE_SOURCE, arg='#detail-desc', scenario='ambiguous')
|
|
assert observed['author_profile_url'] == '' and observed['author_profile_selector'] == ''
|
|
|
|
|
|
def test_actual_narrow_viewport_inspector_does_not_turn_comment_count_into_read_comments():
|
|
observed = dom_observer(tools._INSPECT_SOURCE_CANDIDATES,
|
|
arg={'max_comments': 5, 'saved_selectors': [], 'note_saved': True}, scenario='below_viewport')
|
|
assert observed['discussion_state'] == 'comments_not_in_view' and observed['visible_comment_count'] == 0
|
|
assert observed['candidates'] == [] and observed['saved_count'] == 1
|
|
navigation = dom_observer(tools._OBSERVE_DISCUSSION_NAVIGATION, scenario='below_viewport')
|
|
assert navigation['visible_comment_count'] == 0 and navigation['comment_control_selector'] == '#comment-entry'
|
|
assert navigation['scroll']['selector'] == 'html'
|
|
|
|
|
|
def test_actual_visible_discussion_has_independent_comment_and_reply_containers():
|
|
observed = dom_observer(tools._INSPECT_SOURCE_CANDIDATES,
|
|
arg={'max_comments': 5, 'saved_selectors': [], 'note_saved': True})
|
|
assert observed['discussion_state'] == 'visible_comments'
|
|
assert [(row['source_type'],row['source_selector']) for row in observed['candidates']] == [
|
|
('comment','#comment-content'),('reply','#reply-content')]
|