agent-wire-cli 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. agent_wire/__init__.py +1 -0
  2. agent_wire/__main__.py +3 -0
  3. agent_wire/assets/SKILL.md +26 -0
  4. agent_wire/assets/cursor_rule.mdc +13 -0
  5. agent_wire/assets/hermes_skill.md +28 -0
  6. agent_wire/assets/windsurf_rules.md +14 -0
  7. agent_wire/assets/wire-worker.md +20 -0
  8. agent_wire/auth/__init__.py +0 -0
  9. agent_wire/auth/browser_paths.py +143 -0
  10. agent_wire/auth/cookie_vault.py +181 -0
  11. agent_wire/auth/rate_limiter.py +26 -0
  12. agent_wire/cache/__init__.py +0 -0
  13. agent_wire/cache/ring_buffer.py +94 -0
  14. agent_wire/cache/sqlite_ledger.py +132 -0
  15. agent_wire/channels/__init__.py +41 -0
  16. agent_wire/channels/base.py +249 -0
  17. agent_wire/channels/tier1_global/__init__.py +0 -0
  18. agent_wire/channels/tier1_global/github.py +371 -0
  19. agent_wire/channels/tier1_global/hackernews.py +135 -0
  20. agent_wire/channels/tier1_global/reddit.py +371 -0
  21. agent_wire/channels/tier1_global/rss.py +129 -0
  22. agent_wire/channels/tier1_global/search.py +160 -0
  23. agent_wire/channels/tier1_global/stackoverflow.py +94 -0
  24. agent_wire/channels/tier1_global/web.py +98 -0
  25. agent_wire/channels/tier1_global/wikipedia.py +91 -0
  26. agent_wire/channels/tier1_global/x_twitter.py +380 -0
  27. agent_wire/channels/tier1_global/youtube.py +223 -0
  28. agent_wire/channels/tier2_pro/__init__.py +0 -0
  29. agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
  30. agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
  31. agent_wire/channels/tier2_pro/devto.py +114 -0
  32. agent_wire/channels/tier2_pro/fediverse.py +181 -0
  33. agent_wire/channels/tier2_pro/hubs.py +161 -0
  34. agent_wire/channels/tier2_pro/linkedin.py +146 -0
  35. agent_wire/channels/tier2_pro/markets.py +207 -0
  36. agent_wire/channels/tier2_pro/meta_social.py +104 -0
  37. agent_wire/channels/tier2_pro/news.py +173 -0
  38. agent_wire/channels/tier2_pro/polymarket.py +105 -0
  39. agent_wire/channels/tier2_pro/publishing.py +84 -0
  40. agent_wire/channels/tier2_pro/stocktwits.py +79 -0
  41. agent_wire/channels/tier3_regional/bilibili.py +171 -0
  42. agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
  43. agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
  44. agent_wire/channels/tier3_regional/v2ex.py +70 -0
  45. agent_wire/channels/tier3_regional/wechat.py +67 -0
  46. agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
  47. agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
  48. agent_wire/channels/tier3_regional/xueqiu.py +77 -0
  49. agent_wire/channels/tier3_regional/zhihu.py +57 -0
  50. agent_wire/cli.py +403 -0
  51. agent_wire/compression/__init__.py +0 -0
  52. agent_wire/compression/anchor_slicer.py +45 -0
  53. agent_wire/compression/bm25_slicer.py +151 -0
  54. agent_wire/compression/dam_formatter.py +74 -0
  55. agent_wire/compression/url_compactor.py +45 -0
  56. agent_wire/config.py +52 -0
  57. agent_wire/engine/__init__.py +0 -0
  58. agent_wire/engine/challenge.py +58 -0
  59. agent_wire/engine/http_pool.py +136 -0
  60. agent_wire/engine/stealth_browser.py +90 -0
  61. agent_wire/engine/waterfall.py +159 -0
  62. agent_wire/firewall/__init__.py +0 -0
  63. agent_wire/firewall/inbound_shield.py +116 -0
  64. agent_wire/firewall/outbound_privacy.py +120 -0
  65. agent_wire/installers/__init__.py +0 -0
  66. agent_wire/installers/client_installer.py +315 -0
  67. agent_wire/media/__init__.py +0 -0
  68. agent_wire/media/audio_transcriber.py +77 -0
  69. agent_wire/media/subtitle_cleaner.py +102 -0
  70. agent_wire/models.py +60 -0
  71. agent_wire/research/__init__.py +1 -0
  72. agent_wire/research/brief.py +227 -0
  73. agent_wire/research/briefing.py +41 -0
  74. agent_wire/research/cluster.py +128 -0
  75. agent_wire/research/engine.py +632 -0
  76. agent_wire/research/library.py +74 -0
  77. agent_wire/research/planner.py +232 -0
  78. agent_wire/research/scoring.py +89 -0
  79. agent_wire/research/store.py +149 -0
  80. agent_wire/research/watch.py +70 -0
  81. agent_wire/router.py +107 -0
  82. agent_wire/server.py +468 -0
  83. agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
  84. agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
  85. agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
  86. agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
  87. agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,151 @@
