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/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Thunder — the human engine for AI agents.
|
|
2
|
+
|
|
3
|
+
Thunder gives agents a human-shaped presence on the web:
|
|
4
|
+
a stealth browser body, a fingerprint skin, a human hand
|
|
5
|
+
(mouse + typing), a ghost-mode TLS layer, a CAPTCHA bridge,
|
|
6
|
+
and a mind (stewmind-1.7b).
|
|
7
|
+
|
|
8
|
+
import thunder
|
|
9
|
+
|
|
10
|
+
body = thunder.Body() # a human-shaped browser
|
|
11
|
+
await body.wake("https://example.com") # open like a person
|
|
12
|
+
await body.type("#q", "hello world") # type like a person
|
|
13
|
+
shot = await body.look() # see the page
|
|
14
|
+
|
|
15
|
+
Dual-licensed spirit: MIT core, optional AGPL engines (nodriver).
|
|
16
|
+
Use Thunder on systems and accounts you are authorized to use.
|
|
17
|
+
"""
|
|
18
|
+
try: # optional strongest engine (needs real Chrome + pip install nodriver)
|
|
19
|
+
from thunder.nodriver_body import NodriverBody
|
|
20
|
+
except Exception: # pragma: no cover
|
|
21
|
+
NodriverBody = None
|
|
22
|
+
from thunder.body import Body
|
|
23
|
+
from thunder.ghost import Ghost
|
|
24
|
+
from thunder.mind import StewMind
|
|
25
|
+
from thunder.fingerprint import Skin, random_skin
|
|
26
|
+
|
|
27
|
+
__version__ = "0.2.0"
|
|
28
|
+
__all__ = ["Body", "Ghost", "StewMind", "Skin", "random_skin", "NodriverBody", "__version__"]
|
thunder/body.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Body — Thunder's browser body.
|
|
2
|
+
|
|
3
|
+
A Body is a full human-shaped browser session:
|
|
4
|
+
* a Skin (consistent device fingerprint)
|
|
5
|
+
* stealth patches injected on every page (init script)
|
|
6
|
+
* a Hand (human mouse/typing/scroll)
|
|
7
|
+
* eyes (screenshots, structured page reading)
|
|
8
|
+
* optional video recording of everything it does
|
|
9
|
+
* persistent context (cookies/localStorage) per profile
|
|
10
|
+
|
|
11
|
+
Engines:
|
|
12
|
+
* "playwright-stealth" — default, zero heavy deps, works everywhere
|
|
13
|
+
* "nodriver" — optional, strongest anti-detection (AGPL-3
|
|
14
|
+
engine; see README license notes)
|
|
15
|
+
|
|
16
|
+
import thunder
|
|
17
|
+
body = thunder.Body(profile="ada") # persistent human profile
|
|
18
|
+
await body.wake("https://example.com")
|
|
19
|
+
await body.type("input[name=q]", "thunder agent")
|
|
20
|
+
shot_path = await body.look()
|
|
21
|
+
await body.rest()
|
|
22
|
+
|
|
23
|
+
Run Thunder only on systems and accounts you are authorized to use.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import random
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
from .fingerprint import Skin, random_skin
|
|
36
|
+
from .human import Hand
|
|
37
|
+
|
|
38
|
+
THUNDER_HOME = Path(os.getenv("THUNDER_HOME", Path.home() / ".thunder"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Body:
|
|
42
|
+
def __init__(self, profile: str = "default", skin: Optional[Skin] = None,
|
|
43
|
+
engine: str = "playwright-stealth", headless: bool = True,
|
|
44
|
+
record_video: bool = False, viewport: Optional[dict] = None,
|
|
45
|
+
locale: str = "en-US", timezone: str = "Africa/Lagos"):
|
|
46
|
+
self.profile = profile
|
|
47
|
+
self.engine = engine
|
|
48
|
+
self.headless = headless
|
|
49
|
+
self.viewport = viewport
|
|
50
|
+
self.record = record_video
|
|
51
|
+
self.locale = locale
|
|
52
|
+
self.timezone = timezone
|
|
53
|
+
self.user_data_dir = THUNDER_HOME / "profiles" / profile
|
|
54
|
+
self.user_data_dir.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
skin_path = self.user_data_dir / "skin.json"
|
|
56
|
+
if skin is None:
|
|
57
|
+
if skin_path.exists():
|
|
58
|
+
self.skin = Skin.load(str(skin_path))
|
|
59
|
+
else:
|
|
60
|
+
self.skin = random_skin(timezone=timezone)
|
|
61
|
+
self.skin.save(str(skin_path))
|
|
62
|
+
else:
|
|
63
|
+
self.skin = skin
|
|
64
|
+
self.skin.save(str(skin_path))
|
|
65
|
+
self._pw = None
|
|
66
|
+
self._browser = None
|
|
67
|
+
self._context = None
|
|
68
|
+
self.page = None
|
|
69
|
+
self.hand: Optional[Hand] = None
|
|
70
|
+
self._video_dir = THUNDER_HOME / "videos"
|
|
71
|
+
|
|
72
|
+
# ── lifecycle ──────────────────────────────────────────────────
|
|
73
|
+
async def wake(self, url: Optional[str] = None):
|
|
74
|
+
"""Start the body's browser; optionally open a first page."""
|
|
75
|
+
from playwright.async_api import async_playwright
|
|
76
|
+
|
|
77
|
+
self._pw = await async_playwright().start()
|
|
78
|
+
launch_kw: Dict[str, Any] = dict(
|
|
79
|
+
headless=self.headless,
|
|
80
|
+
record_video_dir=str(self._video_dir) if self.record else None,
|
|
81
|
+
record_video_size={"width": 1366, "height": 900} if self.record else None,
|
|
82
|
+
args=[
|
|
83
|
+
"--disable-blink-features=AutomationControlled",
|
|
84
|
+
"--no-sandbox", "--disable-dev-shm-usage",
|
|
85
|
+
"--disable-infobars", "--window-size=1380,860",
|
|
86
|
+
f"--window-position={random.randint(0,60)},{random.randint(0,40)}",
|
|
87
|
+
])
|
|
88
|
+
if self.engine == "chrome":
|
|
89
|
+
# REAL Google Chrome (channel), not bundled Chromium
|
|
90
|
+
import shutil
|
|
91
|
+
launch_kw["channel"] = "chrome" if shutil.which("google-chrome") else "chromium"
|
|
92
|
+
if self.engine == "nodriver":
|
|
93
|
+
# nodriver is a stronger, separate engine; supported via
|
|
94
|
+
# thunder.nodriver_engine when installed (AGPL-3).
|
|
95
|
+
raise ValueError("use thunder.nodriver_engine.NodriverBody for the nodriver engine")
|
|
96
|
+
launch_kw.update({
|
|
97
|
+
"user_agent": self.skin.user_agent, # header + JS always match the Skin
|
|
98
|
+
"viewport": {"width": self.skin.window_w, "height": self.skin.window_h},
|
|
99
|
+
"locale": self.locale,
|
|
100
|
+
"timezone_id": self.timezone,
|
|
101
|
+
})
|
|
102
|
+
self._browser = await self._pw.chromium.launch_persistent_context(
|
|
103
|
+
str(self.user_data_dir), **launch_kw)
|
|
104
|
+
ctx = self._browser
|
|
105
|
+
vp = self.viewport or {"width": self.skin.window_w, "height": self.skin.window_h}
|
|
106
|
+
try:
|
|
107
|
+
await ctx.add_init_script(self.skin.injection_script())
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
self.page = ctx.pages[0] if ctx.pages else await ctx.new_page()
|
|
111
|
+
await self.page.set_viewport_size(vp)
|
|
112
|
+
try:
|
|
113
|
+
await self.page.set_locale(self.locale)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
try:
|
|
117
|
+
await ctx.set_default_timeout(20000)
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
# pages created later must also wear the skin
|
|
121
|
+
ctx.on("page", lambda p: asyncio.ensure_future(self._dress(p)))
|
|
122
|
+
self.hand = Hand(self.page, seed=self.skin.seed)
|
|
123
|
+
if self.record:
|
|
124
|
+
self._video_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if url:
|
|
126
|
+
await self.go(url)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
async def _dress(self, page):
|
|
130
|
+
try:
|
|
131
|
+
await page.add_init_script(self.skin.injection_script())
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
async def rest(self):
|
|
136
|
+
"""Close the body gracefully (context persists to disk)."""
|
|
137
|
+
try:
|
|
138
|
+
if self.page and self.record:
|
|
139
|
+
await self.page.wait_for_timeout(300)
|
|
140
|
+
if self._browser:
|
|
141
|
+
await self._browser.close()
|
|
142
|
+
if self._pw:
|
|
143
|
+
await self._pw.stop()
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
finally:
|
|
147
|
+
self._browser = None
|
|
148
|
+
self.page = None
|
|
149
|
+
|
|
150
|
+
# ── actions (human-shaped) ─────────────────────────────────────
|
|
151
|
+
async def go(self, url: str, human: bool = True):
|
|
152
|
+
"""Navigate like a person: sometimes pause before, sometimes
|
|
153
|
+
read the page after."""
|
|
154
|
+
if human and random.random() < 0.4:
|
|
155
|
+
await asyncio.sleep(random.uniform(0.4, 1.8))
|
|
156
|
+
r = await self.page.goto(url, wait_until="domcontentloaded")
|
|
157
|
+
try:
|
|
158
|
+
await self.page.wait_for_load_state("networkidle", timeout=8000)
|
|
159
|
+
except Exception:
|
|
160
|
+
pass # heavy sites never go idle; a human keeps reading anyway
|
|
161
|
+
await asyncio.sleep(random.uniform(0.3, 0.9))
|
|
162
|
+
return r
|
|
163
|
+
|
|
164
|
+
async def look(self, path: Optional[str] = None) -> str:
|
|
165
|
+
"""Eyes: full-page screenshot."""
|
|
166
|
+
p = path or str(self._video_dir / f"see_{int(time.time())}.png") \
|
|
167
|
+
if self.record else path
|
|
168
|
+
if not p:
|
|
169
|
+
import tempfile
|
|
170
|
+
fd, p = tempfile.mkstemp(suffix=".png")
|
|
171
|
+
os.close(fd)
|
|
172
|
+
await self.page.screenshot(path=p, full_page=False)
|
|
173
|
+
return p
|
|
174
|
+
|
|
175
|
+
async def read(self) -> Dict[str, Any]:
|
|
176
|
+
"""Eyes + understanding: structured outline of the page for
|
|
177
|
+
LLMs (title, url, text excerpt, interactive elements)."""
|
|
178
|
+
return await self.page.evaluate(
|
|
179
|
+
"(() => {" + _read.__doc__ + "})()" if False else """(() => {
|
|
180
|
+
const els = [];
|
|
181
|
+
for (const el of document.querySelectorAll(
|
|
182
|
+
"a, button, input, textarea, select, [role=button], [contenteditable=true]")) {
|
|
183
|
+
const r = el.getBoundingClientRect();
|
|
184
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
185
|
+
let sel = el.id ? "#" + el.id : "";
|
|
186
|
+
if (!sel && el.name) sel = el.tagName.toLowerCase() + '[name="' + el.name + '"]';
|
|
187
|
+
if (!sel && el.className && typeof el.className === "string") {
|
|
188
|
+
const c = el.className.trim().split(/\\s+/)[0];
|
|
189
|
+
if (c) sel = el.tagName.toLowerCase() + "." + c;
|
|
190
|
+
}
|
|
191
|
+
els.push({
|
|
192
|
+
tag: el.tagName.toLowerCase(),
|
|
193
|
+
text: (el.innerText || el.value || el.placeholder || "").slice(0, 80),
|
|
194
|
+
selector: sel,
|
|
195
|
+
meta: el.getAttribute("href") || el.getAttribute("type") || "",
|
|
196
|
+
});
|
|
197
|
+
if (els.length >= 60) break;
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
title: document.title,
|
|
201
|
+
url: location.href,
|
|
202
|
+
text: document.body ? document.body.innerText.slice(0, 4000) : "",
|
|
203
|
+
elements: els,
|
|
204
|
+
};
|
|
205
|
+
})()""")
|
|
206
|
+
|
|
207
|
+
async def click(self, selector: str):
|
|
208
|
+
await self.hand.click(selector)
|
|
209
|
+
|
|
210
|
+
async def type(self, selector: str, text: str):
|
|
211
|
+
await self.hand.type(selector, text)
|
|
212
|
+
|
|
213
|
+
async def scroll(self, amount: int = 600):
|
|
214
|
+
await self.hand.scroll(amount)
|
|
215
|
+
|
|
216
|
+
async def extract(self, selector: str = "body") -> str:
|
|
217
|
+
loc = self.page.locator(selector).first
|
|
218
|
+
return await loc.inner_text()
|
|
219
|
+
|
|
220
|
+
async def wait_human(self, seconds: float = 1.5):
|
|
221
|
+
await self.hand.idle(seconds)
|
|
222
|
+
|
|
223
|
+
# ── persistence ───────────────────────────────────────────────
|
|
224
|
+
async def save_state(self):
|
|
225
|
+
st = self.user_data_dir / "state.json"
|
|
226
|
+
if self.page:
|
|
227
|
+
st.write_text(json.dumps({
|
|
228
|
+
"storage": await self.page.evaluate(
|
|
229
|
+
"JSON.stringify({l: localStorage, s: sessionStorage})"),
|
|
230
|
+
}, indent=2))
|
|
231
|
+
return str(st)
|
thunder/captcha.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""CAPTCHA bridge — detect, classify, and solve.
|
|
2
|
+
|
|
3
|
+
Thunder's stance on CAPTCHAs:
|
|
4
|
+
1. AVOID: with a good Skin, human hands and a residential IP,
|
|
5
|
+
most challenges never fire. That is the first line.
|
|
6
|
+
2. DETECT: senses() reports exactly which challenge (if any)
|
|
7
|
+
is on the page, so an agent can decide.
|
|
8
|
+
3. SOLVE: on your own accounts, plug in your existing solver
|
|
9
|
+
subscription (CapSolver or 2Captcha API key). Thunder formats
|
|
10
|
+
the task, polls, and injects the token.
|
|
11
|
+
4. VISION (experimental): an LLM vision hook describes the
|
|
12
|
+
challenge — the beginning of stewmind-native solving.
|
|
13
|
+
|
|
14
|
+
Thunder does not ship solver keys and is not a mass-solving
|
|
15
|
+
service. Use it where you have authorization.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any, Dict, Optional
|
|
24
|
+
|
|
25
|
+
DETECT_JS = """(() => {
|
|
26
|
+
const out = {found: false, type: null, sitekey: null, selector: null};
|
|
27
|
+
const q = (sel) => document.querySelector(sel);
|
|
28
|
+
const cf = q('.cf-turnstile, [data-sitekey]');
|
|
29
|
+
if (cf) { out.found = true; out.type = 'turnstile';
|
|
30
|
+
out.sitekey = cf.getAttribute('data-sitekey'); out.selector = '.cf-turnstile'; return out; }
|
|
31
|
+
const rc = q('iframe[src*="recaptcha"]');
|
|
32
|
+
if (rc) { out.found = true; out.type = 'recaptcha';
|
|
33
|
+
const m = document.documentElement.innerHTML.match(/sitekey["']?\\s*[:=]\\s*["']([\\w-]{40})/);
|
|
34
|
+
out.sitekey = m ? m[1] : null; out.selector = 'iframe[src*="recaptcha"]'; return out; }
|
|
35
|
+
const hc = q('iframe[src*="hcaptcha"], [data-hcaptcha-sitekey]');
|
|
36
|
+
if (hc) { out.found = true; out.type = 'hcaptcha';
|
|
37
|
+
const m = document.documentElement.innerHTML.match(/sitekey["']?\\s*[:=]\\s*["']([\\w-]{30,})/);
|
|
38
|
+
out.sitekey = m ? m[1] : null; return out; }
|
|
39
|
+
const slide = q('.slider, .geetest_slider, [class*=slider]');
|
|
40
|
+
if (slide) { out.found = true; out.type = 'slider'; return out; }
|
|
41
|
+
return out;
|
|
42
|
+
})()"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def senses(page) -> Dict[str, Any]:
|
|
46
|
+
"""Look at the page and report any CAPTCHA challenge found."""
|
|
47
|
+
try:
|
|
48
|
+
return await page.evaluate(DETECT_JS)
|
|
49
|
+
except Exception:
|
|
50
|
+
return {"found": False, "type": None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SolverBridge:
|
|
54
|
+
"""Routes a challenge to your solver subscription."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, capsolver_key: Optional[str] = None,
|
|
57
|
+
twocaptcha_key: Optional[str] = None):
|
|
58
|
+
self.capsolver = capsolver_key or os.getenv("CAPSOLVER_API_KEY", "")
|
|
59
|
+
self.twocaptcha = twocaptcha_key or os.getenv("TWOCAPTCHA_API_KEY", "")
|
|
60
|
+
|
|
61
|
+
def available(self) -> bool:
|
|
62
|
+
return bool(self.capsolver or self.twocaptcha)
|
|
63
|
+
|
|
64
|
+
async def solve(self, page, challenge: Dict[str, Any],
|
|
65
|
+
page_url: Optional[str] = None) -> Dict[str, Any]:
|
|
66
|
+
"""Solve the given challenge and inject the token."""
|
|
67
|
+
import httpx
|
|
68
|
+
|
|
69
|
+
url = page_url or page.url
|
|
70
|
+
ctype = challenge.get("type")
|
|
71
|
+
sitekey = challenge.get("sitekey") or ""
|
|
72
|
+
if not self.available():
|
|
73
|
+
return {"solved": False,
|
|
74
|
+
"error": "no solver key: set CAPSOLVER_API_KEY or TWOCAPTCHA_API_KEY",
|
|
75
|
+
"hint": "Thunder avoids most challenges with a good skin + human hand"}
|
|
76
|
+
if not sitekey:
|
|
77
|
+
return {"solved": False, "error": "no sitekey found on page"}
|
|
78
|
+
|
|
79
|
+
async with httpx.AsyncClient(timeout=120) as c:
|
|
80
|
+
token = None
|
|
81
|
+
if self.capsolver and ctype == "turnstile":
|
|
82
|
+
task = {"type": "AntiTurnstileTaskProxyLess",
|
|
83
|
+
"websiteURL": url, "websiteKey": sitekey}
|
|
84
|
+
token = await self._capsolver(c, task)
|
|
85
|
+
elif self.capsolver and ctype == "recaptcha":
|
|
86
|
+
token = await self._capsolver(c, {
|
|
87
|
+
"type": "ReCaptchaV2TaskProxyLess",
|
|
88
|
+
"websiteURL": url, "websiteKey": sitekey})
|
|
89
|
+
elif self.capsolver and ctype == "hcaptcha":
|
|
90
|
+
token = await self._capsolver(c, {
|
|
91
|
+
"type": "HCaptchaTaskProxyLess",
|
|
92
|
+
"websiteURL": url, "websiteKey": sitekey})
|
|
93
|
+
elif self.twocaptcha:
|
|
94
|
+
token = await self._twocaptcha(c, ctype, url, sitekey)
|
|
95
|
+
if not token:
|
|
96
|
+
return {"solved": False, "error": "solver returned no token"}
|
|
97
|
+
inject = {
|
|
98
|
+
"turnstile": "document.querySelector('[name=cf-turnstile-response]'"
|
|
99
|
+
",input[name=cf-turnstile-response]').value = t;"
|
|
100
|
+
"window.turnbridge_token = t;",
|
|
101
|
+
"recaptcha": "document.getElementById('g-recaptcha-response')"
|
|
102
|
+
".innerHTML = t; window.turnbridge_token = t;",
|
|
103
|
+
"hcaptcha": "document.querySelector('[name=h-captcha-response]'"
|
|
104
|
+
",textarea[name=g-recaptcha-response]').value = t;",
|
|
105
|
+
}.get(ctype, "window.turnbridge_token = t;")
|
|
106
|
+
await page.evaluate(f"(() => {{ const t = {token!r}; {inject} }})()")
|
|
107
|
+
return {"solved": True, "type": ctype, "token": token[:24] + "..."}
|
|
108
|
+
|
|
109
|
+
async def _capsolver(self, c, task: dict) -> Optional[str]:
|
|
110
|
+
r = await c.post("https://api.capsolver.com/createTask",
|
|
111
|
+
json={"clientKey": self.capsolver, "task": task})
|
|
112
|
+
d = r.json()
|
|
113
|
+
if d.get("errorId"):
|
|
114
|
+
return None
|
|
115
|
+
tid = d["taskId"]
|
|
116
|
+
for _ in range(40):
|
|
117
|
+
await asyncio.sleep(3)
|
|
118
|
+
r = await c.post("https://api.capsolver.com/getTaskResult",
|
|
119
|
+
json={"clientKey": self.capsolver, "taskId": tid})
|
|
120
|
+
g = r.json().get("solution", {})
|
|
121
|
+
if g.get("gRecaptchaResponse") or g.get("token"):
|
|
122
|
+
return g.get("gRecaptchaResponse") or g.get("token")
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
async def _twocaptcha(self, c, ctype: str, url: str, sitekey: str) -> Optional[str]:
|
|
126
|
+
kind = {"recaptcha": "userrecaptcha", "hcaptcha": "hcaptcha"}.get(ctype)
|
|
127
|
+
if not kind:
|
|
128
|
+
return None
|
|
129
|
+
r = await c.post("https://2captcha.com/in.php",
|
|
130
|
+
data={"key": self.twocaptcha, "method": kind,
|
|
131
|
+
"sitekey": sitekey, "pageurl": url, "json": 1})
|
|
132
|
+
tid = r.json().get("request")
|
|
133
|
+
if not tid:
|
|
134
|
+
return None
|
|
135
|
+
for _ in range(60):
|
|
136
|
+
await asyncio.sleep(5)
|
|
137
|
+
r = await c.get("https://2captcha.com/res.php",
|
|
138
|
+
params={"key": self.twocaptcha,
|
|
139
|
+
"action": "get", "id": tid, "json": 1})
|
|
140
|
+
d = r.json()
|
|
141
|
+
if d.get("status") == 1:
|
|
142
|
+
return d["request"]
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def describe_challenge(page, mind=None) -> str:
|
|
147
|
+
"""Experimental: stewmind vision describing a challenge."""
|
|
148
|
+
shot = await page.screenshot()
|
|
149
|
+
prompt = ("Describe this CAPTCHA challenge and the exact steps a human "
|
|
150
|
+
"would take to solve it. Be brief.")
|
|
151
|
+
if mind is None:
|
|
152
|
+
return prompt # caller routes screenshot + prompt to a vision model
|
|
153
|
+
return prompt
|
thunder/cli.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Thunder CLI.
|
|
2
|
+
|
|
3
|
+
thunder browse https://example.com # open with the human body
|
|
4
|
+
thunder ghost https://example.com # TLS-true fetch
|
|
5
|
+
thunder think "find the pricing page" # stewmind's next action
|
|
6
|
+
thunder serve # run the MCP server (stdio)
|
|
7
|
+
thunder demo # full showcase run
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv=None):
|
|
18
|
+
p = argparse.ArgumentParser(prog="thunder",
|
|
19
|
+
description="Thunder — the human engine for AI agents")
|
|
20
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
21
|
+
|
|
22
|
+
b = sub.add_parser("browse", help="open a URL with the human body")
|
|
23
|
+
b.add_argument("url")
|
|
24
|
+
b.add_argument("--shot", default=None)
|
|
25
|
+
b.add_argument("--scroll", type=int, default=0)
|
|
26
|
+
b.add_argument("--record", action="store_true", help="save a video")
|
|
27
|
+
|
|
28
|
+
g = sub.add_parser("ghost", help="fetch with a browser TLS fingerprint")
|
|
29
|
+
g.add_argument("url")
|
|
30
|
+
g.add_argument("--head", action="store_true")
|
|
31
|
+
|
|
32
|
+
t = sub.add_parser("think", help="stewmind decides the next action")
|
|
33
|
+
t.add_argument("goal")
|
|
34
|
+
t.add_argument("--endpoint", default=None)
|
|
35
|
+
t.add_argument("--local", action="store_true")
|
|
36
|
+
|
|
37
|
+
sub.add_parser("serve", help="run the MCP server over stdio")
|
|
38
|
+
sub.add_parser("demo", help="run the full Thunder showcase")
|
|
39
|
+
|
|
40
|
+
a = p.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
if a.cmd == "serve":
|
|
43
|
+
from thunder.mcp_server import main as serve
|
|
44
|
+
serve()
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
if a.cmd == "browse":
|
|
48
|
+
from thunder.body import Body
|
|
49
|
+
|
|
50
|
+
async def run():
|
|
51
|
+
body = Body(headless=True, record_video=a.record)
|
|
52
|
+
await body.wake(a.url)
|
|
53
|
+
if a.scroll:
|
|
54
|
+
await body.scroll(a.scroll)
|
|
55
|
+
shot = a.shot or "thunder_shot.png"
|
|
56
|
+
await body.look(shot)
|
|
57
|
+
print(json.dumps({"url": body.page.url,
|
|
58
|
+
"title": await body.page.title(),
|
|
59
|
+
"screenshot": shot}, indent=2))
|
|
60
|
+
await body.rest()
|
|
61
|
+
asyncio.run(run())
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
if a.cmd == "ghost":
|
|
65
|
+
from thunder.ghost import Ghost
|
|
66
|
+
|
|
67
|
+
async def run():
|
|
68
|
+
gh = Ghost()
|
|
69
|
+
r = await gh.get(a.url)
|
|
70
|
+
if a.head:
|
|
71
|
+
print(json.dumps({"status": r.status_code,
|
|
72
|
+
"headers": dict(r.headers)}, indent=2)[:2000])
|
|
73
|
+
else:
|
|
74
|
+
print(r.text[:4000])
|
|
75
|
+
await gh.close()
|
|
76
|
+
asyncio.run(run())
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
if a.cmd == "think":
|
|
80
|
+
from thunder.body import Body
|
|
81
|
+
from thunder.mind import StewMind
|
|
82
|
+
|
|
83
|
+
async def run():
|
|
84
|
+
mind = StewMind(endpoint=a.endpoint, local=a.local)
|
|
85
|
+
body = Body()
|
|
86
|
+
await body.wake()
|
|
87
|
+
page = await body.read()
|
|
88
|
+
print(json.dumps(mind.think(a.goal, page), indent=2))
|
|
89
|
+
await body.rest()
|
|
90
|
+
asyncio.run(run())
|
|
91
|
+
return 0
|
|
92
|
+
|
|
93
|
+
if a.cmd == "demo":
|
|
94
|
+
from thunder.demo import run as run_demo
|
|
95
|
+
asyncio.run(run_demo())
|
|
96
|
+
return 0
|
|
97
|
+
return 1
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
thunder/demo.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Thunder end-to-end showcase.
|
|
2
|
+
|
|
3
|
+
1. Ghost handshake test (requests vs thunder TLS shape)
|
|
4
|
+
2. Body wakes, opens a page like a human, types with the human hand
|
|
5
|
+
3. stewmind decides the next action
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
async def run():
|
|
14
|
+
print("⚡ THUNDER — human engine demo\n" + "─" * 46)
|
|
15
|
+
|
|
16
|
+
# 1 ── Ghost: TLS-true HTTP
|
|
17
|
+
from thunder.ghost import Ghost
|
|
18
|
+
print("[1/3] Ghost: comparing handshakes on a bot-blocking target…")
|
|
19
|
+
gh = Ghost()
|
|
20
|
+
url = "https://nowsecure.nl" # classic Cloudflare gate
|
|
21
|
+
try:
|
|
22
|
+
r = await gh.get(url)
|
|
23
|
+
print(f" thunder ghost status: {r.status_code} "
|
|
24
|
+
f"(raw requests/urllib would be challenged)")
|
|
25
|
+
except Exception as e:
|
|
26
|
+
print(f" ghost: {type(e).__name__}: {str(e)[:100]}")
|
|
27
|
+
await gh.close()
|
|
28
|
+
|
|
29
|
+
# 2 ── Body: human-shaped browsing on a real form
|
|
30
|
+
from thunder.body import Body
|
|
31
|
+
print("[2/3] Body: opening a page and typing like a human…")
|
|
32
|
+
body = Body(headless=True)
|
|
33
|
+
await body.wake("https://www.wikipedia.org")
|
|
34
|
+
await body.type("#searchInput", "lightning") # human cadence
|
|
35
|
+
await asyncio.sleep(0.4)
|
|
36
|
+
shot = await body.look("thunder_demo.png")
|
|
37
|
+
page = await body.read()
|
|
38
|
+
print(f" opened: {page['title']}")
|
|
39
|
+
print(f" typed with human hand, screenshot → {shot}")
|
|
40
|
+
await body.rest()
|
|
41
|
+
|
|
42
|
+
# 3 ── StewMind
|
|
43
|
+
print("[3/3] StewMind: deciding the next action…")
|
|
44
|
+
try:
|
|
45
|
+
from thunder.mind import StewMind
|
|
46
|
+
mind = StewMind()
|
|
47
|
+
d = mind.think("Find the article about lightning storms", page)
|
|
48
|
+
print(" stewmind says:", json.dumps(d, indent=2)[:400])
|
|
49
|
+
except Exception as e:
|
|
50
|
+
print(f" (stewmind offline here: {str(e)[:90]})")
|
|
51
|
+
print("─" * 46 + "\n⚡ demo done. Thunder never sleeps.")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
asyncio.run(run())
|