diff --git a/GramAddict/core/perception/intent_resolver.py b/GramAddict/core/perception/intent_resolver.py index 89e7058..47a8c6b 100644 --- a/GramAddict/core/perception/intent_resolver.py +++ b/GramAddict/core/perception/intent_resolver.py @@ -135,23 +135,16 @@ class IntentResolver: # NEW REGRESSION FIX: Exclude interaction buttons (comment, like, share) when looking for author or media # This prevents the weak VLM from hallucinating bounding box numbers that point to "Comment". - interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon"] + interaction_suffixes = ["comment", "like", "share", "send", "save", "button_icon", "scrubber"] is_interaction = any(s in rid for s in interaction_suffixes) or any(s in desc for s in interaction_suffixes) node_text_lower = (node.text or "").lower() is_follow = "follow" in rid or "follow" in node_text_lower or "follow" in desc is_media_intent = "media content" in intent_lower or "image" in intent_lower or "video" in intent_lower - if is_author_intent and (is_interaction or is_follow): + if (is_author_intent or is_media_intent) and (is_interaction or is_follow): logger.debug( - f"🛡️ [Author Interaction Guard] Excluded interaction/follow button '{node.resource_id}' " - f"(desc='{node.content_desc}', text='{node.text}') for author intent '{intent_description}'" - ) - continue - - if is_media_intent and (is_interaction or is_follow): - logger.debug( - f"🛡️ [Media Interaction Guard] Excluded interaction/follow button '{node.resource_id}' " - f"(desc='{node.content_desc}') for media intent '{intent_description}'" + f"🛡️ [Interaction Guard] Excluded non-target element '{node.resource_id}' " + f"(desc='{node.content_desc}', text='{node.text}') for intent '{intent_description}'" ) continue @@ -233,15 +226,27 @@ class IntentResolver: return node if "post author username" in intent_lower or "tap post username" in intent_lower: + # 1. Feed Posts for node in candidates: - if "row_feed_photo_profile_imageview" in (node.resource_id or "").lower(): + rid = (node.resource_id or "").lower() + if "row_feed_photo_profile_imageview" in rid: logger.info(f"🎯 [Structural Fast-Path] Found post author avatar image: {node.content_desc}") return node for node in candidates: - if "row_feed_photo_profile_name" in (node.resource_id or "").lower(): + rid = (node.resource_id or "").lower() + if "row_feed_photo_profile_name" in rid: logger.info(f"🎯 [Structural Fast-Path] Found post author username text: {node.text}") return node + # 2. Reels (Clips) + for node in candidates: + rid = (node.resource_id or "").lower() + if "clips_author_username" in rid or "clips_author_container" in rid: + logger.info( + f"🎯 [Structural Fast-Path] Found Reel author username: {node.text or node.content_desc}" + ) + return node + if "feed post content" in intent_lower or "post media content" in intent_lower: for node in candidates: rid = (node.resource_id or "").lower() diff --git a/GramAddict/core/telepathic_engine.py b/GramAddict/core/telepathic_engine.py index efe7ee7..29bd374 100644 --- a/GramAddict/core/telepathic_engine.py +++ b/GramAddict/core/telepathic_engine.py @@ -184,14 +184,19 @@ class TelepathicEngine: if not root: return None - # Find grid-like visual nodes + # Find grid-like visual nodes using structural markers all_nodes = self._parser.get_all_nodes(root) - grid_candidates = [ - n - for n in all_nodes - if not n.text - and ("photo" in n.content_desc.lower() or "video" in n.content_desc.lower() or (50000 < n.area < 400000)) - ] + grid_candidates = [] + for n in all_nodes: + rid = (n.resource_id or "").lower() + desc = (n.content_desc or "").lower() + + # Structural anchor for Instagram grid items + if "grid_card_layout_container" in rid: + grid_candidates.append(n) + # Fallback for older versions or profile grids if resource-id is missing + elif not n.text and ("photo" in desc or "video" in desc) and (50000 < n.area < 400000): + grid_candidates.append(n) best = self._evaluator.evaluate_grid_visuals(device, persona_interests, grid_candidates) if best: diff --git a/tests/unit/test_structural_hardening.py b/tests/unit/test_structural_hardening.py new file mode 100644 index 0000000..d6971e0 --- /dev/null +++ b/tests/unit/test_structural_hardening.py @@ -0,0 +1,104 @@ +import pytest + +from GramAddict.core.perception.intent_resolver import IntentResolver +from GramAddict.core.perception.spatial_parser import SpatialNode + + +@pytest.fixture +def resolver(): + return IntentResolver() + + +def test_clips_author_fast_path(resolver): + """ + Ensures that Reel author usernames are resolved via the clips_author_username fast-path. + """ + reel_author_node = SpatialNode( + resource_id="com.instagram.android:id/clips_author_username", text="maor.benezri", bounds=(0, 0, 100, 100) + ) + + result = resolver.resolve("tap post username", [reel_author_node]) + assert result is not None + assert result.resource_id == "com.instagram.android:id/clips_author_username" + + +def test_scrubber_blacklist_for_author_intent(resolver): + """ + Ensures that the seeker/scrubber bar is filtered out for author intents. + """ + scrubber_node = SpatialNode( + resource_id="com.instagram.android:id/scrubber", + text="32291.0", + content_desc="@2131978468", + bounds=(0, 0, 500, 10), + ) + + # We need a mock device that supports screenshots to enter _visual_discovery + # where filter_navigation_conflicts is called for visual intents. + class MockDevice: + def screenshot(self): + return None + + @property + def deviceV2(self): + return self + + # Since we can't easily mock the VLM response here without more setup, + # we test filter_navigation_conflicts directly. + candidates = [scrubber_node] + filtered = resolver.filter_navigation_conflicts(candidates, "tap post username") + + assert scrubber_node not in filtered, "Scrubber should be filtered out for author intents" + + +def test_interaction_guard_excludes_scrubber_for_author(resolver): + """ + Verifies that the interaction guard correctly identifies and excludes the scrubber. + """ + scrubber_node = SpatialNode( + resource_id="com.instagram.android:id/scrubber", + text="32291.0", + content_desc="@2131978468", + bounds=(0, 0, 500, 10), + ) + + filtered = resolver.filter_navigation_conflicts([scrubber_node], "post author username") + assert len(filtered) == 0, "Interaction guard should have excluded the scrubber" + + +def test_telepathic_grid_selection_uses_structural_id(monkeypatch): + """ + Ensures TelepathicEngine prefers grid_card_layout_container for grid candidates. + """ + from GramAddict.core.telepathic_engine import TelepathicEngine + + engine = TelepathicEngine() + + # Mock nodes + grid_node = SpatialNode( + resource_id="com.instagram.android:id/grid_card_layout_container", bounds=(10, 10, 300, 300) + ) + background_node = SpatialNode( + resource_id="com.instagram.android:id/swipeable_nav_view_pager_inner_recycler_view", bounds=(0, 0, 1080, 2400) + ) + + # Use monkeypatch instead of MagicMock + monkeypatch.setattr(engine._parser, "parse", lambda x: "mock_root") + monkeypatch.setattr(engine._parser, "get_all_nodes", lambda x: [grid_node, background_node]) + + candidates_captured = [] + + def mock_evaluate(device, persona_interests, candidates): + candidates_captured.extend(candidates) + return grid_node + + monkeypatch.setattr(engine._evaluator, "evaluate_grid_visuals", mock_evaluate) + + class MockDevice: + def dump_hierarchy(self): + return "" + + engine.evaluate_grid_visuals(MockDevice(), ["travel"]) + + assert grid_node in candidates_captured + assert background_node not in candidates_captured, "Background recycler view should not be a grid candidate"