from __future__ import annotations import asyncio import json import os import re import shutil import uuid from copy import deepcopy from pathlib import Path from urllib.parse import unquote, urlsplit from .learning_contracts import NAVIGATION_PROFILE, execution_metadata_matches FAILURE_PHASES = {"validate", "inspect_current", "capture_before", "resolve_target", "execute", "settle", "request_guard", "capture_after"} PRIVATE_PATH = re.compile(r"password|passwd|credential|token|secret|session|cookie|authorization|captcha|verification|phone|mobile|验证码|密码|手机号", re.I) LOGIN_EVIDENCE = {"visible_current_account_navigation", "visible_login_dialog", "visible_login_entry", "visible_verification_widget", "no_conclusive_visible_evidence", "unsupported_platform", "inspection_unavailable"} TIMING_PHASES = {"validate", "inspect_current", "capture_before", "resolve_target", "execute", "settle", "request_guard", "capture_after", "failure_capture", "batch_capture"} REQUEST_POLICY_RULES = { "xhs-search-experimental-v1": {"collect", "search_history", "search_notes", "search_onebox", "search_dqa"}, "xhs-search-experimental-v2": {"collect", "search_history", "search_notes", "search_onebox", "search_dqa", "apm_data"}, "xhs-search-experimental-v3": {"collect", "search_history", "search_notes", "search_onebox", "search_dqa", "apm_data", "apm_data_xhr"}, "xhs-note-open-experimental-v1": {"collect", "note_share_code", "note_widgets", "note_feed", "note_seo", "note_metrics", "apm_data"}, "xhs-note-open-experimental-v2": {"collect", "note_share_code", "note_widgets", "note_feed", "note_seo", "note_metrics", "apm_data", "note_history_report"}, "xhs-readonly-ui-experimental-v1": {"collect", "apm_data"}, "xhs-search-scroll-experimental-v1": {"collect", "apm_data", "search_notes"}, "xhs-page-native-v1": {"native_page_traffic"}, "xhs-page-navigation-v1": {"native_page_traffic"}, } REQUEST_RESOURCE_TYPES = {"document", "fetch", "xhr", "other", "script", "stylesheet", "image", "font", "media", "manifest", "websocket", "eventsource", "texttrack"} ACTIVE_REQUEST_POLICIES = {"xhs-search-experimental-v3", "xhs-note-open-experimental-v2", "xhs-readonly-ui-experimental-v1", "xhs-search-scroll-experimental-v1", "xhs-page-native-v1", "xhs-page-navigation-v1"} REQUEST_POLICY_REASONS = {"missing_policy", "inactive_policy", "invalid_time", "policy_expired", "automation_inactive", "session_out_of_scope", "page_out_of_scope", "not_main_frame", "method_not_allowed", "page_changed", "policy_not_started", "invalid_url", "request_out_of_scope", "resource_type_not_allowed", "navigation_out_of_scope"} def safe_login_detection(value) -> dict | None: if not isinstance(value, dict): return None state = value.get("state") if not isinstance(state, str) or state not in {"logged_in", "logged_out", "verification_required", "unknown"} or value.get("basis") != "visible_page": return None timestamp = value.get("observed_at") if not isinstance(timestamp, str) or not re.fullmatch(r"\d{4}-\d\d-\d\dT[0-9:.+Z-]{8,25}", timestamp): return None evidence = value.get("evidence") return {"state": state, "basis": "visible_page", "observed_at": timestamp, "evidence": [item for item in evidence[:7] if isinstance(item, str) and item in LOGIN_EVIDENCE] if isinstance(evidence, list) else []} def safe_worker_code(value) -> str: return value if isinstance(value, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", value) else "worker_error" def safe_request_summary(value, *, blocked: bool = False) -> dict | None: """A second boundary: never accept arbitrary worker error/request payloads.""" if not isinstance(value, dict) or not isinstance(value.get("method"), str) or value["method"] not in {"GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE", "CONNECT", "TRACE"}: return None if not isinstance(value.get("resource_type"), str) or value["resource_type"] not in REQUEST_RESOURCE_TYPES: return None location = safe_location(value.get("origin"), value.get("pathname")) if not location: return None summary = {"method": value["method"], **location, "resource_type": value["resource_type"]} if blocked: reason, version = value.get("policy_reason"), value.get("active_policy_version") if isinstance(reason, str) and reason in REQUEST_POLICY_REASONS: summary["policy_reason"] = reason if isinstance(version, str) and version in ACTIVE_REQUEST_POLICIES: summary["active_policy_version"] = version expected = value.get("expected_resource_types") if reason == "resource_type_not_allowed" and isinstance(expected, list): types = list(dict.fromkeys(item for item in expected if isinstance(item, str) and item in REQUEST_RESOURCE_TYPES))[:4] if types: summary["expected_resource_types"] = types return summary def safe_location(origin_value, raw_path) -> dict | None: try: parsed = urlsplit(origin_value) if isinstance(origin_value, str) else None if not parsed: return None if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"}: return None parsed.port origin = f"{parsed.scheme}://{parsed.netloc}" except (ValueError, TypeError): return None if not isinstance(raw_path, str) or not raw_path.startswith("/"): return None segments = raw_path.split("?", 1)[0].split("#", 1)[0].split("/") path = [] for segment in segments: decoded = unquote(segment) if PRIVATE_PATH.search(decoded): path.append(":redacted") elif len(decoded) > 64 or "@" in decoded or re.search(r"1[3-9]\d{9}", decoded) or re.fullmatch(r"[a-f0-9-]{16,}|[A-Za-z0-9_=-]{32,}", decoded) or re.search(r"[\x00-\x1f]", decoded): path.append(":id") else: path.append(segment) return {"origin": origin, "pathname": "/".join(path)[:300]} def safe_popup_observations(value) -> list[dict]: records = [] for item in value[-10:] if isinstance(value, list) else []: if not isinstance(item, dict): continue allowed = {"phase": {"manual", "automation", "confirmed"}, "decision": {"adopted", "blocked"}, "reason": {"same_origin_manual", "same_origin_click", "unconfirmed_origin", "outside_scope", "unrequested_popup"}} if any(not isinstance(item.get(key), str) or item[key] not in options for key, options in allowed.items()): continue sequence, timestamp = item.get("sequence"), item.get("recorded_at") if type(sequence) is not int or not 0 <= sequence <= 2 ** 53 or not isinstance(timestamp, str) or not re.fullmatch(r"\d{4}-\d\d-\d\dT[0-9:.+Z-]{8,25}", timestamp): continue record = {"sequence": sequence, "recorded_at": timestamp, **{key: item[key] for key in allowed}} location = safe_location(item.get("origin"), item.get("pathname")) if location: record.update(location) records.append(record) return records def safe_request_observations(value) -> list[dict]: records = [] for item in value[-30:] if isinstance(value, list) else []: summary = safe_request_summary(item, blocked=isinstance(item, dict) and item.get("blocked") is True) if not summary: continue if type(item.get("sequence")) is int and 0 <= item["sequence"] <= 2 ** 53: summary["sequence"] = item["sequence"] timestamp = item.get("recorded_at") if isinstance(timestamp, str) and re.fullmatch(r"\d{4}-\d\d-\d\dT[0-9:.+Z-]{8,25}", timestamp): summary["recorded_at"] = timestamp for key in ("automating", "blocked"): if type(item.get(key)) is bool: summary[key] = item[key] rule, version = item.get("policy_rule"), item.get("policy_version") allowed_rules = REQUEST_POLICY_RULES.get(version) if isinstance(version, str) else None if allowed_rules and isinstance(rule, str) and rule in allowed_rules and item.get("automating") is True and item.get("blocked") is False: summary.update(policy_version=version, policy_rule=rule) records.append(summary) return records def safe_navigation_comparison(value) -> dict | None: """Only fixed relationship facts; never retain either navigation address.""" if not isinstance(value, dict): return None kinds = {"note", "profile", "notification", "explore", "search", "invalid"} booleans = ("same_origin", "same_pathname", "same_object_id", "same_query", "same_query_pairs", "same_hash", "other_query_equal") if any(not isinstance(value.get(key), str) or value[key] not in kinds for key in ("expected_kind", "actual_kind")): return None if any(type(value.get(key)) is not bool for key in booleans): return None result = {key: value[key] for key in ("expected_kind", "actual_kind", *booleans)} for key in ("xsec_token", "xsec_source"): item = value.get(key) if (not isinstance(item, dict) or type(item.get("values_equal")) is not bool or any(type(item.get(count)) is not int or not 0 <= item[count] <= 1000 for count in ("expected_count", "actual_count"))): return None result[key] = {field: item[field] for field in ("expected_count", "actual_count", "values_equal")} return result def safe_diagnostics(value, *, code=None, execution_profile=None) -> dict: value = value if isinstance(value, dict) else {} requests = [summary for item in value.get("blocked_requests", [])[:12] if (summary := safe_request_summary(item, blocked=True))] if isinstance(value.get("blocked_requests"), list) else [] count = value.get("blocked_request_count") phase = value.get("phase") result = {"phase": phase if isinstance(phase, str) and phase in FAILURE_PHASES else "unknown", "blocked_requests": requests, "blocked_request_count": min(count, 1_000_000) if type(count) is int and count >= 0 else len(requests)} if code == "NAVIGATION_BINDING_MISMATCH" and execution_profile == NAVIGATION_PROFILE: if comparison := safe_navigation_comparison(value.get("navigation_comparison")): result["navigation_comparison"] = comparison return result def safe_element_evidence(value: dict) -> dict: fields = {"selector", "tag", "label", "kind", "unique", "count", "sensitive", "in_viewport", "search_input"} result = {key: item for key, item in value.items() if key in fields and isinstance(item, (str, int, bool))} stable_label = value.get("stable_label") if isinstance(stable_label, str) and len(stable_label) <= 500: result["stable_label"] = stable_label basis = value.get("selector_basis") if isinstance(basis, str) and basis in {"id", "semantic", "scroll_container", "id_family", "sibling_class"}: result["selector_basis"] = basis if type(value.get("collection")) is bool: result["collection"] = value["collection"] sources = value.get("source_selectors") if isinstance(sources, list): result["source_selectors"] = [item[:1000] for item in sources[:3] if isinstance(item, str) and item.strip()] scroll = value.get("scroll_y") if isinstance(scroll, dict): cleaned = {key: item for key, item in scroll.items() if key in {"scroll_top", "scroll_height", "client_height"} and type(item) in {int, float} and 0 <= item <= 2 ** 53} if type(scroll.get("can_scroll")) is bool: cleaned["can_scroll"] = scroll["can_scroll"] result["scroll_y"] = cleaned hints = value.get("read_hints") if isinstance(hints, list): result["read_hints"] = [] for hint in hints[:12]: if not isinstance(hint, dict): continue cleaned = {key: item[:limit] for key, limit in (("selector", 1000), ("tag", 32), ("label", 100)) if isinstance(item := hint.get(key), str)} for key in ("unique", "in_viewport", "link"): if type(hint.get(key)) is bool: cleaned[key] = hint[key] if type(hint.get("count")) is int and 0 <= hint["count"] <= 2 ** 53: cleaned["count"] = hint["count"] source = hint.get("source") if isinstance(source, str) and source in {"text", "href", "attribute", "media"}: cleaned["source"] = source if source == "attribute" and hint.get("attribute") == "datetime": cleaned["attribute"] = "datetime" if source == "media" and isinstance(hint.get("media_kind"), str) and hint["media_kind"] in {"image", "video"} and type(hint.get("loaded")) is bool: cleaned.update(media_kind=hint["media_kind"], loaded=hint["loaded"]) elif source == "media": cleaned.pop("source", None) if cleaned.get("selector"): result["read_hints"].append(cleaned) return result def safe_failure_capture(value) -> dict | None: if not isinstance(value, dict) or not isinstance(value.get("url"), str) or not isinstance(value.get("elements"), list) or not isinstance(value.get("screenshot_path"), str): return None # These are already redacted page captures, not diagnostics. Preserve their # actual before/after state, while excluding arbitrary exception attributes. allowed = {"capture_id", "url", "title", "text", "viewport_text", "text_scope", "viewport", "elements", "screenshot_path", "captured_at", "identity_verification", "request_observations", "popup_observations"} capture = {key: deepcopy(item) for key, item in value.items() if key in allowed} if any(key in value for key in ("execution_profile", "execution_contract")): if value.get("execution_profile") is None or not execution_metadata_matches(value, value.get("execution_profile")): return None capture.update(execution_profile=value["execution_profile"], execution_contract=deepcopy(value["execution_contract"])) for key, limit in (("url", 4000), ("title", 250), ("text", 30000), ("viewport_text", 30000), ("capture_id", 128), ("text_scope", 64), ("captured_at", 64), ("identity_verification", 64), ("screenshot_path", 4000)): if key in capture: if not isinstance(capture[key], str): capture.pop(key) else: capture[key] = capture[key][:limit] capture["elements"] = [safe_element_evidence(element) for element in value["elements"][:300] if isinstance(element, dict)] capture["request_observations"] = safe_request_observations(capture.get("request_observations")) capture["popup_observations"] = safe_popup_observations(capture.get("popup_observations")) viewport = capture.get("viewport") if isinstance(viewport, dict): capture["viewport"] = {key: item for key, item in viewport.items() if key in {"width", "height", "scroll_x", "scroll_y"} and type(item) in {int, float}} else: capture.pop("viewport", None) return capture def safe_error_codes(value) -> list[str]: return list(dict.fromkeys(item for item in value[:10] if isinstance(item, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,63}", item))) if isinstance(value, list) else [] def safe_failure_diagnostics(value, steps: list[dict], error_codes, *, passed=None, execution_profile=None) -> dict | None: """Keep a failed worker's bounded cause, never a new outcome or raw payload.""" if passed is not False or not isinstance(value, dict) or set(value) != {"step_position", "kind", "code", "diagnostics"}: return None position, kind, code = (value[key] for key in ("step_position", "kind", "code")) if (type(position) is not int or not 1 <= position <= len(steps) or not isinstance(kind, str) or kind != steps[position - 1].get("kind") or not isinstance(code, str) or code not in safe_error_codes(error_codes) or not isinstance(value["diagnostics"], dict)): return None return {"step_position": position, "kind": kind, "code": code, "diagnostics": safe_diagnostics(value["diagnostics"], code=code, execution_profile=execution_profile)} def safe_verify_timings(step_values, total_value, steps: list[dict], *, passed: bool = False) -> dict | None: def milliseconds(value): return type(value) is int and 0 <= value <= 120000 if not isinstance(total_value, dict) or set(total_value) != {"duration_ms", "entry_ms", "close_ms"}: return None if not milliseconds(total_value["duration_ms"]) or not milliseconds(total_value["entry_ms"]) or total_value["entry_ms"] > total_value["duration_ms"] + 1: return None if total_value["close_ms"] is not None and (not milliseconds(total_value["close_ms"]) or total_value["close_ms"] > total_value["duration_ms"] + 1): return None if not isinstance(step_values, list) or len(step_values) > min(30, len(steps)): return None if passed and (len(step_values) != len(steps) or any(not isinstance(item, dict) or item.get("status") != "passed" for item in step_values)): return None terminal = False for position, item in enumerate(step_values, 1): if terminal or not isinstance(item, dict) or set(item) != {"step_position", "kind", "status", "duration_ms", "phase_ms"}: return None if type(item["step_position"]) is not int or item["step_position"] != position or item["kind"] != steps[position - 1].get("kind") or not isinstance(item["status"], str) or item["status"] not in {"passed", "failed", "interrupted"}: return None terminal = item["status"] != "passed" if not milliseconds(item["duration_ms"]) or item["duration_ms"] > total_value["duration_ms"] + 1 or not isinstance(item["phase_ms"], dict) or not set(item["phase_ms"]) <= TIMING_PHASES: return None if any(not milliseconds(value) for value in item["phase_ms"].values()) or sum(item["phase_ms"].values()) > item["duration_ms"] + len(TIMING_PHASES): return None # Entry, step execution, and explicit closing are exclusive measurements; # allow only a small aggregate tolerance for independently rounded values. recorded_ms = total_value["entry_ms"] + sum(item["duration_ms"] for item in step_values) + (total_value["close_ms"] or 0) if recorded_ms > total_value["duration_ms"] + 30: return None return {"step_timings": deepcopy(step_values), "verify_timing": deepcopy(total_value)} def safe_batch_evidence(value) -> list[dict]: records = [] for item in value[:5] if isinstance(value, list) else []: if not isinstance(item, dict) or type(item.get("batch_index")) is not int or not 1 <= item["batch_index"] <= 5: continue if any(type(item.get(key)) is not int or not 0 <= item[key] <= 500 for key in ("added_count", "duplicate_count")): continue capture = safe_failure_capture(item.get("capture")) if capture: records.append({"batch_index": item["batch_index"], "added_count": item["added_count"], "duplicate_count": item["duplicate_count"], "capture": capture}) return records class WorkerError(Exception): def __init__(self, message: str, code: str = "worker_error", *, before=None, after=None, diagnostics=None, batch_evidence=None, error_codes=None, session_closed=False): super().__init__(message) self.code = safe_worker_code(code) self.before = safe_failure_capture(before) self.after = safe_failure_capture(after) captures = [capture for capture in (self.before, self.after) if capture is not None] profile = NAVIGATION_PROFILE if captures and all(execution_metadata_matches(capture, NAVIGATION_PROFILE) for capture in captures) else None self.diagnostics = safe_diagnostics(diagnostics, code=self.code, execution_profile=profile) self.batch_evidence = safe_batch_evidence(batch_evidence) self.error_codes = safe_error_codes(error_codes) self.session_closed = session_closed is True class BrowserWorker: """Serialized RPC to the single browser owner; never imports the old application.""" def __init__(self, root: Path, home: Path): self.root, self.home = root, home self.process: asyncio.subprocess.Process | None = None self.lock = asyncio.Lock() self.stderr_task: asyncio.Task | None = None self.closing = False self.close_task: asyncio.Task | None = None async def _start(self) -> None: if self.process and self.process.returncode is None: return node = os.environ.get("ASSISTANT_NODE") or shutil.which("node") if not node: raise WorkerError("找不到 Node.js,请通过项目启动脚本运行后台", "node_not_found") script = self.root / "tools" / "learning" / "browser-worker.mjs" if not script.is_file(): raise WorkerError("本机学习执行器尚未就绪", "worker_unavailable") environment = {**os.environ, "ASSISTANT_LEARNING_HOME": str(self.home)} self.process = await asyncio.create_subprocess_exec(node, str(script), cwd=self.root, env=environment, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=8 * 1024 * 1024) if self.closing: await self._stop() raise WorkerError("学习执行器正在正常退出", "worker_closed") process = self.process async def consume_stderr() -> None: assert process.stderr while await process.stderr.readline(): pass # Do not forward browser diagnostic text or URLs into business logs. self.stderr_task = asyncio.create_task(consume_stderr()) async def call(self, method: str, params: dict) -> dict: async with self.lock: if self.closing: raise WorkerError("学习执行器正在正常退出", "worker_closed") await self._start() process = self.process assert process and process.stdin and process.stdout request_id = uuid.uuid4().hex try: process.stdin.write((json.dumps({"id": request_id, "method": method, "params": params}, ensure_ascii=False) + "\n").encode()) await process.stdin.drain() async with asyncio.timeout(120): while True: line = await process.stdout.readline() if not line: raise WorkerError("本机学习浏览器已退出,请重新打开会话", "worker_closed") try: response = json.loads(line) except json.JSONDecodeError: continue if response.get("id") != request_id: continue if response.get("error"): error = response["error"] raise WorkerError(str(error.get("message", "学习执行失败")), error.get("code"), before=error.get("before"), after=error.get("after"), diagnostics=error.get("diagnostics"), batch_evidence=error.get("batch_evidence"), error_codes=error.get("error_codes"), session_closed=error.get("session_closed")) return response.get("result") or {} except (TimeoutError, BrokenPipeError, ConnectionResetError): await self._stop() raise WorkerError("学习执行器响应中断;请核对页面与步骤后重新打开,不会自动重放动作", "worker_timeout") from None async def _stop(self) -> None: process, self.process = self.process, None if not process: return if process.stdin: process.stdin.close() try: await asyncio.wait_for(process.wait(), 15) except TimeoutError: process.terminate() try: await asyncio.wait_for(process.wait(), 3) except TimeoutError: process.kill() await process.wait() if self.stderr_task: self.stderr_task.cancel() self.stderr_task = None async def close(self) -> None: # EOF must reach the child even while an RPC holds the request lock. # Node closes its contexts on EOF, unblocking that exact active action. self.closing = True if self.close_task is None: self.close_task = asyncio.create_task(self._stop()) await asyncio.shield(self.close_task)