"""Task-local semantic no-progress protection, independent of DOM node indices.""" from __future__ import annotations import hashlib import json import re from urllib.parse import parse_qs, urlsplit def _digest(value) -> str: return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest() def page_identity(url: str) -> str: try: parts = urlsplit(url) keyword = (parse_qs(parts.query).get("keyword") or [""])[0] if parts.path.rstrip("/") in {"/search_result", "/search_result_ai"} else "" return _digest([parts.hostname, parts.path.rstrip("/"), keyword]) except (TypeError, ValueError): return _digest("unobserved") def _node_text(node) -> str: try: value = node.get_all_children_text(max_depth=2) except (AttributeError, TypeError, ValueError): value = getattr(node, "node_value", "") return re.sub(r"\s+", " ", str(value or "")).strip()[:512] def _profile_target(node, artifacts: dict) -> str | None: """Guard repeated attempts only; this never verifies a person's identity.""" authors = {str(row.get("author")): row for row in artifacts.get("sources") or [] if row.get("author")} for _ in range(3): if node is None: break attrs = getattr(node, "attributes", {}) or {} href = attrs.get("href", "") try: parts = urlsplit(href) if re.fullmatch(r"/user/profile/[A-Fa-f0-9]{24}/?", parts.path) and parts.hostname in {None, "www.xiaohongshu.com", "xiaohongshu.com"}: return _digest(["observed_profile", parts.path.rstrip("/")]) except (TypeError, ValueError): pass text = _node_text(node) if text and text in authors: return _digest(["observed_source_author_label", text]) node = getattr(node, "parent_node", None) return None def operation_identity(state, output, artifacts: dict) -> tuple[list[str], bool]: rows = [] reset = False selector_map = getattr(getattr(state, "dom_state", None), "selector_map", {}) or {} for action in getattr(output, "action", None) or []: value = action.model_dump(exclude_none=True) for name, args in value.items(): args = args if isinstance(args, dict) else {} if name in {"wait", "done"}: continue if name == "navigate": rows.append(["navigate", page_identity(args.get("url", ""))]) continue if name == "go_back": rows.append(["go_back"]) continue if name == "open_source_profile": rows.append(["open_source_profile", _digest(args.get("source_id", "unknown"))]) continue if name == "click": node = selector_map.get(args.get("index")) target = _profile_target(node, artifacts) if target: rows.append(["open_source_profile", target]) continue if node is not None: attrs = getattr(node, "attributes", {}) or {} rows.append(["click", _digest([getattr(node, "node_name", ""), _node_text(node), attrs.get("role"), attrs.get("aria-label"), page_identity(attrs.get("href", ""))])]) continue # Index values and model wording cannot distinguish two attempts # at the same program operation. Other parameter values stay only # inside a digest, never in logs or knowledge. stable = {key: item for key, item in args.items() if key not in {"index", "element_index", "coordinate_x", "coordinate_y"}} rows.append([name, _digest(stable)]) return [_digest(row) for row in rows], reset def evidence_identity(artifacts: dict) -> str: sources = [(row.get("source_id"), bool(row.get("author_profile_url"))) for row in artifacts.get("sources") or []] profiles = [(row.get("source_id"), bool(row.get("xiaohongshu_id"))) for row in artifacts.get("profiles") or []] progress = [(row.get("capability"), row.get("object_key"), row.get("post_fingerprint")) for row in artifacts.get("navigation_attempts") or [] if row.get("status") in {"comments_visible", "scrolled", "profile_opened"}] expanded = [row.get("id") for row in artifacts.get("operation_evidence") or [] if row.get("capability") == "expand_replies" and row.get("outcome") == "success"] return _digest([sources, profiles, expanded, progress]) class ProgressGuard: def __init__(self, repeats: int = 5): self.limit = repeats self.context = None self.counts: dict[str, int] = {} self.repeated = 0 def observe(self, state, output, artifacts: dict) -> bool: operations, reset = operation_identity(state, output, artifacts) # DOM text is intentionally excluded: hovercards, temporary indices, # animations and unrelated recommendations are not task progress. info = getattr(state, "page_info", None) # A requested scroll is not progress until an observed scroll position # or a program-owned navigation fact actually changes. Inner note # containers use the dedicated tool's verified scrolled fact. position = tuple(value if type(value) is int else None for value in ( getattr(info, "scroll_x", None), getattr(info, "scroll_y", None), getattr(state, "pixels_above", None))) context = (page_identity(getattr(state, "url", "")), evidence_identity(artifacts), position) if reset or context != self.context: self.context, self.counts, self.repeated = context, {}, 0 if reset: return False # Each operation/target has its own counter. A wait or an intervening # observation cannot erase attempts to open the same author. Empty # repeated inspections retain their existing independent protection. for operation in operations: self.counts[operation] = self.counts.get(operation, 0) + 1 self.repeated = self.counts[operation] - 1 if self.repeated >= self.limit: return True return False