WIP: Add Twitter link previews for Discord with Nitter scraping and metadata injection

This commit is contained in:
Joakim Hellsén 2026-08-09 20:05:06 +02:00
commit 8406db5283
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
6 changed files with 440 additions and 19 deletions

View file

@ -1,5 +1,12 @@
{
"cSpell.words": [
"appauthor",
"appname",
"htpy",
"Lovinator",
"Nitter",
"Renderable",
"selectolax",
"wreq"
]
}

View file

@ -6,9 +6,12 @@ readme = "README.md"
authors = [{ name = "Joakim Hellsén", email = "tlovinator@gmail.com" }]
requires-python = ">=3.14"
dependencies = [
"htpy>=26.5.1",
"litestar>=2.24.0",
"loguru>=0.7.3",
"platformdirs>=4.11.1",
"pydantic>=2.13.4",
"selectolax>=0.4.11",
"uvicorn>=0.52.1",
"wreq>=0.12.1",
]

View file

@ -1,29 +1,24 @@
import uvicorn
from litestar import Litestar
from litestar import get
from litestar.response import Response
from e.twitter import twitter
@get("/")
async def twitter() -> dict[str, str]: # ruff: ignore[unused-async]
"""Handle Twitter requests.
https://twitter.com/DiscussingFilm/status/2086143411984208230
https://e.lovinator.space/DiscussingFilm/status/2086143411984208230
If IP is from Discord:
Download the image/video.
Return custom HTML with metadata tags with the image/video.
Otherwise:
Redirect to the original URL.
Returns:
Redirect to the original URL, or custom HTML with metadata tags.
"""
return {"message": "Hello, World!"}
@get("/favicon.ico")
async def favicon() -> Response: # ruff: ignore[unused-async]
"""Return empty response."""
return Response(
content=b"",
media_type="image/x-icon",
status_code=204,
)
app = Litestar(route_handlers=[twitter])
app = Litestar(route_handlers=[twitter, favicon], debug=True)
if __name__ == "__main__":
uvicorn.run(app)
import os
uvicorn.run("e.main:app", port=int(os.getenv("PORT", str(8000))), reload=True)

9
src/e/settings.py Normal file
View file

@ -0,0 +1,9 @@
from platformdirs import user_data_path
DATA_DIR = user_data_path(
appname="e.lovinator.space",
appauthor="TheLovinator",
roaming=True,
ensure_exists=True,
)
"""Directory where all the media files are stored."""

324
src/e/twitter.py Normal file
View file

