132 lines
6.2 KiB
Python
132 lines
6.2 KiB
Python
"""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
|