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.
- agent_wire/__init__.py +1 -0
- agent_wire/__main__.py +3 -0
- agent_wire/assets/SKILL.md +26 -0
- agent_wire/assets/cursor_rule.mdc +13 -0
- agent_wire/assets/hermes_skill.md +28 -0
- agent_wire/assets/windsurf_rules.md +14 -0
- agent_wire/assets/wire-worker.md +20 -0
- agent_wire/auth/__init__.py +0 -0
- agent_wire/auth/browser_paths.py +143 -0
- agent_wire/auth/cookie_vault.py +181 -0
- agent_wire/auth/rate_limiter.py +26 -0
- agent_wire/cache/__init__.py +0 -0
- agent_wire/cache/ring_buffer.py +94 -0
- agent_wire/cache/sqlite_ledger.py +132 -0
- agent_wire/channels/__init__.py +41 -0
- agent_wire/channels/base.py +249 -0
- agent_wire/channels/tier1_global/__init__.py +0 -0
- agent_wire/channels/tier1_global/github.py +371 -0
- agent_wire/channels/tier1_global/hackernews.py +135 -0
- agent_wire/channels/tier1_global/reddit.py +371 -0
- agent_wire/channels/tier1_global/rss.py +129 -0
- agent_wire/channels/tier1_global/search.py +160 -0
- agent_wire/channels/tier1_global/stackoverflow.py +94 -0
- agent_wire/channels/tier1_global/web.py +98 -0
- agent_wire/channels/tier1_global/wikipedia.py +91 -0
- agent_wire/channels/tier1_global/x_twitter.py +380 -0
- agent_wire/channels/tier1_global/youtube.py +223 -0
- agent_wire/channels/tier2_pro/__init__.py +0 -0
- agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
- agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
- agent_wire/channels/tier2_pro/devto.py +114 -0
- agent_wire/channels/tier2_pro/fediverse.py +181 -0
- agent_wire/channels/tier2_pro/hubs.py +161 -0
- agent_wire/channels/tier2_pro/linkedin.py +146 -0
- agent_wire/channels/tier2_pro/markets.py +207 -0
- agent_wire/channels/tier2_pro/meta_social.py +104 -0
- agent_wire/channels/tier2_pro/news.py +173 -0
- agent_wire/channels/tier2_pro/polymarket.py +105 -0
- agent_wire/channels/tier2_pro/publishing.py +84 -0
- agent_wire/channels/tier2_pro/stocktwits.py +79 -0
- agent_wire/channels/tier3_regional/bilibili.py +171 -0
- agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
- agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
- agent_wire/channels/tier3_regional/v2ex.py +70 -0
- agent_wire/channels/tier3_regional/wechat.py +67 -0
- agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
- agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
- agent_wire/channels/tier3_regional/xueqiu.py +77 -0
- agent_wire/channels/tier3_regional/zhihu.py +57 -0
- agent_wire/cli.py +403 -0
- agent_wire/compression/__init__.py +0 -0
- agent_wire/compression/anchor_slicer.py +45 -0
- agent_wire/compression/bm25_slicer.py +151 -0
- agent_wire/compression/dam_formatter.py +74 -0
- agent_wire/compression/url_compactor.py +45 -0
- agent_wire/config.py +52 -0
- agent_wire/engine/__init__.py +0 -0
- agent_wire/engine/challenge.py +58 -0
- agent_wire/engine/http_pool.py +136 -0
- agent_wire/engine/stealth_browser.py +90 -0
- agent_wire/engine/waterfall.py +159 -0
- agent_wire/firewall/__init__.py +0 -0
- agent_wire/firewall/inbound_shield.py +116 -0
- agent_wire/firewall/outbound_privacy.py +120 -0
- agent_wire/installers/__init__.py +0 -0
- agent_wire/installers/client_installer.py +315 -0
- agent_wire/media/__init__.py +0 -0
- agent_wire/media/audio_transcriber.py +77 -0
- agent_wire/media/subtitle_cleaner.py +102 -0
- agent_wire/models.py +60 -0
- agent_wire/research/__init__.py +1 -0
- agent_wire/research/brief.py +227 -0
- agent_wire/research/briefing.py +41 -0
- agent_wire/research/cluster.py +128 -0
- agent_wire/research/engine.py +632 -0
- agent_wire/research/library.py +74 -0
- agent_wire/research/planner.py +232 -0
- agent_wire/research/scoring.py +89 -0
- agent_wire/research/store.py +149 -0
- agent_wire/research/watch.py +70 -0
- agent_wire/router.py +107 -0
- agent_wire/server.py +468 -0
- agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
- agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
- agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
- agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
- 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
|