@ -0,0 +1,324 @@
from ipaddress import ip_address
from typing import TYPE_CHECKING
import wreq
from htpy import head
from htpy import html
from htpy import meta
from litestar import Request
from litestar import get
from litestar.response import Redirect
from loguru import logger
from selectolax.parser import HTMLParser
from selectolax.parser import Node
from wreq import Client
from wreq import Emulation
from e.discord import DiscordIPs
from e.discord import Prefix
from e.discord import get_discord_ips
if TYPE_CHECKING:
from htpy._types import Renderable
from litestar.datastructures import Address
STAT_FIELDS = {
"icon-comment": "comments",
"icon-retweet": "retweets",
"icon-heart": "likes",
"icon-views": "views",
}
def generate_html(tweet: dict) -> Renderable:
"""Generate HTML for a tweet.
Args:
tweet: The tweet to generate HTML for.
Returns:
The HTML for the tweet.
"""
# Discord supports oEmbed, Open Graph, and Twitter Card metadata formats for rendering link embeds.
meta_tags = [
meta(property="theme-color", content="#1d9bf0"),
]
# loop through all the images and add as og:image
meta_tags.extend(
meta(
property="og:image",
content=image["thumbnail"],
width=image["width"],
height=image["height"],
type=image["type"],
secure_url=image["thumbnail"],
alt=tweet["text"],
)
for image in tweet["media"]
)
return html[
head[
meta(name="viewport", content="width=device-width, initial-scale=1.0"),
*meta_tags,
]
]
def save_data_to_disk(tweet: dict) -> None:
"""Save tweet data to DATA_DIR/twitter/<username>/<tweet_id>/data.json.
Has version number so we can update the file later if data changes.
Args:
tweet: The tweet to save.
"""
def text(node: Node | None, default: str | None = None) -> str | None:
"""Safely extract text from a node.
Args:
node: The node to extract text from.
default: The default value to return if the node is None.
Returns:
The text of the node, or the default value if the node is None.
"""
return node.text(strip=True) if node else default
def attr(node: Node | None, name: str, default: str | None = None) -> str | None:
"""Safely extract an attribute from a node.
Args:
node: The node to extract the attribute from.
name: The name of the attribute.
default: The default value to return if the node is None.
Returns:
The attribute of the node, or the default value if the node is None.
"""
return node.attributes.get(name, default) if node else default
def parse_number(value: str) -> int | None:
"""Convert '46,391' -> 46391.
Args:
value: The value to convert.
Returns:
The converted value.
"""
if not value:
logger.warning("Value is empty.")
return None
value = value.replace(",", "").strip()
try:
return int(value)
except ValueError:
logger.error("Could not convert value '{}' to int.", value)
return None
def parse_stats(tweet: Node) -> dict[str, int | None]:
"""Parse the stats of a tweet.
Args:
tweet: The tweet to parse the stats from.
Returns:
The stats of the tweet.
"""
stats = {}
for stat in tweet.css(".tweet-stat"):
icon = stat.css_first("span[class*='icon-']")
if not icon:
logger.warning("Could not find icon for stat.")
continue
classes = attr(icon, "class", "")
if not classes:
logger.warning("Could not find classes for icon.")
continue
field = next(
(field for icon_class, field in STAT_FIELDS.items() if icon_class in classes),
None,
)
if not field:
logger.warning("Could not find field for icon classes.")
continue
# The stat contains the icon plus the number.
# Extract the number by removing the icon element.
if not icon.parent:
logger.warning("Could not find parent for icon.")
continue
icon.parent.decompose()
value = stat.text(strip=True)
num = parse_number(value)
if num is None:
logger.warning("Could not parse number for stat.")
continue
stats[field] = num
return stats
def parse_tweet(html: str) -> dict:
"""Parse a tweet from HTML.
Args:
html: The HTML of the tweet.
Returns:
The parsed tweet.
Raises:
ValueError: If .tweet-body cannot be found.
"""
tree = HTMLParser(html)
tweet = tree.css_first(".tweet-body")
if not tweet:
msg = "Could not find .tweet-body"
raise ValueError(msg)
# Author
avatar = tweet.css_first(".tweet-avatar img")
fullname = tweet.css_first(".fullname")
username = tweet.css_first(".username")
# Date
date_link = tweet.css_first(".tweet-date a")
published = tweet.css_first(".tweet-published")
# Text
content = tweet.css_first(".tweet-content")
# Media
media = []
for attachment in tweet.css(".attachments .attachment"):
link = attachment.css_first("a")
image = attachment.css_first("img")
if link or image:
media.append({
"url": attr(link, "href"),
"thumbnail": attr(image, "src"),
})
return {
"author": {
"name": text(fullname),
"username": text(username),
"profile_url": attr(username, "href"),
"avatar": attr(avatar, "src"),
"verified": bool(tweet.css_first(".verified-icon")),
},
"date": {
"relative": text(date_link),
"published": text(published),
"title": attr(date_link, "title"),
"url": attr(date_link, "href"),
},
"text": text(content),
"media": media,
"stats": parse_stats(tweet),
}
@get("/{username:str}/status/{tweet_id:str}")
async def twitter(request: Request, username: str, tweet_id: str) -> dict[str, str] | Redirect:
"""Handle Twitter requests.
https://twitter.com/DiscussingFilm/status/2086143411984208230
https://x.com/DiscussingFilm/status/2086143411984208230
https://nitter.net/DiscussingFilm/status/2086143411984208230
https://e.lovinator.space/DiscussingFilm/status/2086143411984208230
If IP is from Discord:
Download the image/video.
Return custom HTML with metadata tags with the image/video.
Otherwise:
Redirect to the original URL.
Args:
request: The request.
username: The Twitter handle.
tweet_id: The status ID of the tweet.
Returns:
Redirect to the original URL, or custom HTML with metadata tags.
Raises:
ValueError: If client address is missing.
"""
logger.info(f"Request for {request.url!r} from {request.client}")
logger.info("Username: {}, Tweet ID: {}", username, tweet_id)
client: Address | None = request.client
if client is None:
msg = "No client address"
raise ValueError(msg)
ips: DiscordIPs = await get_discord_ips()
# Append ["127.0.0.1"] for local testing.
ips.prefixes.append(Prefix(ipv4_prefix="127.0.0.1", services=["api", "media"]))
client_ip = ip_address(client.host)
for ip in ips.prefixes:
if client_ip in ip.ipv4_prefix:
logger.info("Client IP {} is in Discord IPs", client.host)
break
else:
logger.warning("Client IP {} is not in Discord IPs", client.host)
return Redirect(
path=f"https://twitter.com/{username}/status/{tweet_id}",
status_code=302,
)
logger.info("Client IP {} is in Discord IPs", client.host)
nitter_url = f"https://nitter.net/{username}/status/{tweet_id}"
logger.info("Getting tweet from Nitter: {}", nitter_url)
wreq_client = Client(emulation=Emulation.Chrome149)
resp: wreq.Response = await wreq_client.get(nitter_url)
data: str = await resp.text()
logger.info("Got tweet from Nitter: {}", data)
tweet = parse_tweet(data)
logger.info(tweet)
return {
"url": f"https://twitter.com/{username}/status/{tweet_id}",
"nitter_url": nitter_url,
"e_url": str(request.url),
"username": username,
"tweet_id": tweet_id,
"media": tweet["media"],
"author": tweet["author"],
"date": tweet["date"],
"text": tweet["text"],
"stats": tweet["stats"],
}

83
uv.lock generated
View file

@ -58,9 +58,12 @@ name = "e"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "htpy" },
{ name = "litestar" },
{ name = "loguru" },
{ name = "platformdirs" },
{ name = "pydantic" },
{ name = "selectolax" },
{ name = "uvicorn" },
{ name = "wreq" },
]
@ -72,9 +75,12 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "htpy", specifier = ">=26.5.1" },
{ name = "litestar", specifier = ">=2.24.0" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "platformdirs", specifier = ">=4.11.1" },
{ name = "pydantic", specifier = ">=2.13.4" },
{ name = "selectolax", specifier = ">=0.4.11" },
{ name = "uvicorn", specifier = ">=0.52.1" },
{ name = "wreq", specifier = ">=0.12.1" },
]
@ -103,6 +109,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "htpy"
version = "26.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/18/48b0fe5f7b23c8bb0b45b09c306f511417fee74afc49a0765178d6dcbac2/htpy-26.5.1.tar.gz", hash = "sha256:43a365c1fc670094da781b923883019339cec031397d61d5fe5f8e2f2278b63e", size = 292049, upload-time = "2026-05-22T08:31:31.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/68/ad12d6519ccc48852ebb1effbe5a160ff21d7b48c484d0dae6fd173f6d22/htpy-26.5.1-py3-none-any.whl", hash = "sha256:148566db9d720c897baa5832e2a496954ce317ab06ef49a103b44f8a15322c3b", size = 21428, upload-time = "2026-05-22T08:31:30.153Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@ -207,6 +225,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -303,6 +351,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "platformdirs"
version = "4.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/0a/062135c9a98dac804265073cc3afdbec5ae1aa37980bb354f461bafe81b4/platformdirs-4.11.1.tar.gz", hash = "sha256:bb1af68078f25e2f3e111e2d43b8d536df41b73c8a684b40bb018223b66fae27", size = 32396, upload-time = "2026-08-07T23:06:48.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/85/9b31b44296cfa3bb56cddb35e6a0f6578bab0b490c0806c0245e32c6110c/platformdirs-4.11.1-py3-none-any.whl", hash = "sha256:2efd27d363e8dd2e661639ffb398865a5e0a46442a11d266bf375a0e0c10e386", size = 23261, upload-time = "2026-08-07T23:06:47.219Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -459,6 +516,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/97/a87901aef6b7e7e4a34c6dd6cc17dca8594a592ef9d9dd765fca2b7facf7/rich_click-1.9.8-py3-none-any.whl", hash = "sha256:12873865396e6927835d4eabb1cc3996edcd65b7ac9b2391a29eca4f335a2f93", size = 72189, upload-time = "2026-05-28T19:54:57.867Z" },
]
[[package]]
name = "selectolax"
version = "0.4.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/6c/aec38dfee314a38cb7c0940fe055b22f22627b3e0a216772c24372eef3a9/selectolax-0.4.11.tar.gz", hash = "sha256:2b565ddabce6c9a7b73fa28a39acf8f411a084fa2f169234ec2470f552d4421d", size = 4883455, upload-time = "2026-07-15T07:25:30.588Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/5a/ba94f50ca5a6a0af65e8d47147bbe9f6ad11c408fd03c832ea737836d3eb/selectolax-0.4.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:663ff792f92ed749cfcf452ac19aff5da74b05521e7daacb3b74388deb14d117", size = 2266464, upload-time = "2026-07-15T07:24:41.038Z" },
{ url = "https://files.pythonhosted.org/packages/12/fe/f4d7d554cd7db415c831c8fb5a2b6bbbe3bdf5a49c8f417a6093d4618d6c/selectolax-0.4.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5ce592a92fceeca2694b369a83ad72891a9c356f668718fe7e1c83eea407bb4", size = 2317609, upload-time = "2026-07-15T07:24:42.682Z" },
{ url = "https://files.pythonhosted.org/packages/96/d6/9d702075634c1a38517a8af4242346bf0e65f206703037b56cf8da114eec/selectolax-0.4.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0f56c49161b18621ac452e42e02b0c5c61ba4c21095cfff3990e040bd9a043c", size = 2382277, upload-time = "2026-07-15T07:24:44.331Z" },
{ url = "https://files.pythonhosted.org/packages/84/c3/f541806ec7bdd0ce8ec69351572d2f2b3919264818cd5bb792482684d492/selectolax-0.4.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224682039ca13eb822be626e49a03592ee2b8557bcdc6381e49417a995170c94", size = 2430423, upload-time = "2026-07-15T07:24:45.937Z" },
{ url = "https://files.pythonhosted.org/packages/70/81/533fa254be8e63b1c0fbe261ba4e2c1ca86357a4844b0830a0d7ae0985f9/selectolax-0.4.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bd843540a197a33049a08fd80e59bfeafbaa688e632d53a05a9b65af5e88296f", size = 2404012, upload-time = "2026-07-15T07:24:47.774Z" },
{ url = "https://files.pythonhosted.org/packages/25/5a/3fc3de5bfdc70af07d55bdc17837b5fd4ae6229444868f057085addd9a18/selectolax-0.4.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b842c829f916fecb51f0f55882eca3e2ad49e85388178f14ae6fe0912be0a57", size = 2466775, upload-time = "2026-07-15T07:24:49.387Z" },
{ url = "https://files.pythonhosted.org/packages/f2/42/62c66067cbd3c360f762ac6964793091ea0371b3527ca2bf90955fb0b6f3/selectolax-0.4.11-cp314-cp314-win32.whl", hash = "sha256:d33e2ed75cc33e7af3fd50521c33e7d8634fae23bc197a6cee6a5015e056eef6", size = 1875717, upload-time = "2026-07-15T07:24:50.996Z" },
{ url = "https://files.pythonhosted.org/packages/14/b5/6d9ed39e909752645798c1469fb9443c0880ede999e63241ee89e91c7a54/selectolax-0.4.11-cp314-cp314-win_amd64.whl", hash = "sha256:e5929cbe3eedfaf51a09ec89642ab5355b703486d43bcf3c8f0c27d6043a488d", size = 1994595, upload-time = "2026-07-15T07:24:53.143Z" },
{ url = "https://files.pythonhosted.org/packages/49/f9/f172cfe8c29e295b9d7bc79e5b071937470f74311cd04dc3090d4166520a/selectolax-0.4.11-cp314-cp314-win_arm64.whl", hash = "sha256:466daca0599408c9d2cad7658a68490facc5c9b8d0f41ac5d17948914f57306f", size = 1928531, upload-time = "2026-07-15T07:24:55.539Z" },
{ url = "https://files.pythonhosted.org/packages/97/e9/6289d23fa4e5ccd5570a31c9180616a2e3c87ec565f7887bcfbca6204b6d/selectolax-0.4.11-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:086ca6f7e4c475bfff871ec1448ae5d342d43d6a2ca2cea65160d01b3a6a75ec", size = 2281363, upload-time = "2026-07-15T07:24:57.054Z" },
{ url = "https://files.pythonhosted.org/packages/06/c4/1fbf3624f9e52dadda8471dfb68eaf6021e819b827cdb62ce878fa28f469/selectolax-0.4.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b530a2c4fad7400af27b2b7e0333c1318ecb5f5dc38e8a141dbe3bd81b398fdf", size = 2325491, upload-time = "2026-07-15T07:24:58.969Z" },
{ url = "https://files.pythonhosted.org/packages/b6/ba/25710a259ecb2b66b9168956b768a2651533c8ea813da9decb0e0f3ee39a/selectolax-0.4.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3637d21f7fe60fbd6ca3dbc67a1747f6a55a9389114d72f06b5d69ba2beddf01", size = 2387575, upload-time = "2026-07-15T07:25:00.788Z" },
{ url = "https://files.pythonhosted.org/packages/bc/73/331f83e64e3a17478e832308248345d5224957eb7a62dad2e7fc5daa15b3/selectolax-0.4.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad5b1065f73eeaa07ea343cbc548aaa9f9a5c359c3bdd8d98f5d80b61550d1c", size = 2439126, upload-time = "2026-07-15T07:25:02.574Z" },
{ url = "https://files.pythonhosted.org/packages/d0/33/ab29a558dc65d3a1e28c217b62605b5135123ad89f1f825c8b741366e0fc/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1da54e42ab99b9191269306e13c0fd67ada1c6654e8dc8d74fac615931dd3c62", size = 2412927, upload-time = "2026-07-15T07:25:04.375Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b6/e774ec9179d7524adf47d7187b3e4e630104e149b2fbcbfe06088a3f4847/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28915b8fa90c1c3cb585858a6d24d433a3f38ea514aea59013bdb0930d9f6025", size = 2475264, upload-time = "2026-07-15T07:25:05.996Z" },
{ url = "https://files.pythonhosted.org/packages/97/14/0b4865125e777c9d852c9e388c1165e2ef4d7f1fb46596b13a1c02153fe7/selectolax-0.4.11-cp314-cp314t-win32.whl", hash = "sha256:1a6deb4464198ac67f32e56c4463aedf3e1d834b458eaac5b5b5b1ef02dcf15e", size = 1898010, upload-time = "2026-07-15T07:25:07.859Z" },
{ url = "https://files.pythonhosted.org/packages/40/1a/88db3237f2fb357119164c4f5a33a659615e3d10dd0f773d092341ee0cc4/selectolax-0.4.11-cp314-cp314t-win_amd64.whl", hash = "sha256:41f388c26304c1d840f5ee5e07c06bb9388ec834d10fec60dc148f22f98efd38", size = 2019721, upload-time = "2026-07-15T07:25:09.471Z" },
{ url = "https://files.pythonhosted.org/packages/37/03/193913c0f3d37c1e8d66ebfa0f139b2f286f70ec285907aa98b44a620447/selectolax-0.4.11-cp314-cp314t-win_arm64.whl", hash = "sha256:9077fa36e99ef4bb801194ff8f492f67279c0562e7cdfa9b4d06f5c010131969", size = 1950774, upload-time = "2026-07-15T07:25:11.533Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"