1
+ """Query-aware BM25 + social-signal block ranking that packs the best blocks into a token budget."""
2
+
3
+ import math
4
+ import os
5
+ import re
6
+ from collections import Counter
7
+ from functools import cache
8
+
9
+ import tiktoken
10
+
11
+ from agent_wire.config import init_wire_home
12
+ from agent_wire.models import WireComment
13
+
14
+ HEADER_TOKENS = 200
15
+ K1, B = 1.5, 0.75
16
+ W_ACCEPTED, W_OP = 3.0, 1.5
17
+
18
+ _WORD = re.compile(r"\w+")
19
+ _HEADING_ONLY = re.compile(r"#{1,6}\s+[^\n]*")
20
+
21
+
22
+ @cache
23
+ def _encoding() -> tiktoken.Encoding:
24
+ # tiktoken caches its BPE file in the OS temp dir by default; keep it in WIRE_HOME instead.
25
+ cache_dir = init_wire_home() / "tiktoken"
26
+ os.environ.setdefault("TIKTOKEN_CACHE_DIR", str(cache_dir))
27
+ try:
28
+ return tiktoken.get_encoding("o200k_base")
29
+ except FileExistsError:
30
+ # Several first runs at once (clients starting their servers together) each download the
31
+ # file and rename it into place; on Windows the losers' rename fails. The winner's copy is
32
+ # there now, so read it, and drop the losers' temp files.
33
+ for tmp in cache_dir.glob("*.tmp"):
34
+ tmp.unlink(missing_ok=True)
35
+ return tiktoken.get_encoding("o200k_base")
36
+
37
+
38
+ def count_tokens(text: str) -> int:
39
+ return len(_encoding().encode(text, disallowed_special=()))
40
+
41
+
42
+ def truncate_tokens(text: str, max_tokens: int) -> str:
43
+ tokens = _encoding().encode(text, disallowed_special=())
44
+ return text if len(tokens) <= max_tokens else _encoding().decode(tokens[:max_tokens])
45
+
46
+
47
+ def split_blocks(text: str) -> list[str]:
48
+ """Paragraph blocks; fenced code stays whole and a lone heading joins the block after it."""
49
+ blocks, buf, fence = [], [], None
50
+ for line in text.splitlines():
51
+ stripped = line.lstrip()
52
+ if fence:
53
+ buf.append(line)
54
+ if stripped.startswith(fence):
55
+ fence = None
56
+ elif stripped.startswith(("```", "~~~")):
57
+ fence = stripped[:3]
58
+ buf.append(line)
59
+ elif not stripped:
60
+ if buf:
61
+ blocks.append("\n".join(buf))
62
+ buf = []
63
+ else:
64
+ buf.append(line)
65
+ if buf:
66
+ blocks.append("\n".join(buf))
67
+
68
+ merged, heading = [], None
69
+ for block in blocks:
70
+ if heading:
71
+ block, heading = f"{heading}\n\n{block}", None
72
+ if _HEADING_ONLY.fullmatch(block):
73
+ heading = block
74
+ else:
75
+ merged.append(block)
76
+ if heading:
77
+ merged.append(heading)
78
+ return merged
79
+
80
+
81
+ def bm25_scores(blocks: list[str], query: str) -> list[float]:
82
+ terms = set(_WORD.findall(query.lower()))
83
+ if not terms or not blocks:
84
+ return [0.0] * len(blocks)
85
+ docs = [Counter(_WORD.findall(b.lower())) for b in blocks]
86
+ n = len(docs)
87
+ avgdl = sum(sum(d.values()) for d in docs) / n or 1.0
88
+ idf = {}
89
+ for t in terms:
90
+ df = sum(1 for d in docs if t in d)
91
+ idf[t] = math.log((n - df + 0.5) / (df + 0.5) + 1)
92
+ scores = []
93
+ for d in docs:
94
+ dl = sum(d.values())
95
+ s = 0.0
96
+ for t in terms:
97
+ if tf := d.get(t):
98
+ s += idf[t] * tf * (K1 + 1) / (tf + K1 * (1 - B + B * dl / avgdl))
99
+ scores.append(s)
100
+ return scores
101
+
102
+
103
+ def select(costs: list[int], scores: list[float], budget: int) -> list[int]:
104
+ """Greedy pack by score (ties keep document order); returns kept indices in document order."""
105
+ kept, used = [], 0
106
+ for i in sorted(range(len(costs)), key=lambda i: -scores[i]):
107
+ if used + costs[i] <= budget:
108
+ kept.append(i)
109
+ used += costs[i]
110
+ return sorted(kept)
111
+
112
+
113
+ def slice_text(text: str, query: str | None = None, max_tokens: int = 3000) -> str:
114
+ if count_tokens(text) <= max_tokens:
115
+ return text
116
+ blocks = split_blocks(text)
117
+ if len(blocks) == 1:
118
+ return truncate_tokens(text, max_tokens)
119
+
120
+ # Always keep the lead (title / TL;DR / first ~200 tokens), even if it isn't query-relevant.
121
+ lead_budget = min(HEADER_TOKENS, max_tokens)
122
+ costs = [count_tokens(b) + 1 for b in blocks] # +1 for the joining blank line
123
+ lead, used = 0, 0
124
+ while lead < len(blocks) and used + costs[lead] <= lead_budget:
125
+ used += costs[lead]
126
+ lead += 1
127
+ if lead == 0:
128
+ blocks[0] = truncate_tokens(blocks[0], lead_budget)
129
+ used, lead = count_tokens(blocks[0]) + 1, 1
130
+
131
+ rest = blocks[lead:]
132
+ kept = select(costs[lead:], bm25_scores(rest, query or ""), max_tokens - used)
133
+ return "\n\n".join(blocks[:lead] + [rest[i] for i in kept])
134
+
135
+
136
+ def _subtree_text(c: WireComment) -> str:
137
+ return "\n".join([c.body, *(_subtree_text(r) for r in c.replies)])
138
+
139
+
140
+ def rank_comments(
141
+ comments: list[WireComment], query: str | None = None, max_tokens: int = 1500
142
+ ) -> list[WireComment]:
143
+ """Keep the highest-signal top-level comment sub-trees that fit, in original order."""
144
+ texts = [_subtree_text(c) for c in comments]
145
+ bm = bm25_scores(texts, query or "")
146
+ scores = [
147
+ bm[i] + W_ACCEPTED * c.is_accepted + W_OP * c.is_op + math.log1p(max(0, c.score))
148
+ for i, c in enumerate(comments)
149
+ ]
150
+ kept = select([count_tokens(t) for t in texts], scores, max_tokens)
151
+ return [comments[i] for i in kept]
@@ -0,0 +1,74 @@
1
+ """Dense Agent Markdown: compact document + comment-tree rendering and the [WIRE | ...] envelope."""
2
+
3
+ import re
4
+ from datetime import UTC, datetime
5
+
6
+ from agent_wire.compression.url_compactor import compact
7
+ from agent_wire.firewall.inbound_shield import spotlight
8
+ from agent_wire.models import WireComment, WireDocument, WireTelemetry
9
+
10
+ _IMAGE = re.compile(r"!\[[^\]]*\]\([^)]*\)")
11
+ _BLANKS = re.compile(r"\n{3,}")
12
+ _AGE_UNITS = (("y", 31_536_000), ("mo", 2_592_000), ("d", 86_400), ("h", 3_600), ("m", 60))
13
+
14
+
15
+ def telemetry_header(t: WireTelemetry) -> str:
16
+ return (
17
+ f"[WIRE | {t.channel} | {t.tokens} tokens (saved {t.saved_pct}%) | auth:{t.auth}"
18
+ f" | stage:{t.stage} | {t.elapsed_ms}ms]"
19
+ )
20
+
21
+
22
+ def envelope(t: WireTelemetry, content: str) -> str:
23
+ return spotlight(telemetry_header(t), content)
24
+
25
+
26
+ def relative_age(ts: datetime, now: datetime | None = None) -> str:
27
+ now = now or datetime.now(UTC)
28
+ if ts.tzinfo is None:
29
+ ts = ts.replace(tzinfo=UTC)
30
+ seconds = max(0, int((now - ts).total_seconds()))
31
+ for unit, size in _AGE_UNITS:
32
+ if seconds >= size:
33
+ return f"{seconds // size}{unit} ago"
34
+ return "just now"
35
+
36
+
37
+ def format_comments(
38
+ comments: list[WireComment], now: datetime | None = None, depth: int = 0
39
+ ) -> str:
40
+ lines = []
41
+ prefix = " " * depth + ("> " if depth else "")
42
+ for c in comments:
43
+ tags = (
44
+ [f"{c.score} replies" if c.score_kind == "replies" else f"{c.score:+d}"]
45
+ + ["OP"] * c.is_op
46
+ + ["ACCEPTED"] * c.is_accepted
47
+ )
48
+ who = f"@{c.author_handle}" if c.author_handle else "@[deleted]"
49
+ age = f" ({relative_age(c.timestamp, now)})" if c.timestamp else ""
50
+ body = [ln for ln in c.body.strip().splitlines() if ln.strip()] or [""]
51
+ lines.append(f"{prefix}[{' | '.join(tags)}] {who}{age}: {body[0]}")
52
+ lines.extend(" " * len(prefix) + ln for ln in body[1:])
53
+ if c.replies:
54
+ lines.append(format_comments(c.replies, now, depth + 1))
55
+ return "\n".join(lines)
56
+
57
+
58
+ def render_document(doc: WireDocument, now: datetime | None = None) -> str:
59
+ parts = [f"# {doc.title}"] if doc.title else []
60
+ meta = []
61
+ if doc.author_handle:
62
+ meta.append(f"@{doc.author_handle}")
63
+ if doc.timestamp:
64
+ meta.append(doc.timestamp.date().isoformat())
65
+ if doc.score is not None:
66
+ meta.append(f"{doc.score:+d}")
67
+ meta += [f"{k}: {v}" for k, v in doc.metadata.items() if v is not None]
68
+ if meta:
69
+ parts.append(" · ".join(meta))
70
+ if body := _IMAGE.sub("", doc.body).strip():
71
+ parts.append(body)
72
+ if doc.comments:
73
+ parts.append("## Comments\n" + format_comments(doc.comments, now))
74
+ return compact(_BLANKS.sub("\n\n", "\n\n".join(parts)))
@@ -0,0 +1,45 @@
1
+ """Strip tracking params from URLs and fold repeated Markdown links into reference links."""
2
+
3
+ import re
4
+ from collections import Counter
5
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
6
+
7
+ _TRACKING = re.compile(r"utm_\w*|fbclid|gclid|si|ref|spm", re.IGNORECASE)
8
+ _URL = re.compile(r"https?://[^\s<>()\[\]\"']+")
9
+ _MD_LINK = re.compile(r"(?<!!)\[([^\]]*)\]\((\S+?)\)")
10
+
11
+
12
+ def clean_url(url: str) -> str:
13
+ parts = urlsplit(url)
14
+ if not parts.query:
15
+ return url
16
+ pairs = parse_qsl(parts.query, keep_blank_values=True)
17
+ kept = [(k, v) for k, v in pairs if not _TRACKING.fullmatch(k)]
18
+ if len(kept) == len(pairs):
19
+ return url # untouched: avoid re-encoding churn
20
+ return urlunsplit(parts._replace(query=urlencode(kept)))
21
+
22
+
23
+ def _clean_match(m: re.Match) -> str:
24
+ url = m[0].rstrip(".,;:!?")
25
+ return clean_url(url) + m[0][len(url) :]
26
+
27
+
28
+ def compress_links(markdown: str) -> str:
29
+ counts = Counter(m[2] for m in _MD_LINK.finditer(markdown))
30
+ if not any(n > 1 for n in counts.values()):
31
+ return markdown
32
+ ids: dict[str, int] = {}
33
+
34
+ def repl(m: re.Match) -> str:
35
+ if counts[m[2]] < 2:
36
+ return m[0]
37
+ return f"[{m[1]}][{ids.setdefault(m[2], len(ids) + 1)}]"
38
+
39
+ body = _MD_LINK.sub(repl, markdown)
40
+ refs = "\n".join(f"[{n}]: {url}" for url, n in ids.items())
41
+ return f"{body}\n\n{refs}"
42
+
43
+
44
+ def compact(text: str) -> str:
45
+ return compress_links(_URL.sub(_clean_match, text))
agent_wire/config.py ADDED
@@ -0,0 +1,52 @@
1
+ """Portable WIRE_HOME resolution and runtime profiles."""
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ CLIENT_DIRS = (".claude", ".codex", ".hermes", ".agent", ".cursor", ".windsurf", ".roo", ".gemini")
8
+
9
+ # Everything in WIRE_HOME (vault.enc, cache.db, venvs) stays out of git except config.
10
+ WIRE_GITIGNORE = "*\n!.gitignore\n!config.toml\n"
11
+
12
+ PROFILES = {
13
+ "standard": {"read_max_tokens": 3000, "search_max_tokens": 2500, "auto_expand_top": 2},
14
+ # IDEA says auto_expand_top=1.5; a result count must be whole, so 1.
15
+ "local": {"read_max_tokens": 800, "search_max_tokens": 800, "auto_expand_top": 1},
16
+ }
17
+
18
+
19
+ def _client_dir(path: Path) -> Path | None:
20
+ for p in (path, *path.parents):
21
+ if p.name in CLIENT_DIRS:
22
+ return p
23
+ return None
24
+
25
+
26
+ def resolve_wire_home() -> Path:
27
+ if env := os.environ.get("WIRE_HOME"):
28
+ return Path(env)
29
+ if root := os.environ.get("CLAUDE_PLUGIN_ROOT"):
30
+ return Path(root) / ".wire"
31
+ # Run from inside a client folder, or installed into one (venv lives under it).
32
+ for start in (Path.cwd(), Path(__file__).resolve()):
33
+ if client := _client_dir(start):
34
+ return client / ".wire"
35
+ if sys.platform == "win32":
36
+ return Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming") / "agent-wire"
37
+ return Path.home() / ".config" / "agent-wire"
38
+
39
+
40
+ def init_wire_home(home: Path | None = None) -> Path:
41
+ home = home or resolve_wire_home()
42
+ home.mkdir(parents=True, exist_ok=True)
43
+ gitignore = home / ".gitignore"
44
+ if not gitignore.exists():
45
+ gitignore.write_text(WIRE_GITIGNORE, encoding="utf-8")
46
+ return home
47
+
48
+
49
+ def get_profile(name: str = "standard") -> dict[str, int]:
50
+ if name not in PROFILES:
51
+ raise ValueError(f"unknown profile {name!r}; expected one of {sorted(PROFILES)}")
52
+ return PROFILES[name]
File without changes
@@ -0,0 +1,58 @@
1
+ """Detect Cloudflare / DataDome / Turnstile challenges, login walls and empty JS shells."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ from selectolax.lexbor import LexborHTMLParser
6
+
7
+ from agent_wire.engine.http_pool import Fetched
8
+
9
+ BLOCK_STATUSES = frozenset({401, 403, 429, 503})
10
+ SMALL_BODY = 35_000
11
+ MIN_VISIBLE_CHARS = 80
12
+
13
+ # Only ever present on a challenge page, so safe to match anywhere near the top.
14
+ _HARD = (
15
+ b"<title>just a moment...</title>",
16
+ b"cf-browser-verification",
17
+ b"cf_chl_opt",
18
+ b"captcha-delivery.com", # DataDome block page (its normal tag script lives on js.datadome.co)
19
+ )
20
+ # Also appear on ordinary pages (articles about them, login forms), so only on small bodies.
21
+ _SOFT = (
22
+ b"challenges.cloudflare.com/turnstile",
23
+ b"<title>access denied</title>",
24
+ b"enable javascript and cookies to continue",
25
+ )
26
+ _LOGIN_PATHS = ("/login", "/signin", "/sign-in", "/accounts/login", "/i/flow/login", "/authwall")
27
+
28
+
29
+ def visible_text_len(html: bytes) -> int:
30
+ tree = LexborHTMLParser(html)
31
+ for node in tree.css("script, style, noscript, template"):
32
+ node.decompose()
33
+ body = tree.body
34
+ return len(body.text(strip=True)) if body else 0
35
+
36
+
37
+ def is_blocked(f: Fetched, *, rendered: bool = False) -> bool:
38
+ """`rendered=True` for stage-4 output: JS already ran, so a short page is just short."""
39
+ small = len(f.content) < SMALL_BODY
40
+ if f.status in BLOCK_STATUSES and small:
41
+ return True
42
+ head = f.content[:65_536].lower()
43
+ if any(s in head for s in _HARD):
44
+ return True
45
+ if small and any(s in head for s in _SOFT):
46
+ return True
47
+ return bool(
48
+ not rendered
49
+ and f.ok
50
+ and f.content
51
+ and "html" in f.content_type
52
+ and visible_text_len(f.content) < MIN_VISIBLE_CHARS
53
+ )
54
+
55
+
56
+ def is_auth_wall(f: Fetched) -> bool:
57
+ path = urlsplit(f.url).path.lower()
58
+ return f.status in (401, 403) or any(path.startswith(p) for p in _LOGIN_PATHS)
@@ -0,0 +1,136 @@
1
+ """Persistent curl_cffi connection pool with browser TLS / HTTP2 fingerprints."""
2
+
3
+ from dataclasses import dataclass
4
+ from email.message import Message
5
+ from urllib.parse import urljoin, urlsplit
6
+
7
+ import orjson
8
+ from curl_cffi.requests import AsyncSession
9
+
10
+ from agent_wire import __version__
11
+ from agent_wire.firewall.outbound_privacy import check_request
12
+
13
+ DEFAULT_TIMEOUT = 12 # a stalled stage should fail fast and let the waterfall escalate
14
+ USER_AGENT = f"agent-wire/{__version__} (+https://github.com/uncoalesced/agent-wire)"
15
+ MAX_COOKIE_REDIRECTS = 5
16
+ _TWO_LEVEL_TLDS = frozenset(
17
+ {"co.uk", "org.uk", "ac.uk", "com.au", "co.in", "co.jp", "com.cn", "com.br", "co.kr", "com.tw"}
18
+ )
19
+
20
+
21
+ def registrable_domain(host: str | None) -> str:
22
+ # shortcut: tiny 2-level TLD list instead of the Public Suffix List; upgrade if mis-grouping shows up.
23
+ labels = (host or "").lower().rstrip(".").split(".")
24
+ n = 3 if ".".join(labels[-2:]) in _TWO_LEVEL_TLDS else 2
25
+ return ".".join(labels[-n:])
26
+
27
+
28
+ @dataclass
29
+ class Fetched:
30
+ url: str
31
+ status: int
32
+ headers: dict[str, str]
33
+ content: bytes
34
+ requested: str = ""
35
+ stage: int = 1
36
+ auth: str = "none"
37
+
38
+ @property
39
+ def ok(self) -> bool:
40
+ return 200 <= self.status < 300
41
+
42
+ @property
43
+ def content_type(self) -> str:
44
+ return self.headers.get("content-type", "").split(";")[0].strip().lower()
45
+
46
+ @property
47
+ def text(self) -> str:
48
+ msg = Message()
49
+ msg["content-type"] = self.headers.get("content-type", "")
50
+ charset = msg.get_param("charset") or "utf-8"
51
+ try:
52
+ return self.content.decode(str(charset), errors="replace")
53
+ except LookupError:
54
+ return self.content.decode("utf-8", errors="replace")
55
+
56
+ def json(self):
57
+ return orjson.loads(self.content)
58
+
59
+
60
+ class HttpPool:
61
+ """One warm AsyncSession per fingerprint for anonymous traffic.
62
+
63
+ Cookie-bearing requests get a throwaway session so the user's session cookies (and anything
64
+ the site sets in reply) never end up in the shared anonymous cookie jars.
65
+ """
66
+
67
+ def __init__(self, timeout: float = DEFAULT_TIMEOUT):
68
+ self.timeout = timeout
69
+ self._sessions: dict[str | None, AsyncSession] = {}
70
+
71
+ def _session(self, impersonate: str | None) -> AsyncSession:
72
+ if impersonate not in self._sessions:
73
+ self._sessions[impersonate] = AsyncSession(impersonate=impersonate)
74
+ return self._sessions[impersonate]
75
+
76
+ async def close(self) -> None:
77
+ for s in self._sessions.values():
78
+ await s.close()
79
+ self._sessions.clear()
80
+
81
+ async def get(
82
+ self,
83
+ url: str,
84
+ *,
85
+ headers: dict[str, str] | None = None,
86
+ cookies: dict[str, str] | None = None,
87
+ impersonate: str | None = "chrome",
88
+ params: dict | None = None,
89
+ timeout: float | None = None,
90
+ method: str = "GET",
91
+ json_body: dict | None = None,
92
+ data: dict | None = None,
93
+ multipart=None,
94
+ ) -> Fetched:
95
+ check_request(method, url, authenticated=bool(cookies))
96
+ hdrs = dict(headers or {})
97
+ if impersonate is None:
98
+ hdrs.setdefault("User-Agent", USER_AGENT)
99
+ kwargs = {
100
+ "headers": hdrs,
101
+ "params": params,
102
+ "json": json_body,
103
+ "data": data,
104
+ "multipart": multipart,
105
+ "timeout": timeout or self.timeout,
106
+ }
107
+ if not cookies:
108
+ r = await self._session(impersonate).request(
109
+ method, url, allow_redirects=True, max_redirects=8, **kwargs
110
+ )
111
+ return _wrap(r, url)
112
+
113
+ # Follow redirects by hand so every hop is re-checked by the privacy jail and
114
+ # cookies never follow a redirect onto another site.
115
+ async with AsyncSession(impersonate=impersonate) as s:
116
+ current = url
117
+ for _ in range(MAX_COOKIE_REDIRECTS + 1):
118
+ r = await s.request(
119
+ method, current, cookies=cookies, allow_redirects=False, **kwargs
120
+ )
121
+ location = r.headers.get("location")
122
+ if r.status_code not in (301, 302, 303, 307, 308) or not location:
123
+ return _wrap(r, url)
124
+ nxt = urljoin(current, location)
125
+ if registrable_domain(urlsplit(nxt).hostname) != registrable_domain(
126
+ urlsplit(url).hostname
127
+ ):
128
+ return _wrap(r, url) # cross-site redirect: stop rather than leak cookies
129
+ check_request(method, nxt, authenticated=True)
130
+ current, kwargs["params"] = nxt, None
131
+ return _wrap(r, url)
132
+
133
+
134
+ def _wrap(r, requested: str) -> Fetched:
135
+ headers = {k.lower(): v for k, v in r.headers.items()}
136
+ return Fetched(str(r.url), r.status_code, headers, r.content, requested=requested)
@@ -0,0 +1,90 @@
1
+ """Optional stage 4: render in a local headless browser and harvest cf_clearance.
2
+
3
+ Needs the `stealth` extra (camoufox or playwright). The profile and the browser's own temp
4
+ files live in WIRE_HOME, not the OS temp dir.
5
+ """
6
+
7
+ import asyncio
8
+ import importlib.util
9
+ import os
10
+ from pathlib import Path
11
+ from urllib.parse import urlsplit
12
+
13
+ from agent_wire.engine.challenge import _HARD
14
+ from agent_wire.engine.http_pool import Fetched
15
+
16
+ NAV_TIMEOUT_MS = 30_000
17
+ SETTLE_MS = 6_000 # extra wait for XHR-rendered pages; never blocks on long-polling sites
18
+ CHALLENGE_WAIT_S = 20 # how long a JS challenge may take to clear itself
19
+
20
+
21
+ def browser_env(home: Path) -> dict[str, str]:
22
+ """Point the browser process's temp files at the tmp folder inside WIRE_HOME."""
23
+ tmp = home / "tmp"
24
+ tmp.mkdir(parents=True, exist_ok=True)
25
+ return {**os.environ, "TMP": str(tmp), "TEMP": str(tmp), "TMPDIR": str(tmp)}
26
+
27
+
28
+ class StealthBrowser:
29
+ def __init__(self, home: Path):
30
+ self.home = home
31
+ self.profile_dir = home / "browser-profile"
32
+
33
+ @staticmethod
34
+ def available() -> bool:
35
+ return bool(importlib.util.find_spec("camoufox") or importlib.util.find_spec("playwright"))
36
+
37
+ async def render(self, url: str) -> tuple[Fetched | None, str | None, str | None]:
38
+ if importlib.util.find_spec("camoufox"):
39
+ from camoufox.async_api import AsyncCamoufox
40
+
41
+ async with AsyncCamoufox(
42
+ headless=True, persistent_context=True, user_data_dir=str(self.profile_dir)
43
+ ) as context:
44
+ return await _capture(context, url)
45
+
46
+ from playwright.async_api import async_playwright
47
+
48
+ async with async_playwright() as p:
49
+ context = await p.chromium.launch_persistent_context(
50
+ str(self.profile_dir), headless=True, env=browser_env(self.home),
51
+ downloads_path=str(self.home / "tmp"),
52
+ ) # fmt: skip
53
+ try:
54
+ return await _capture(context, url)
55
+ finally:
56
+ await context.close()
57
+
58
+
59
+ async def _still_challenged(page) -> bool:
60
+ html = (await page.content())[:65_536].lower().encode()
61
+ return any(s in html for s in _HARD)
62
+
63
+
64
+ async def _capture(context, url: str):
65
+ page = await context.new_page()
66
+ response = await page.goto(url, wait_until="load", timeout=NAV_TIMEOUT_MS)
67
+ try:
68
+ await page.wait_for_load_state("networkidle", timeout=SETTLE_MS)
69
+ except Exception: # noqa: BLE001,S110 - playwright TimeoutError; long-polling never goes idle
70
+ pass
71
+ for _ in range(CHALLENGE_WAIT_S):
72
+ if not await _still_challenged(page):
73
+ break
74
+ await asyncio.sleep(1) # Cloudflare's JS challenge redirects to the page when solved
75
+ html = await page.content()
76
+ ua = await page.evaluate("navigator.userAgent")
77
+ host = urlsplit(url).hostname or ""
78
+ cf = next(
79
+ (
80
+ c["value"]
81
+ for c in await context.cookies()
82
+ if c["name"] == "cf_clearance" and host.endswith(c["domain"].lstrip("."))
83
+ ),
84
+ None,
85
+ )
86
+ status = response.status if response else 200
87
+ if status in (403, 503) and not await _still_challenged(page):
88
+ status = 200 # the challenge response itself was 403/503; the page we hold now is real
89
+ headers = {"content-type": "text/html; charset=utf-8"}
90
+ return Fetched(page.url, status, headers, html.encode(), requested=url), cf, ua