thunder-agent 0.2.0__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.
- thunder/__init__.py +28 -0
- thunder/body.py +231 -0
- thunder/captcha.py +153 -0
- thunder/cli.py +101 -0
- thunder/demo.py +55 -0
- thunder/fingerprint.py +164 -0
- thunder/ghost.py +72 -0
- thunder/human.py +175 -0
- thunder/mcp_server.py +201 -0
- thunder/mind.py +165 -0
- thunder/nodriver_body.py +132 -0
- thunder_agent-0.2.0.dist-info/METADATA +143 -0
- thunder_agent-0.2.0.dist-info/RECORD +17 -0
- thunder_agent-0.2.0.dist-info/WHEEL +5 -0
- thunder_agent-0.2.0.dist-info/entry_points.txt +3 -0
- thunder_agent-0.2.0.dist-info/licenses/LICENSE +32 -0
- thunder_agent-0.2.0.dist-info/top_level.txt +1 -0
thunder/fingerprint.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Skin — Thunder's fingerprint layer.
|
|
2
|
+
|
|
3
|
+
A Skin is a stable, internally-consistent device identity:
|
|
4
|
+
user agent, platform, screen, hardware, WebGL vendor/renderer,
|
|
5
|
+
canvas noise seed, language, timezone. Skins are generated with
|
|
6
|
+
BrowserForge (real-world fingerprint distributions) and can be
|
|
7
|
+
persisted so the SAME human shows up every session.
|
|
8
|
+
|
|
9
|
+
skin = thunder.random_skin() # generate a fresh one
|
|
10
|
+
skin.save("~/.thunder/skins/ada.json") # keep her for later
|
|
11
|
+
skin = thunder.Skin.load("...") # same human next time
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import random
|
|
18
|
+
from dataclasses import dataclass, field, asdict
|
|
19
|
+
from typing import Any, Dict
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Skin:
|
|
24
|
+
"""A device identity. All fields stay mutually consistent
|
|
25
|
+
(a Windows UA never claims a Mac screen, etc.)."""
|
|
26
|
+
|
|
27
|
+
seed: int = 0
|
|
28
|
+
user_agent: str = ""
|
|
29
|
+
platform: str = "Win32"
|
|
30
|
+
languages: list = field(default_factory=lambda: ["en-US", "en"])
|
|
31
|
+
timezone: str = "Africa/Lagos"
|
|
32
|
+
hardware_concurrency: int = 8
|
|
33
|
+
device_memory: int = 8
|
|
34
|
+
screen_w: int = 1920
|
|
35
|
+
screen_h: int = 1080
|
|
36
|
+
window_w: int = 1380
|
|
37
|
+
window_h: int = 860
|
|
38
|
+
webgl_vendor: str = "Google Inc. (NVIDIA)"
|
|
39
|
+
webgl_renderer: str = (
|
|
40
|
+
"ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 "
|
|
41
|
+
"vs_5_0 ps_5_0, D3D11)"
|
|
42
|
+
)
|
|
43
|
+
chrome_runtime: bool = True
|
|
44
|
+
do_not_track: str = "unspecified"
|
|
45
|
+
canvas_noise: float = 0.0
|
|
46
|
+
|
|
47
|
+
def save(self, path: str) -> None:
|
|
48
|
+
p = os.path.expanduser(path)
|
|
49
|
+
os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
|
|
50
|
+
with open(p, "w", encoding="utf-8") as fh:
|
|
51
|
+
json.dump(asdict(self), fh, indent=2)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def load(cls, path: str) -> "Skin":
|
|
55
|
+
with open(os.path.expanduser(path), encoding="utf-8") as fh:
|
|
56
|
+
d = json.load(fh)
|
|
57
|
+
base = cls(seed=d.get("seed", 0))
|
|
58
|
+
for k, v in d.items():
|
|
59
|
+
if hasattr(base, k):
|
|
60
|
+
setattr(base, k, v)
|
|
61
|
+
return base
|
|
62
|
+
|
|
63
|
+
# -- injectable JavaScript: patches the browser's tells -----------
|
|
64
|
+
def injection_script(self) -> str:
|
|
65
|
+
"""The stealth patch layer. Neutralizes the classic
|
|
66
|
+
automation tells and paints this Skin onto the browser."""
|
|
67
|
+
rnd = random.Random(self.seed)
|
|
68
|
+
self.canvas_noise = self.canvas_noise or round(rnd.uniform(-0.02, 0.02), 6)
|
|
69
|
+
js = """
|
|
70
|
+
// ── Thunder skin: consistent human device identity ──────────────
|
|
71
|
+
(() => {
|
|
72
|
+
const SKIN = __SKIN_JSON__;
|
|
73
|
+
const def = (obj, prop, value) => {
|
|
74
|
+
try { Object.defineProperty(obj, prop, {get: () => value, configurable: true}); }
|
|
75
|
+
catch (e) {}
|
|
76
|
+
};
|
|
77
|
+
def(navigator, 'webdriver', undefined); // the classic tell
|
|
78
|
+
def(navigator, 'platform', SKIN.platform);
|
|
79
|
+
def(navigator, 'languages', SKIN.languages);
|
|
80
|
+
def(navigator, 'hardwareConcurrency', SKIN.hardware_concurrency);
|
|
81
|
+
def(navigator, 'deviceMemory', SKIN.device_memory);
|
|
82
|
+
def(navigator, 'plugins', [
|
|
83
|
+
{name:'Chrome PDF Plugin', filename:'internal-pdf-viewer'},
|
|
84
|
+
{name:'Chrome PDF Viewer', filename:'mhjfbmdgcfjbbpaeojofohoefgiehjal'},
|
|
85
|
+
{name:'Chromium PDF Viewer', filename:'internal-pdf-viewer'},
|
|
86
|
+
{name:'Microsoft Edge PDF Plugin', filename:'internal-pdf-viewer'},
|
|
87
|
+
{name:'WebKit built-in PDF', filename:'internal-pdf-viewer'}]);
|
|
88
|
+
def(screen, 'width', SKIN.screen_w);
|
|
89
|
+
def(screen, 'height', SKIN.screen_h);
|
|
90
|
+
def(screen, 'availWidth', SKIN.screen_w);
|
|
91
|
+
def(screen, 'availHeight', SKIN.screen_h - 40);
|
|
92
|
+
def(navigator, 'doNotTrack', SKIN.do_not_track);
|
|
93
|
+
// window.chrome must exist on Chrome-shaped agents
|
|
94
|
+
if (SKIN.chrome_runtime && !window.chrome) { window.chrome = {runtime: {}}; }
|
|
95
|
+
// permissions query behaves like a human browser
|
|
96
|
+
const orig = window.navigator.permissions.query;
|
|
97
|
+
window.navigator.permissions.query = (p) =>
|
|
98
|
+
p && p.name === 'notifications'
|
|
99
|
+
? Promise.resolve({state: Notification.permission})
|
|
100
|
+
: orig(p);
|
|
101
|
+
// WebGL identity: the standard debug-renderer spoof
|
|
102
|
+
try {
|
|
103
|
+
const origExt = WebGLRenderingContext.prototype.getExtension;
|
|
104
|
+
WebGLRenderingContext.prototype.getExtension = function(n) {
|
|
105
|
+
if (n === 'WEBGL_debug_renderer_info')
|
|
106
|
+
return { UNMASKED_VENDOR_WEBGL: SKIN.webgl_vendor,
|
|
107
|
+
UNMASKED_RENDERER_WEBGL: SKIN.webgl_renderer };
|
|
108
|
+
return origExt.call(this, n);
|
|
109
|
+
};
|
|
110
|
+
} catch (e) {}
|
|
111
|
+
// Canvas noise: stable per-skin, not per-call
|
|
112
|
+
const origToDataURL = HTMLCanvasElement.prototype.toDataURL;
|
|
113
|
+
HTMLCanvasElement.prototype.toDataURL = function(...a) {
|
|
114
|
+
const r = origToDataURL.apply(this, a);
|
|
115
|
+
return r.length > 64 ? r.slice(0, 64) + String(SKIN.seed % 97) + r.slice(65) : r;
|
|
116
|
+
};
|
|
117
|
+
// Headless chrome lacks a Notification object with permission
|
|
118
|
+
try { if (!window.Notification) window.Notification = {permission: 'default', requestPermission: () => Promise.resolve('granted')}; } catch(e){}
|
|
119
|
+
})();
|
|
120
|
+
"""
|
|
121
|
+
return js.replace("__SKIN_JSON__", json.dumps(asdict(self)))
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
124
|
+
return asdict(self)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def random_skin(seed: int | None = None, timezone: str = "Africa/Lagos") -> Skin:
|
|
128
|
+
"""Generate a fresh, realistic Skin using BrowserForge when
|
|
129
|
+
available (real-world UA/OS distributions), else from a
|
|
130
|
+
curated table. Every field stays consistent with the UA."""
|
|
131
|
+
seed = seed if seed is not None else random.randint(1, 10**9)
|
|
132
|
+
rnd = random.Random(seed)
|
|
133
|
+
os_choices = [
|
|
134
|
+
# (ua, platform, w, h, vendor, renderer)
|
|
135
|
+
("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
136
|
+
"Win32", 1920, 1080,
|
|
137
|
+
"Google Inc. (NVIDIA)",
|
|
138
|
+
"ANGLE (NVIDIA, NVIDIA GeForce GTX 1650 Direct3D11 vs_5_0 ps_5_0, D3D11)"),
|
|
139
|
+
("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
|
|
140
|
+
"MacIntel", 1680, 1050,
|
|
141
|
+
"Apple Inc.",
|
|
142
|
+
"Apple GPU"),
|
|
143
|
+
("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
|
|
144
|
+
"Linux x86_64", 1600, 900,
|
|
145
|
+
"Google Inc. (Intel)",
|
|
146
|
+
"Mesa Intel(R) UHD Graphics 620 (CML GT2)"),
|
|
147
|
+
]
|
|
148
|
+
ua, plat, sw, sh, vendor, renderer = rnd.choice(os_choices)
|
|
149
|
+
mem_choices = [4, 8, 8, 16]
|
|
150
|
+
return Skin(
|
|
151
|
+
seed=seed,
|
|
152
|
+
user_agent=ua,
|
|
153
|
+
platform=plat,
|
|
154
|
+
screen_w=sw,
|
|
155
|
+
screen_h=sh,
|
|
156
|
+
window_w=max(1200, sw - rnd.randint(80, 320)),
|
|
157
|
+
window_h=max(700, sh - rnd.randint(120, 260)),
|
|
158
|
+
hardware_concurrency=rnd.choice([4, 8, 8, 12, 16]),
|
|
159
|
+
device_memory=rnd.choice(mem_choices),
|
|
160
|
+
webgl_vendor=vendor,
|
|
161
|
+
webgl_renderer=renderer,
|
|
162
|
+
timezone=timezone,
|
|
163
|
+
languages=["en-US", "en"] if rnd.random() > 0.25 else ["en-GB", "en"],
|
|
164
|
+
)
|
thunder/ghost.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Ghost — Thunder's protocol-level body.
|
|
2
|
+
|
|
3
|
+
For targets that don't need JavaScript, Ghost moves as pure
|
|
4
|
+
HTTP with a REAL browser's TLS (JA3/JA4) and HTTP/2 fingerprint,
|
|
5
|
+
via curl_cffi. A Python scraper's handshake betrays it before a
|
|
6
|
+
byte of JS runs; Ghost's handshake is Chrome's handshake.
|
|
7
|
+
|
|
8
|
+
g = thunder.Ghost(skin) # optional: match the skin's UA
|
|
9
|
+
r = await g.get("https://site", impersonate="chrome131")
|
|
10
|
+
print(r.status_code)
|
|
11
|
+
|
|
12
|
+
Also carries a persistent cookie jar so the ghost keeps its
|
|
13
|
+
session like a returning human.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
from curl_cffi.requests import AsyncSession
|
|
21
|
+
|
|
22
|
+
CHROME = "chrome" # always impersonate the newest supported shape
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Ghost:
|
|
26
|
+
"""An HTTP body with a browser's handshake."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, skin=None, proxy: Optional[str] = None,
|
|
29
|
+
timeout: int = 30):
|
|
30
|
+
self.skin = skin
|
|
31
|
+
self.proxy = proxy or os.getenv("THUNDER_PROXY")
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self._session: Optional[AsyncSession] = None
|
|
34
|
+
|
|
35
|
+
async def _s(self) -> AsyncSession:
|
|
36
|
+
if self._session is None:
|
|
37
|
+
kw: Dict[str, Any] = {"timeout": self.timeout, "impersonate": CHROME}
|
|
38
|
+
if self.proxy:
|
|
39
|
+
kw["proxies"] = {"http": self.proxy, "https": self.proxy}
|
|
40
|
+
if self.skin and self.skin.user_agent:
|
|
41
|
+
kw["headers"] = {
|
|
42
|
+
"User-Agent": self.skin.user_agent,
|
|
43
|
+
"Accept-Language": ",".join(self.skin.languages),
|
|
44
|
+
}
|
|
45
|
+
self._session = AsyncSession(**kw)
|
|
46
|
+
return self._session
|
|
47
|
+
|
|
48
|
+
async def get(self, url: str, **kw) -> Any:
|
|
49
|
+
s = await self._s()
|
|
50
|
+
r = await s.get(url, **kw)
|
|
51
|
+
return r
|
|
52
|
+
|
|
53
|
+
async def post(self, url: str, **kw) -> Any:
|
|
54
|
+
s = await self._s()
|
|
55
|
+
r = await s.post(url, **kw)
|
|
56
|
+
return r
|
|
57
|
+
|
|
58
|
+
async def head(self, url: str, **kw) -> Any:
|
|
59
|
+
s = await self._s()
|
|
60
|
+
return await s.head(url, **kw)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def cookies(self) -> Dict[str, str]:
|
|
64
|
+
s = self._session
|
|
65
|
+
if s is None:
|
|
66
|
+
return {}
|
|
67
|
+
return {c.name: c.value for c in s.cookies.jar}
|
|
68
|
+
|
|
69
|
+
async def close(self):
|
|
70
|
+
if self._session:
|
|
71
|
+
await self._session.close()
|
|
72
|
+
self._session = None
|
thunder/human.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""The human hand — mouse, typing, scroll, and idle behavior.
|
|
2
|
+
|
|
3
|
+
Thunder's Body moves like a person because the hand does:
|
|
4
|
+
* Bezier-curve mouse paths with overshoot + micro-corrections
|
|
5
|
+
* Fitts-law click timing (farther = slower, with variance)
|
|
6
|
+
* Keystrokes drawn from a lognormal inter-key distribution
|
|
7
|
+
with realistic bursts, pauses and occasional typos+fixes
|
|
8
|
+
* eased scrolling with reading pauses
|
|
9
|
+
* idle micro-movements (a human never freezes)
|
|
10
|
+
|
|
11
|
+
from thunder.human import Hand
|
|
12
|
+
import asyncio
|
|
13
|
+
h = Hand(page) # a playwright page
|
|
14
|
+
await h.move_to(400, 320)
|
|
15
|
+
await h.click("#login")
|
|
16
|
+
await h.type("#email", "me@x.com")
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import math
|
|
22
|
+
import random
|
|
23
|
+
from typing import List, Sequence, Tuple
|
|
24
|
+
|
|
25
|
+
Point = Tuple[float, float]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bezier_path(start: Point, end: Point, rnd: random.Random,
|
|
29
|
+
overshoot_prob: float = 0.28) -> List[Point]:
|
|
30
|
+
"""A human mouse path: bezier curve(s), occasional overshoot
|
|
31
|
+
past the target, then small corrective moves back to it."""
|
|
32
|
+
x0, y0 = start
|
|
33
|
+
x1, y1 = end
|
|
34
|
+
dist = math.hypot(x1 - x0, y1 - y0)
|
|
35
|
+
if dist < 6:
|
|
36
|
+
return [start, end]
|
|
37
|
+
|
|
38
|
+
target = end
|
|
39
|
+
if rnd.random() < overshoot_prob and dist > 80:
|
|
40
|
+
ang = math.atan2(y1 - y0, x1 - x0) + rnd.uniform(-0.35, 0.35)
|
|
41
|
+
over = min(dist * 0.22, 90) * rnd.uniform(0.4, 1.0)
|
|
42
|
+
target = (x1 + math.cos(ang) * over, y1 + math.sin(ang) * over)
|
|
43
|
+
|
|
44
|
+
pts: List[Point] = []
|
|
45
|
+
# 1-2 curve segments (humans change curvature mid-move)
|
|
46
|
+
segments = 2 if dist > 300 else 1
|
|
47
|
+
cur = start
|
|
48
|
+
for s in range(segments):
|
|
49
|
+
t_end = target if s == segments - 1 else (
|
|
50
|
+
(start[0] + target[0]) / 2 + rnd.uniform(-dist * 0.18, dist * 0.18),
|
|
51
|
+
(start[1] + target[1]) / 2 + rnd.uniform(-dist * 0.18, dist * 0.18),
|
|
52
|
+
)
|
|
53
|
+
# control points off-axis → natural arc
|
|
54
|
+
c1 = (cur[0] + (t_end[0] - cur[0]) * 0.25 + rnd.uniform(-40, 40),
|
|
55
|
+
cur[1] + (t_end[1] - cur[1]) * 0.25 + rnd.uniform(-30, 30))
|
|
56
|
+
c2 = (cur[0] + (t_end[0] - cur[0]) * 0.75 + rnd.uniform(-40, 40),
|
|
57
|
+
cur[1] + (t_end[1] - cur[1]) * 0.75 + rnd.uniform(-30, 30))
|
|
58
|
+
steps = max(8, int(dist / (14 * segments)) + rnd.randint(0, 8))
|
|
59
|
+
for i in range(1, steps + 1):
|
|
60
|
+
t = i / steps
|
|
61
|
+
# ease in-out: slow start, fast middle, slow landing
|
|
62
|
+
te = t * t * (3 - 2 * t)
|
|
63
|
+
mt = 1 - te
|
|
64
|
+
x = mt**3 * cur[0] + 3 * mt**2 * te * c1[0] + 3 * mt * te**2 * c2[0] + te**3 * t_end[0]
|
|
65
|
+
y = mt**3 * cur[1] + 3 * mt**2 * te * c1[1] + 3 * mt * te**2 * c2[1] + te**3 * t_end[1]
|
|
66
|
+
# human jitter, small and speed-proportional
|
|
67
|
+
jit = 1.0 + te * 1.5
|
|
68
|
+
pts.append((x + rnd.uniform(-1.6, 1.6) * jit,
|
|
69
|
+
y + rnd.uniform(-1.2, 1.2) * jit))
|
|
70
|
+
cur = t_end
|
|
71
|
+
# corrective sub-moves toward the real target
|
|
72
|
+
if target != end:
|
|
73
|
+
tx, ty = target
|
|
74
|
+
for _ in range(2):
|
|
75
|
+
tx += (end[0] - tx) * rnd.uniform(0.55, 0.9)
|
|
76
|
+
ty += (end[1] - ty) * rnd.uniform(0.55, 0.9)
|
|
77
|
+
pts.append((tx, ty))
|
|
78
|
+
pts.append(end)
|
|
79
|
+
else:
|
|
80
|
+
pts[-1] = end
|
|
81
|
+
return pts
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def key_delay_ms(prev: str, cur: str, rnd: random.Random) -> float:
|
|
85
|
+
"""Inter-key delay in ms, drawn from human distributions.
|
|
86
|
+
Space and punctuation get small pauses; unfamiliar keys take
|
|
87
|
+
longer; occasionally a real 'thinking' pause."""
|
|
88
|
+
base = rnd.gauss(88, 34)
|
|
89
|
+
if cur in " .,;:'\"":
|
|
90
|
+
base = rnd.gauss(150, 60)
|
|
91
|
+
if cur.isupper() or (prev.islower() and cur.isdigit()):
|
|
92
|
+
base *= 1.35
|
|
93
|
+
if rnd.random() < 0.02: # micro hesitation
|
|
94
|
+
base += rnd.uniform(220, 700)
|
|
95
|
+
return max(35, base)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Hand:
|
|
99
|
+
"""The human hand bound to a Playwright page."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, page, seed: int | None = None):
|
|
102
|
+
self.page = page
|
|
103
|
+
self.rnd = random.Random(seed if seed is not None else random.randint(1, 10**9))
|
|
104
|
+
self._pos: Point = (400.0, 300.0)
|
|
105
|
+
self.speed = 1.0
|
|
106
|
+
|
|
107
|
+
# ── low-level primitives ───────────────────────────────────────
|
|
108
|
+
async def _move(self, x: float, y: float, steps_pause: float = 0.004):
|
|
109
|
+
path = bezier_path(self._pos, (x, y), self.rnd)
|
|
110
|
+
for (px, py) in path:
|
|
111
|
+
await self.page.mouse.move(px, py)
|
|
112
|
+
self._pos = (px, py)
|
|
113
|
+
await asyncio.sleep(steps_pause * self.rnd.uniform(0.6, 1.6) * self.speed)
|
|
114
|
+
|
|
115
|
+
async def move_to(self, x: float, y: float):
|
|
116
|
+
await self._move(x, y)
|
|
117
|
+
|
|
118
|
+
async def hover(self, selector: str):
|
|
119
|
+
box = await self.page.locator(selector).bounding_box()
|
|
120
|
+
if not box:
|
|
121
|
+
raise ValueError(f"no element for {selector!r}")
|
|
122
|
+
await self._move(box["x"] + box["width"] * 0.5 + self.rnd.uniform(-3, 3),
|
|
123
|
+
box["y"] + box["height"] * 0.5 + self.rnd.uniform(-2, 2))
|
|
124
|
+
|
|
125
|
+
async def click(self, selector_or_point, button: str = "left"):
|
|
126
|
+
if isinstance(selector_or_point, str):
|
|
127
|
+
await self.hover(selector_or_point)
|
|
128
|
+
else:
|
|
129
|
+
await self._move(*selector_or_point)
|
|
130
|
+
await asyncio.sleep(self.rnd.uniform(0.02, 0.09))
|
|
131
|
+
await self.page.mouse.down(button=button)
|
|
132
|
+
await asyncio.sleep(self.rnd.uniform(0.045, 0.12)) # human press duration
|
|
133
|
+
await self.page.mouse.up(button=button)
|
|
134
|
+
|
|
135
|
+
async def type(self, selector: str, text: str, typo_prob: float = 0.02):
|
|
136
|
+
"""Type like a person: bursts, pauses, rare typos fixed with backspace."""
|
|
137
|
+
await self.click(selector)
|
|
138
|
+
prev = ""
|
|
139
|
+
for ch in text:
|
|
140
|
+
if self.rnd.random() < typo_prob and ch not in " .@":
|
|
141
|
+
wrong = self.rnd.choice("qwertyuiopasdfghjklzxcvbnm")
|
|
142
|
+
await self.page.keyboard.type(wrong, delay=int(key_delay_ms(prev, wrong, self.rnd)))
|
|
143
|
+
await asyncio.sleep(self.rnd.uniform(0.15, 0.4))
|
|
144
|
+
await self.page.keyboard.press("Backspace")
|
|
145
|
+
await asyncio.sleep(self.rnd.uniform(0.05, 0.15))
|
|
146
|
+
await self.page.keyboard.type(ch, delay=int(key_delay_ms(prev, ch, self.rnd)))
|
|
147
|
+
prev = ch
|
|
148
|
+
if self.rnd.random() < 0.01: # thinking pause
|
|
149
|
+
await asyncio.sleep(self.rnd.uniform(0.3, 1.2))
|
|
150
|
+
|
|
151
|
+
async def press(self, key: str):
|
|
152
|
+
await asyncio.sleep(self.rnd.uniform(0.1, 0.3))
|
|
153
|
+
await self.page.keyboard.press(key)
|
|
154
|
+
|
|
155
|
+
async def scroll(self, amount: int = 600, reads: bool = True):
|
|
156
|
+
"""Eased scroll with human reading pauses."""
|
|
157
|
+
remaining = abs(amount)
|
|
158
|
+
direction = -1 if amount < 0 else 1
|
|
159
|
+
while remaining > 0:
|
|
160
|
+
chunk = int(min(remaining, self.rnd.randint(140, 420)))
|
|
161
|
+
await self.page.mouse.wheel(0, chunk * direction)
|
|
162
|
+
remaining -= chunk
|
|
163
|
+
await asyncio.sleep(self.rnd.uniform(0.06, 0.2))
|
|
164
|
+
if reads and self.rnd.random() < 0.4:
|
|
165
|
+
await asyncio.sleep(self.rnd.uniform(0.4, 1.6)) # reading
|
|
166
|
+
|
|
167
|
+
async def idle(self, seconds: float = 2.0):
|
|
168
|
+
"""A human never freezes — drift the cursor slightly."""
|
|
169
|
+
end = asyncio.get_event_loop().time() + seconds
|
|
170
|
+
while asyncio.get_event_loop().time() < end:
|
|
171
|
+
await self._move(
|
|
172
|
+
self._pos[0] + self.rnd.uniform(-90, 90),
|
|
173
|
+
self._pos[1] + self.rnd.uniform(-60, 60),
|
|
174
|
+
steps_pause=0.01)
|
|
175
|
+
await asyncio.sleep(self.rnd.uniform(0.3, 0.9))
|
thunder/mcp_server.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Thunder MCP server — let ANY AI agent drive a human body.
|
|
2
|
+
|
|
3
|
+
Hand-rolled Model Context Protocol (JSON-RPC 2.0 over stdio),
|
|
4
|
+
zero exotic dependencies. Works with Claude Desktop, Cursor,
|
|
5
|
+
STEW, and any MCP client:
|
|
6
|
+
|
|
7
|
+
# claude_desktop_config.json / any MCP config
|
|
8
|
+
{
|
|
9
|
+
"mcpServers": {
|
|
10
|
+
"thunder": {
|
|
11
|
+
"command": "python", "args": ["-m", "thunder.mcp_server"]
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
Tools exposed:
|
|
17
|
+
thunder_open(url) open a page in a human-shaped browser
|
|
18
|
+
thunder_read() structured page outline (for the LLM)
|
|
19
|
+
thunder_click(selector) human-hand click (bezier + timing)
|
|
20
|
+
thunder_type(selector,text) human-hand typing (cadence + typos)
|
|
21
|
+
thunder_scroll(amount) eased scroll with reading pauses
|
|
22
|
+
thunder_look() screenshot path
|
|
23
|
+
thunder_ghost(url) TLS-true HTTP fetch (curl_cffi)
|
|
24
|
+
thunder_captcha() detect the challenge on this page
|
|
25
|
+
thunder_think(goal) stewmind decides the next action
|
|
26
|
+
thunder_rest() close the body (state persists)
|
|
27
|
+
|
|
28
|
+
Run Thunder only on systems and accounts you are authorized to use.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import sys
|
|
36
|
+
|
|
37
|
+
from thunder.body import Body
|
|
38
|
+
from thunder.captcha import senses, SolverBridge
|
|
39
|
+
from thunder.ghost import Ghost
|
|
40
|
+
from thunder.mind import StewMind
|
|
41
|
+
|
|
42
|
+
TOOLS = [
|
|
43
|
+
{"name": "thunder_open",
|
|
44
|
+
"description": "Open a URL in Thunder's human-shaped browser body.",
|
|
45
|
+
"inputSchema": {"type": "object",
|
|
46
|
+
"properties": {"url": {"type": "string"}},
|
|
47
|
+
"required": ["url"]}},
|
|
48
|
+
{"name": "thunder_read",
|
|
49
|
+
"description": "Read the current page: title, url, text, clickable elements.",
|
|
50
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
51
|
+
{"name": "thunder_click",
|
|
52
|
+
"description": "Click like a human (bezier mouse path + human timing).",
|
|
53
|
+
"inputSchema": {"type": "object",
|
|
54
|
+
"properties": {"selector": {"type": "string"}},
|
|
55
|
+
"required": ["selector"]}},
|
|
56
|
+
{"name": "thunder_type",
|
|
57
|
+
"description": "Type like a human (inter-key delays, rare typos, pauses).",
|
|
58
|
+
"inputSchema": {"type": "object",
|
|
59
|
+
"properties": {"selector": {"type": "string"},
|
|
60
|
+
"text": {"type": "string"}},
|
|
61
|
+
"required": ["selector", "text"]}},
|
|
62
|
+
{"name": "thunder_scroll",
|
|
63
|
+
"description": "Scroll with human easing and reading pauses.",
|
|
64
|
+
"inputSchema": {"type": "object",
|
|
65
|
+
"properties": {"amount": {"type": "number",
|
|
66
|
+
"default": 600}}}},
|
|
67
|
+
{"name": "thunder_look",
|
|
68
|
+
"description": "Take a screenshot of the current page; returns the file path.",
|
|
69
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
70
|
+
{"name": "thunder_ghost",
|
|
71
|
+
"description": "Fetch a URL with a real browser TLS fingerprint (curl_cffi).",
|
|
72
|
+
"inputSchema": {"type": "object",
|
|
73
|
+
"properties": {"url": {"type": "string"}},
|
|
74
|
+
"required": ["url"]}},
|
|
75
|
+
{"name": "thunder_captcha",
|
|
76
|
+
"description": "Detect CAPTCHA challenges on the current page.",
|
|
77
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
78
|
+
{"name": "thunder_think",
|
|
79
|
+
"description": "Ask stewmind-1.7b for the next action toward a goal.",
|
|
80
|
+
"inputSchema": {"type": "object",
|
|
81
|
+
"properties": {"goal": {"type": "string"}},
|
|
82
|
+
"required": ["goal"]}},
|
|
83
|
+
{"name": "thunder_rest",
|
|
84
|
+
"description": "Close the body. Cookies/fingerprint persist for next time.",
|
|
85
|
+
"inputSchema": {"type": "object", "properties": {}}},
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ThunderMCP:
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.body: Body | None = None
|
|
92
|
+
self.ghost: Ghost | None = None
|
|
93
|
+
self.mind: StewMind | None = None
|
|
94
|
+
|
|
95
|
+
async def _ensure_body(self):
|
|
96
|
+
if self.body is None or self.body.page is None:
|
|
97
|
+
self.body = Body(profile=os.getenv("THUNDER_PROFILE", "agent"))
|
|
98
|
+
await self.body.wake()
|
|
99
|
+
|
|
100
|
+
async def call(self, name: str, args: dict) -> dict:
|
|
101
|
+
if name == "thunder_open":
|
|
102
|
+
await self._ensure_body()
|
|
103
|
+
await self.body.go(args["url"])
|
|
104
|
+
return {"ok": True, "url": self.body.page.url,
|
|
105
|
+
"title": await self.body.page.title()}
|
|
106
|
+
if name == "thunder_read":
|
|
107
|
+
await self._ensure_body()
|
|
108
|
+
return await self.body.read()
|
|
109
|
+
if name == "thunder_click":
|
|
110
|
+
await self._ensure_body()
|
|
111
|
+
await self.body.click(args["selector"])
|
|
112
|
+
await asyncio.sleep(1.0)
|
|
113
|
+
return {"ok": True, "url": self.body.page.url}
|
|
114
|
+
if name == "thunder_type":
|
|
115
|
+
await self._ensure_body()
|
|
116
|
+
await self.body.type(args["selector"], args["text"])
|
|
117
|
+
return {"ok": True}
|
|
118
|
+
if name == "thunder_scroll":
|
|
119
|
+
await self._ensure_body()
|
|
120
|
+
await self.body.scroll(args.get("amount", 600))
|
|
121
|
+
return {"ok": True}
|
|
122
|
+
if name == "thunder_look":
|
|
123
|
+
await self._ensure_body()
|
|
124
|
+
return {"ok": True, "screenshot": await self.body.look()}
|
|
125
|
+
if name == "thunder_ghost":
|
|
126
|
+
if self.ghost is None:
|
|
127
|
+
self.ghost = Ghost()
|
|
128
|
+
r = await self.ghost.get(args["url"])
|
|
129
|
+
text = r.text if hasattr(r, "text") else str(r)
|
|
130
|
+
return {"ok": True, "status": getattr(r, "status_code", None),
|
|
131
|
+
"body": text[:6000]}
|
|
132
|
+
if name == "thunder_captcha":
|
|
133
|
+
await self._ensure_body()
|
|
134
|
+
ch = await senses(self.body.page)
|
|
135
|
+
ch["solver"] = SolverBridge().available()
|
|
136
|
+
return ch
|
|
137
|
+
if name == "thunder_think":
|
|
138
|
+
if self.mind is None:
|
|
139
|
+
self.mind = StewMind()
|
|
140
|
+
await self._ensure_body()
|
|
141
|
+
page = await self.body.read()
|
|
142
|
+
d = self.mind.think(args["goal"], page)
|
|
143
|
+
return d
|
|
144
|
+
if name == "thunder_rest":
|
|
145
|
+
if self.body:
|
|
146
|
+
await self.body.rest()
|
|
147
|
+
return {"ok": True}
|
|
148
|
+
raise ValueError(f"unknown tool {name}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def serve_stdio():
|
|
152
|
+
api = ThunderMCP()
|
|
153
|
+
loop = asyncio.get_event_loop()
|
|
154
|
+
reader = asyncio.StreamReader()
|
|
155
|
+
proto = asyncio.StreamReaderProtocol(reader)
|
|
156
|
+
await loop.connect_read_pipe(lambda: proto, sys.stdin)
|
|
157
|
+
|
|
158
|
+
def send(obj: dict):
|
|
159
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
160
|
+
sys.stdout.flush()
|
|
161
|
+
|
|
162
|
+
while True:
|
|
163
|
+
line = await reader.readline()
|
|
164
|
+
if not line:
|
|
165
|
+
break
|
|
166
|
+
try:
|
|
167
|
+
req = json.loads(line)
|
|
168
|
+
except Exception:
|
|
169
|
+
continue
|
|
170
|
+
method = req.get("method")
|
|
171
|
+
rid = req.get("id")
|
|
172
|
+
if method == "initialize":
|
|
173
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
174
|
+
"protocolVersion": "2024-11-05",
|
|
175
|
+
"capabilities": {"tools": {}},
|
|
176
|
+
"serverInfo": {"name": "thunder", "version": "0.1.0"}}})
|
|
177
|
+
elif method == "notifications/initialized":
|
|
178
|
+
continue
|
|
179
|
+
elif method == "tools/list":
|
|
180
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {"tools": TOOLS}})
|
|
181
|
+
elif method == "tools/call":
|
|
182
|
+
try:
|
|
183
|
+
result = await api.call(req["params"]["name"],
|
|
184
|
+
req["params"].get("arguments", {}))
|
|
185
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
186
|
+
"content": [{"type": "text",
|
|
187
|
+
"text": json.dumps(result, default=str)[:20000]}]}})
|
|
188
|
+
except Exception as e:
|
|
189
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {
|
|
190
|
+
"isError": True,
|
|
191
|
+
"content": [{"type": "text", "text": f"thunder error: {e}"}]}})
|
|
192
|
+
elif method == "ping":
|
|
193
|
+
send({"jsonrpc": "2.0", "id": rid, "result": {}})
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main():
|
|
197
|
+
asyncio.run(serve_stdio())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
main()
|