fix: revert old oauth behavior and new default email provider
This commit is contained in:
parent
0af7179596
commit
6dd26ad3d8
7 changed files with 439 additions and 23 deletions
|
|
@ -52,6 +52,11 @@ Behavior:
|
||||||
4. When usage reaches `CHATGPT_PREPARE_THRESHOLD`, service prepares `next_account`.
|
4. When usage reaches `CHATGPT_PREPARE_THRESHOLD`, service prepares `next_account`.
|
||||||
5. When usage reaches `CHATGPT_SWITCH_THRESHOLD`, service switches active account to `next_account`.
|
5. When usage reaches `CHATGPT_SWITCH_THRESHOLD`, service switches active account to `next_account`.
|
||||||
|
|
||||||
|
## Disposable Email Provider
|
||||||
|
|
||||||
|
- Default provider is `mail.tm` API (`MailTmProvider`) and does not use browser automation.
|
||||||
|
- Flow: fetch domains -> create account with random address/password -> get JWT token -> poll messages.
|
||||||
|
|
||||||
## Startup Behavior
|
## Startup Behavior
|
||||||
|
|
||||||
On startup, service ensures active token exists and is usable.
|
On startup, service ensures active token exists and is usable.
|
||||||
|
|
|
||||||
55
scripts/run_token_refresh_flow.py
Normal file
55
scripts/run_token_refresh_flow.py
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import browser
|
||||||
|
import server
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRequest:
|
||||||
|
def __init__(self, provider: str):
|
||||||
|
self.match_info = {"provider": provider}
|
||||||
|
self.method = "GET"
|
||||||
|
self.path_qs = f"/{provider}/token"
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_headed_browser() -> bool:
|
||||||
|
if "--no-startup-window" in browser.CHROME_FLAGS:
|
||||||
|
browser.CHROME_FLAGS.remove("--no-startup-window")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(provider: str) -> int:
|
||||||
|
patched = _enable_headed_browser()
|
||||||
|
logging.info("Headed mode patch applied: %s", patched)
|
||||||
|
|
||||||
|
request = _FakeRequest(provider)
|
||||||
|
response = await server.token_handler(request)
|
||||||
|
payload = json.loads(response.body.decode("utf-8"))
|
||||||
|
|
||||||
|
logging.info("Response status: %s", response.status)
|
||||||
|
logging.info("Response body: %s", json.dumps(payload, indent=2))
|
||||||
|
return 0 if response.status == 200 else 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=(
|
||||||
|
"Run the same token refresh/issue flow as server /{provider}/token "
|
||||||
|
"in headed browser mode (non-headless)."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser.add_argument("--provider", default="chatgpt")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
)
|
||||||
|
return asyncio.run(_run(args.provider))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
from .base import BaseProvider
|
from .base import BaseProvider
|
||||||
|
from .mail_tm import MailTmProvider
|
||||||
from .ten_minute_mail import TenMinuteMailProvider
|
from .ten_minute_mail import TenMinuteMailProvider
|
||||||
from .temp_mail_org import TempMailOrgProvider
|
from .temp_mail_org import TempMailOrgProvider
|
||||||
|
|
||||||
__all__ = ["BaseProvider", "TenMinuteMailProvider", "TempMailOrgProvider"]
|
__all__ = [
|
||||||
|
"BaseProvider",
|
||||||
|
"MailTmProvider",
|
||||||
|
"TenMinuteMailProvider",
|
||||||
|
"TempMailOrgProvider",
|
||||||
|
]
|
||||||
|
|
|
||||||
232
src/email_providers/mail_tm.py
Normal file
232
src/email_providers/mail_tm.py
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
from playwright.async_api import BrowserContext
|
||||||
|
|
||||||
|
from .base import BaseProvider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_API_BASE = os.environ.get("MAIL_TM_API_BASE", "https://api.mail.tm")
|
||||||
|
_TIMEOUT_SECONDS = 20
|
||||||
|
_FIRST_NAMES = [
|
||||||
|
"james",
|
||||||
|
"john",
|
||||||
|
"robert",
|
||||||
|
"michael",
|
||||||
|
"david",
|
||||||
|
"william",
|
||||||
|
"joseph",
|
||||||
|
"thomas",
|
||||||
|
"daniel",
|
||||||
|
"mark",
|
||||||
|
"paul",
|
||||||
|
"kevin",
|
||||||
|
]
|
||||||
|
_LAST_NAMES = [
|
||||||
|
"smith",
|
||||||
|
"johnson",
|
||||||
|
"williams",
|
||||||
|
"brown",
|
||||||
|
"jones",
|
||||||
|
"miller",
|
||||||
|
"davis",
|
||||||
|
"wilson",
|
||||||
|
"anderson",
|
||||||
|
"taylor",
|
||||||
|
"martin",
|
||||||
|
"thompson",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_local_part() -> str:
|
||||||
|
first = secrets.choice(_FIRST_NAMES)
|
||||||
|
last = secrets.choice(_LAST_NAMES)
|
||||||
|
digits = "".join(secrets.choice(string.digits) for _ in range(8))
|
||||||
|
return f"{first}{last}{digits}"
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_password(length: int = 24) -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
class MailTmProvider(BaseProvider):
|
||||||
|
def __init__(self, browser_session: BrowserContext):
|
||||||
|
super().__init__(browser_session)
|
||||||
|
self._address: str | None = None
|
||||||
|
self._password: str | None = None
|
||||||
|
self._token: str | None = None
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
token: str | None = None,
|
||||||
|
json_body: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[int, dict[str, Any] | list[Any] | None]:
|
||||||
|
url = f"{_API_BASE.rstrip('/')}{path}"
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_TIMEOUT_SECONDS)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.request(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
json=json_body,
|
||||||
|
) as resp:
|
||||||
|
status = resp.status
|
||||||
|
try:
|
||||||
|
payload = await resp.json()
|
||||||
|
except aiohttp.ContentTypeError:
|
||||||
|
payload = None
|
||||||
|
return status, payload
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
logger.warning("[mail.tm] request failed %s %s: %s", method, path, e)
|
||||||
|
return 0, None
|
||||||
|
|
||||||
|
async def _get_domains(self) -> list[str]:
|
||||||
|
status, payload = await self._request("GET", "/domains")
|
||||||
|
if status != 200 or not isinstance(payload, dict):
|
||||||
|
raise RuntimeError("mail.tm domains request failed")
|
||||||
|
|
||||||
|
members = payload.get("hydra:member")
|
||||||
|
if not isinstance(members, list):
|
||||||
|
raise RuntimeError("mail.tm domains response has unexpected format")
|
||||||
|
|
||||||
|
domains: list[str] = []
|
||||||
|
for item in members:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
domain = item.get("domain")
|
||||||
|
is_active = bool(item.get("isActive", True))
|
||||||
|
if isinstance(domain, str) and domain and is_active:
|
||||||
|
domains.append(domain)
|
||||||
|
|
||||||
|
if not domains:
|
||||||
|
raise RuntimeError("mail.tm returned no active domains")
|
||||||
|
return domains
|
||||||
|
|
||||||
|
async def _create_account(self, address: str, password: str) -> bool:
|
||||||
|
status, _ = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/accounts",
|
||||||
|
json_body={"address": address, "password": password},
|
||||||
|
)
|
||||||
|
if status in (200, 201):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _create_token(self, address: str, password: str) -> str | None:
|
||||||
|
status, payload = await self._request(
|
||||||
|
"POST",
|
||||||
|
"/token",
|
||||||
|
json_body={"address": address, "password": password},
|
||||||
|
)
|
||||||
|
if status != 200 or not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
token = payload.get("token")
|
||||||
|
if isinstance(token, str) and token:
|
||||||
|
return token
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_new_email(self) -> str:
|
||||||
|
domains = await self._get_domains()
|
||||||
|
|
||||||
|
for _ in range(8):
|
||||||
|
domain = secrets.choice(domains)
|
||||||
|
address = f"{_generate_local_part()}@{domain}"
|
||||||
|
password = _generate_password()
|
||||||
|
|
||||||
|
created = await self._create_account(address, password)
|
||||||
|
if not created:
|
||||||
|
continue
|
||||||
|
|
||||||
|
token = await self._create_token(address, password)
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._address = address
|
||||||
|
self._password = password
|
||||||
|
self._token = token
|
||||||
|
logger.info("[mail.tm] New mailbox acquired: %s", address)
|
||||||
|
return address
|
||||||
|
|
||||||
|
raise RuntimeError("mail.tm could not create account")
|
||||||
|
|
||||||
|
async def _list_messages(self) -> list[dict[str, Any]]:
|
||||||
|
if not self._token:
|
||||||
|
return []
|
||||||
|
status, payload = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/messages",
|
||||||
|
token=self._token,
|
||||||
|
)
|
||||||
|
if status == 401 and self._address and self._password:
|
||||||
|
token = await self._create_token(self._address, self._password)
|
||||||
|
if token:
|
||||||
|
self._token = token
|
||||||
|
status, payload = await self._request(
|
||||||
|
"GET",
|
||||||
|
"/messages",
|
||||||
|
token=self._token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status != 200 or not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
members = payload.get("hydra:member")
|
||||||
|
if not isinstance(members, list):
|
||||||
|
return []
|
||||||
|
return [item for item in members if isinstance(item, dict)]
|
||||||
|
|
||||||
|
async def _get_message_text(self, message_id: str) -> str | None:
|
||||||
|
if not self._token:
|
||||||
|
return None
|
||||||
|
status, payload = await self._request(
|
||||||
|
"GET",
|
||||||
|
f"/messages/{message_id}",
|
||||||
|
token=self._token,
|
||||||
|
)
|
||||||
|
if status != 200 or not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
parts = [
|
||||||
|
payload.get("subject"),
|
||||||
|
payload.get("intro"),
|
||||||
|
payload.get("text"),
|
||||||
|
payload.get("html"),
|
||||||
|
]
|
||||||
|
text = "\n".join(str(part) for part in parts if part)
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
async def get_latest_message(self, email: str) -> str | None:
|
||||||
|
del email
|
||||||
|
if not self._token:
|
||||||
|
raise RuntimeError("mail.tm provider is not initialized with mailbox token")
|
||||||
|
|
||||||
|
for _ in range(45):
|
||||||
|
messages = await self._list_messages()
|
||||||
|
if messages:
|
||||||
|
latest = messages[0]
|
||||||
|
message_id = latest.get("id")
|
||||||
|
if isinstance(message_id, str) and message_id:
|
||||||
|
full_message = await self._get_message_text(message_id)
|
||||||
|
if full_message:
|
||||||
|
logger.info("[mail.tm] Latest message received")
|
||||||
|
return full_message
|
||||||
|
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
logger.warning("[mail.tm] No messages received within timeout")
|
||||||
|
return None
|
||||||
|
|
@ -7,7 +7,7 @@ from typing import Callable
|
||||||
from playwright.async_api import BrowserContext
|
from playwright.async_api import BrowserContext
|
||||||
|
|
||||||
from email_providers import BaseProvider
|
from email_providers import BaseProvider
|
||||||
from email_providers import TempMailOrgProvider
|
from email_providers import MailTmProvider
|
||||||
from providers.base import Provider, ProviderTokens
|
from providers.base import Provider, ProviderTokens
|
||||||
from .tokens import (
|
from .tokens import (
|
||||||
clear_next_tokens,
|
clear_next_tokens,
|
||||||
|
|
@ -34,7 +34,7 @@ class ChatGPTProvider(Provider):
|
||||||
self,
|
self,
|
||||||
email_provider_factory: Callable[[BrowserContext], BaseProvider] | None = None,
|
email_provider_factory: Callable[[BrowserContext], BaseProvider] | None = None,
|
||||||
):
|
):
|
||||||
self.email_provider_factory = email_provider_factory or TempMailOrgProvider
|
self.email_provider_factory = email_provider_factory or MailTmProvider
|
||||||
self._token_write_lock = asyncio.Lock()
|
self._token_write_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def _register_with_retries(self) -> bool:
|
async def _register_with_retries(self) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,34 @@ async def click_continue(page: Page, timeout_ms: int = 10000):
|
||||||
await btn.click()
|
await btn.click()
|
||||||
|
|
||||||
|
|
||||||
async def click_any_visible_button(
|
async def oauth_needs_email_check(page: Page) -> bool:
|
||||||
|
marker = page.get_by_text("Check your inbox", exact=False)
|
||||||
|
return await marker.count() > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def fill_oauth_code_if_present(page: Page, code: str) -> bool:
|
||||||
|
candidates = [
|
||||||
|
page.get_by_placeholder("Code"),
|
||||||
|
page.get_by_label("Code"),
|
||||||
|
page.locator(
|
||||||
|
'input[name*="code" i], input[id*="code" i], '
|
||||||
|
'input[autocomplete="one-time-code"], input[inputmode="numeric"]'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for locator in candidates:
|
||||||
|
if await locator.count() == 0:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await locator.first.wait_for(state="visible", timeout=1500)
|
||||||
|
await locator.first.fill(code)
|
||||||
|
return True
|
||||||
|
except PlaywrightError:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def click_first_visible_button(
|
||||||
page: Page,
|
page: Page,
|
||||||
labels: list[str],
|
labels: list[str],
|
||||||
timeout_ms: int = 2000,
|
timeout_ms: int = 2000,
|
||||||
|
|
@ -415,44 +442,67 @@ async def register_chatgpt_account(
|
||||||
oauth_page.on("request", handle_request)
|
oauth_page.on("request", handle_request)
|
||||||
|
|
||||||
await oauth_page.goto(authorize_url, wait_until="domcontentloaded")
|
await oauth_page.goto(authorize_url, wait_until="domcontentloaded")
|
||||||
|
await oauth_page.locator(
|
||||||
|
'input[type="email"], input[name="email"]'
|
||||||
|
).first.wait_for(state="visible", timeout=20000)
|
||||||
|
|
||||||
email_input = oauth_page.locator('input[type="email"], input[name="email"]')
|
email_input = oauth_page.locator('input[type="email"], input[name="email"]')
|
||||||
if await email_input.count() > 0:
|
if await email_input.count() > 0:
|
||||||
await email_input.first.wait_for(state="visible", timeout=10000)
|
|
||||||
await email_input.first.fill(email)
|
await email_input.first.fill(email)
|
||||||
await click_any_visible_button(
|
|
||||||
oauth_page, ["Continue"], timeout_ms=4000
|
continue_button = oauth_page.get_by_role("button", name="Continue")
|
||||||
|
if await continue_button.count() > 0:
|
||||||
|
await continue_button.first.click()
|
||||||
|
await oauth_page.locator('input[type="password"]').first.wait_for(
|
||||||
|
state="visible", timeout=20000
|
||||||
)
|
)
|
||||||
|
|
||||||
password_input = oauth_page.locator('input[type="password"]')
|
password_input = oauth_page.locator('input[type="password"]')
|
||||||
if await password_input.count() > 0:
|
if await password_input.count() > 0:
|
||||||
await password_input.first.wait_for(state="visible", timeout=10000)
|
|
||||||
await password_input.first.fill(password)
|
await password_input.first.fill(password)
|
||||||
await click_any_visible_button(
|
continue_button = oauth_page.get_by_role("button", name="Continue")
|
||||||
oauth_page, ["Continue"], timeout_ms=4000
|
if await continue_button.count() > 0:
|
||||||
)
|
await continue_button.first.click()
|
||||||
|
|
||||||
for _ in range(6):
|
last_oauth_email_code = code
|
||||||
|
oauth_deadline = asyncio.get_running_loop().time() + 60
|
||||||
|
while asyncio.get_running_loop().time() < oauth_deadline:
|
||||||
if redirect_url_captured:
|
if redirect_url_captured:
|
||||||
break
|
break
|
||||||
clicked = await click_any_visible_button(
|
|
||||||
oauth_page,
|
|
||||||
["Continue", "Allow", "Authorize"],
|
|
||||||
timeout_ms=2000,
|
|
||||||
)
|
|
||||||
if clicked:
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
else:
|
|
||||||
await asyncio.sleep(0.4)
|
|
||||||
|
|
||||||
if not redirect_url_captured:
|
if await oauth_needs_email_check(oauth_page):
|
||||||
|
logger.info("OAuth requested email confirmation code")
|
||||||
|
new_code = await get_latest_code(email_provider, email)
|
||||||
|
if new_code and new_code != last_oauth_email_code:
|
||||||
|
filled = await fill_oauth_code_if_present(oauth_page, new_code)
|
||||||
|
if filled:
|
||||||
|
last_oauth_email_code = new_code
|
||||||
|
logger.info("Filled OAuth email confirmation code")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"OAuth inbox challenge detected but code field not found"
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_url = oauth_page.url
|
current_url = oauth_page.url
|
||||||
if "localhost:1455" in current_url and "code=" in current_url:
|
if "localhost:1455" in current_url and "code=" in current_url:
|
||||||
redirect_url_captured = current_url
|
redirect_url_captured = current_url
|
||||||
logger.info("Captured OAuth redirect from page URL")
|
logger.info("Captured OAuth redirect from page URL")
|
||||||
except PlaywrightError:
|
break
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
clicked = await click_first_visible_button(
|
||||||
|
oauth_page,
|
||||||
|
["Continue", "Allow", "Authorize", "Verify"],
|
||||||
|
timeout_ms=2000,
|
||||||
|
)
|
||||||
|
|
||||||
|
if clicked:
|
||||||
|
await oauth_page.wait_for_timeout(500)
|
||||||
|
else:
|
||||||
|
await oauth_page.wait_for_timeout(1000)
|
||||||
|
|
||||||
if not redirect_url_captured:
|
if not redirect_url_captured:
|
||||||
raise AutomationError(
|
raise AutomationError(
|
||||||
"oauth", "OAuth redirect with code was not captured", oauth_page
|
"oauth", "OAuth redirect with code was not captured", oauth_page
|
||||||
|
|
|
||||||
68
uv.lock
generated
68
uv.lock
generated
|
|
@ -83,6 +83,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "frozenlist"
|
name = "frozenlist"
|
||||||
version = "1.8.0"
|
version = "1.8.0"
|
||||||
|
|
@ -158,6 +167,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "megapt"
|
name = "megapt"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -168,12 +186,19 @@ dependencies = [
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "aiohttp", specifier = "==3.13.3" },
|
{ name = "aiohttp", specifier = "==3.13.3" },
|
||||||
{ name = "pkce", specifier = "==1.0.3" },
|
{ name = "pkce", specifier = "==1.0.3" },
|
||||||
{ name = "playwright", specifier = "==1.58.0" },
|
{ name = "playwright", specifier = "==1.58.0" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
|
||||||
]
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
|
|
@ -220,6 +245,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkce"
|
name = "pkce"
|
||||||
version = "1.0.3"
|
version = "1.0.3"
|
||||||
|
|
@ -248,6 +282,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
|
|
@ -299,6 +342,31 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "9.0.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue