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 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())