diff --git a/app/models/userpage.py b/app/models/userpage.py
index d03b085..57545ad 100644
--- a/app/models/userpage.py
+++ b/app/models/userpage.py
@@ -98,3 +98,12 @@ class ForbiddenTagError(UserpageError):
message = f"Forbidden tag '{tag}' is not allowed."
super().__init__(message, "forbidden_tag")
self.tag = tag
+
+
+class MaliciousBBCodeError(UserpageError):
+ """恶意BBCode错误"""
+
+ def __init__(self, detail: str):
+ message = f"Malicious BBCode detected: {detail}"
+ super().__init__(message, "malicious_bbcode")
+ self.detail = detail
diff --git a/app/router/private/user.py b/app/router/private/user.py
index 3646f08..5443a73 100644
--- a/app/router/private/user.py
+++ b/app/router/private/user.py
@@ -105,6 +105,11 @@ async def update_userpage(
raise HTTPException(403, "Your account is restricted and cannot perform this action.")
try:
+ errors = bbcode_service.validate_bbcode(request.body)
+ if errors:
+ msg = "Invalid BBCode content: " + "; ".join(errors)
+ raise UserpageError(msg)
+
# 处理BBCode内容
processed_page = bbcode_service.process_userpage_content(request.body)
diff --git a/app/service/bbcode_service.py b/app/service/bbcode_service.py
index f890b85..e0d88e6 100644
--- a/app/service/bbcode_service.py
+++ b/app/service/bbcode_service.py
@@ -1,27 +1,55 @@
"""
-BBCode处理服务
-基于 osu-web 官方实现的 BBCode 解析器
-支持所有 osu! 官方 BBCode 标签
+BBCode markup language to HTML.
+
+This module provides functionality to parse BBCode into HTML, sanitize the HTML,
+and validate BBCode syntax, based on the implementation from osu-web.
+
+Reference:
+ - https://osu.ppy.sh/wiki/BBCode
+ - https://github.com/ppy/osu-web/blob/master/app/Libraries/BBCodeFromDB.php
"""
import html
-import re
from typing import ClassVar
from app.models.userpage import (
ContentEmptyError,
ContentTooLongError,
ForbiddenTagError,
+ MaliciousBBCodeError,
)
import bleach
from bleach.css_sanitizer import CSSSanitizer
+import regex as re
+
+HTTP_PATTERN = re.compile(r"^https?://", re.IGNORECASE)
+REGEX_TIMEOUT = 5
class BBCodeService:
- """BBCode处理服务类 - 基于 osu-web 官方实现"""
+ """A service for parsing and sanitizing BBCode content.
- # 允许的HTML标签和属性 - 基于官方实现
+ Attributes:
+ ALLOWED_TAGS: A list of allowed HTML tags in sanitized content.
+ ALLOWED_ATTRIBUTES: A dictionary mapping HTML tags to their allowed attributes.
+ FORBIDDEN_TAGS: A list of disallowed HTML tags that should not appear in user-generated content.
+
+ Methods:
+ parse_bbcode(text: str) -> str:
+ Parse BBCode text and convert it to HTML.
+
+ make_tag(tag: str, content: str, attributes: dict[str, str] | None = None, self_closing: bool = False) -> str:
+ Generate an HTML tag with optional attributes.
+
+ sanitize_html(html_content: str) -> str:
+ Clean and sanitize HTML content to prevent XSS attacks.
+
+ process_userpage_content(raw_content: str, max_length: int = 60000) -> dict[str, str]:
+ Process user page content based on osu-web's handling procedure.
+ """
+
+ # allowed HTML tags in sanitized content
ALLOWED_TAGS: ClassVar[list[str]] = [
"a",
"audio",
@@ -45,10 +73,10 @@ class BBCodeService:
"strong",
"u",
"ul",
- # imagemap 相关
+ # imagemap
"map",
"area",
- # 自定义容器
+ # custom box
"details",
"summary",
]
@@ -75,7 +103,7 @@ class BBCodeService:
"*": ["class"],
}
- # 危险的BBCode标签(不允许)
+ # Disallowed tags that should not appear in user-generated content
FORBIDDEN_TAGS: ClassVar[list[str]] = [
"script",
"iframe",
@@ -98,261 +126,463 @@ class BBCodeService:
@classmethod
def parse_bbcode(cls, text: str) -> str:
"""
- 解析BBCode文本并转换为HTML
- 基于 osu-web BBCodeFromDB.php 的实现
+ Parse BBCode text and convert it to HTML.
Args:
- text: 包含BBCode的原始文本
+ text: Original text containing BBCode
Returns:
- 转换后的HTML字符串
+ Converted HTML string
+
+ Reference:
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L354
"""
if not text:
return ""
- # 预处理:转义HTML实体
text = html.escape(text)
- # 按照 osu-web 的解析顺序进行处理
- # 块级标签处理
- text = cls._parse_imagemap(text)
- text = cls._parse_box(text)
- text = cls._parse_code(text)
- text = cls._parse_list(text)
- text = cls._parse_notice(text)
- text = cls._parse_quote(text)
- text = cls._parse_heading(text)
+ try:
+ text = cls._parse_imagemap(text)
+ text = cls._parse_box(text)
+ text = cls._parse_code(text)
+ text = cls._parse_list(text)
+ text = cls._parse_notice(text)
+ text = cls._parse_quote(text)
+ text = cls._parse_heading(text)
- # 行内标签处理
- text = cls._parse_audio(text)
- text = cls._parse_bold(text)
- text = cls._parse_centre(text)
- text = cls._parse_inline_code(text)
- text = cls._parse_colour(text)
- text = cls._parse_email(text)
- text = cls._parse_image(text)
- text = cls._parse_italic(text)
- text = cls._parse_size(text)
- text = cls._parse_smilies(text)
- text = cls._parse_spoiler(text)
- text = cls._parse_strike(text)
- text = cls._parse_underline(text)
- text = cls._parse_url(text)
- text = cls._parse_youtube(text)
- text = cls._parse_profile(text)
+ # inline tags
+ text = cls._parse_audio(text)
+ text = cls._parse_bold(text)
+ text = cls._parse_centre(text)
+ text = cls._parse_inline_code(text)
+ text = cls._parse_colour(text)
+ text = cls._parse_email(text)
+ text = cls._parse_image(text)
+ text = cls._parse_italic(text)
+ text = cls._parse_size(text)
+ text = cls._parse_smilies(text)
+ text = cls._parse_spoiler(text)
+ text = cls._parse_strike(text)
+ text = cls._parse_underline(text)
+ text = cls._parse_url(text)
+ text = cls._parse_youtube(text)
+ text = cls._parse_profile(text)
+ except TimeoutError:
+ raise MaliciousBBCodeError("Regular expression processing timed out.")
- # 换行处理
+ # replace newlines with
text = text.replace("\n", "
")
return text
+ @classmethod
+ def make_tag(
+ cls,
+ tag: str,
+ content: str,
+ attributes: dict[str, str] | None = None,
+ self_closing: bool = False,
+ ) -> str:
+ """Generate an HTML tag with optional attributes."""
+ attr_str = ""
+ if attributes:
+ attr_parts = [f'{key}="{html.escape(value)}"' for key, value in attributes.items()]
+ attr_str = " " + " ".join(attr_parts)
+
+ if self_closing:
+ return f"<{tag}{attr_str} />"
+ else:
+ return f"<{tag}{attr_str}>{content}{tag}>"
+
@classmethod
def _parse_audio(cls, text: str) -> str:
- """解析 [audio] 标签"""
+ """
+ Parse [audio] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#audio
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L41
+ """
pattern = r"\[audio\]([^\[]+)\[/audio\]"
def replace_audio(match):
url = match.group(1).strip()
- return f''
+ return cls.make_tag("audio", "", attributes={"controls": "", "preload": "none", "src": url})
- return re.sub(pattern, replace_audio, text, flags=re.IGNORECASE)
+ return re.sub(pattern, replace_audio, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_bold(cls, text: str) -> str:
- """解析 [b] 标签"""
- text = re.sub(r"\[b\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/b\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [b] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#bold
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L55
+ """
+ text = re.sub(r"\[b\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/b\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_box(cls, text: str) -> str:
- """解析 [box] 和 [spoilerbox] 标签"""
- # [box=title] 格式
+ """
+ Parse [box] and [spoilerbox] tags.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#box
+ - https://osu.ppy.sh/wiki/en/BBCode#spoilerbox
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L63
+ """
+ # [box=title] format
pattern = r"\[box=([^\]]+)\](.*?)\[/box\]"
def replace_box_with_title(match):
title = match.group(1)
content = match.group(2)
- return (
- f"
"
- f"
"
- f"
{content}
"
+
+ icon = cls.make_tag("span", "", attributes={"class": "bbcode-spoilerbox__link-icon"})
+ button_content = icon + title
+ button = cls.make_tag(
+ "button",
+ button_content,
+ attributes={
+ "type": "button",
+ "class": "js-spoilerbox__link bbcode-spoilerbox__link",
+ "style": (
+ "background: none; border: none; cursor: pointer; padding: 0; text-align: left; width: 100%;"
+ ),
+ },
)
+ body = cls.make_tag("div", content, attributes={"class": "js-spoilerbox__body bbcode-spoilerbox__body"})
+ return cls.make_tag("div", button + body, attributes={"class": "js-spoilerbox bbcode-spoilerbox"})
- text = re.sub(pattern, replace_box_with_title, text, flags=re.DOTALL | re.IGNORECASE)
+ text = re.sub(pattern, replace_box_with_title, text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
- # [spoilerbox] 格式
+ # [spoilerbox] format
pattern = r"\[spoilerbox\](.*?)\[/spoilerbox\]"
def replace_spoilerbox(match):
content = match.group(1)
- return (
- f""
- f"
"
- f"
{content}
"
- )
- return re.sub(pattern, replace_spoilerbox, text, flags=re.DOTALL | re.IGNORECASE)
+ icon = cls.make_tag("span", "", attributes={"class": "bbcode-spoilerbox__link-icon"})
+ button_content = icon + "SPOILER"
+ button = cls.make_tag(
+ "button",
+ button_content,
+ attributes={
+ "type": "button",
+ "class": "js-spoilerbox__link bbcode-spoilerbox__link",
+ "style": (
+ "background: none; border: none; cursor: pointer; padding: 0; text-align: left; width: 100%;"
+ ),
+ },
+ )
+ body = cls.make_tag("div", content, attributes={"class": "js-spoilerbox__body bbcode-spoilerbox__body"})
+ return cls.make_tag("div", button + body, attributes={"class": "js-spoilerbox bbcode-spoilerbox"})
+
+ return re.sub(pattern, replace_spoilerbox, text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_centre(cls, text: str) -> str:
- """解析 [centre] 标签"""
- text = re.sub(r"\[centre\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/centre\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[center\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/center\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [centre] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#centre
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L86
+ """
+ text = re.sub(r"\[centre\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/centre\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[center\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/center\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_code(cls, text: str) -> str:
- """解析 [code] 标签"""
+ """
+ Parse [code] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#code-block
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L94
+ """
pattern = r"\[code\]\n*(.*?)\n*\[/code\]"
- return re.sub(pattern, r"\1
", text, flags=re.DOTALL | re.IGNORECASE)
+
+ def replace_code(match):
+ return cls.make_tag("pre", match.group(1))
+
+ return re.sub(pattern, replace_code, text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_colour(cls, text: str) -> str:
- """解析 [color] 标签"""
+ """
+ Parse [color] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#colour
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L103
+ """
pattern = r"\[color=([^\]]+)\](.*?)\[/color\]"
- return re.sub(pattern, r'\2', text, flags=re.IGNORECASE)
+
+ def replace_colour(match):
+ return cls.make_tag("span", match.group(2), attributes={"style": f"color:{match.group(1)}"})
+
+ return re.sub(pattern, replace_colour, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_email(cls, text: str) -> str:
- """解析 [email] 标签"""
+ """
+ Parse [email] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#email
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L111
+ """
# [email]email@example.com[/email]
pattern1 = r"\[email\]([^\[]+)\[/email\]"
- text = re.sub(pattern1, r'\1', text, flags=re.IGNORECASE)
+
+ def replace_email1(match):
+ email = match.group(1)
+ return cls.make_tag("a", email, attributes={"rel": "nofollow", "href": f"mailto:{email}"})
+
+ text = re.sub(
+ pattern1,
+ replace_email1,
+ text,
+ flags=re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
# [email=email@example.com]text[/email]
pattern2 = r"\[email=([^\]]+)\](.*?)\[/email\]"
- text = re.sub(pattern2, r'\2', text, flags=re.IGNORECASE)
+
+ def replace_email2(match):
+ email = match.group(1)
+ content = match.group(2)
+ return cls.make_tag("a", content, attributes={"rel": "nofollow", "href": f"mailto:{email}"})
+
+ text = re.sub(
+ pattern2,
+ replace_email2,
+ text,
+ flags=re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
return text
@classmethod
def _parse_heading(cls, text: str) -> str:
- """解析 [heading] 标签"""
+ """
+ Parse [heading] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#heading-(v1)
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L124
+ """
pattern = r"\[heading\](.*?)\[/heading\]"
- return re.sub(pattern, r"\1
", text, flags=re.IGNORECASE)
+
+ def replace_heading(match):
+ return cls.make_tag("h2", match.group(1))
+
+ return re.sub(pattern, replace_heading, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_image(cls, text: str) -> str:
- """解析 [img] 标签"""
+ """
+ Parse [img] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#images
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L194
+ """
pattern = r"\[img\]([^\[]+)\[/img\]"
def replace_image(match):
url = match.group(1).strip()
- # TODO: 可以在这里添加图片代理支持
- # 生成带有懒加载的图片标签
- return f'
'
+ # TODO: image reverse proxy support
+ return cls.make_tag(
+ "img",
+ "",
+ attributes={"loading": "lazy", "src": url, "alt": "", "style": "max-width: 100%; height: auto;"},
+ self_closing=True,
+ )
- return re.sub(pattern, replace_image, text, flags=re.IGNORECASE)
+ return re.sub(pattern, replace_image, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_imagemap(cls, text: str) -> str:
"""
- 解析 [imagemap] 标签
- 基于 osu-web BBCodeFromDB.php 的实现
+ Parse [imagemap] tag.
+ Use a simple parser to avoid ReDos vulnerabilities.
+
+ Structure:
+ [imagemap]
+ IMAGE_URL
+ X(int) Y(int) WIDTH(int) HEIGHT(int) REDIRECT(url or #) TITLE(optional)
+ ...
+ [/imagemap]
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#imagemap
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L132
"""
- pattern = r"\[imagemap\]\s*\n([^\s\n]+)\s*\n((?:[0-9.]+ [0-9.]+ [0-9.]+ [0-9.]+ (?:#|https?://[^\s]+|mailto:[^\s]+)[^\n]*\n?)+)\[/imagemap\]"
+ redirect_pattern = re.compile(r"^(#|https?://[^\s]+|mailto:[^\s]+)$", re.IGNORECASE)
- def replace_imagemap(match):
- image_url = match.group(1).strip()
- links_data = match.group(2).strip()
+ def replace_imagemap(match: re.Match) -> str:
+ content = match.group(1)
+ content = html.unescape(content)
- if not links_data:
- return f'
'
+ result = [""]
+ lines = content.strip().splitlines()
+ if len(lines) < 2:
+ return text
+ image_url = lines[0].strip()
+ if not HTTP_PATTERN.match(image_url, timeout=REGEX_TIMEOUT):
+ return text
+ result.append(
+ cls.make_tag(
+ "img",
+ "",
+ attributes={"src": image_url, "loading": "lazy", "class": "imagemap__image"},
+ self_closing=True,
+ )
+ )
- # 解析链接数据
- links = []
- for line in links_data.split("\n"):
- line = line.strip()
- if not line:
+ for line in lines[1:]:
+ parts = line.strip().split()
+ if len(parts) < 5:
+ continue
+ x, y, width, height, redirect = parts[:5]
+ title = " ".join(parts[5:]) if len(parts) > 5 else ""
+ if not redirect_pattern.match(redirect, timeout=REGEX_TIMEOUT):
continue
- # 按空格分割,最多分成6部分(前5个是数字和URL,第6个是标题)
- parts = line.split(" ", 5)
- if len(parts) >= 5:
- try:
- left = float(parts[0])
- top = float(parts[1])
- width = float(parts[2])
- height = float(parts[3])
- href = parts[4]
- # 标题可能包含空格,所以重新组合
- title = parts[5] if len(parts) > 5 else ""
-
- # 构建样式
- style = f"left: {left}%; top: {top}%; width: {width}%; height: {height}%;"
-
- if href == "#":
- # 无链接区域
- links.append(f'
')
- else:
- # 有链接区域
- links.append(
- f'
'
- )
- except (ValueError, IndexError):
- continue
-
- if links:
- links_html = "".join(links)
- # 基于官方实现的图片标签
- image_html = (
- f'

'
+ result.append(
+ cls.make_tag(
+ "span" if redirect == "#" else "a",
+ "",
+ attributes={
+ "href": redirect,
+ "style": f"left: {x}%; top: {y}%; width: {width}%; height: {height}%;",
+ "title": title,
+ "class": "imagemap__link",
+ },
+ self_closing=True,
+ )
)
- # 使用imagemap容器
- return f'
{image_html}{links_html}
'
- else:
- return f'

'
+ result.append("
")
+ return "".join(result)
- return re.sub(pattern, replace_imagemap, text, flags=re.DOTALL | re.IGNORECASE)
+ imagemap_box = re.sub(
+ r"\[imagemap\]((?:(?!\[/imagemap\]).)*?)\[/imagemap\]",
+ replace_imagemap,
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
+ return imagemap_box
@classmethod
def _parse_italic(cls, text: str) -> str:
- """解析 [i] 标签"""
- text = re.sub(r"\[i\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/i\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [i] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#italic
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L186
+ """
+ text = re.sub(r"\[i\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/i\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_inline_code(cls, text: str) -> str:
- """解析 [c] 内联代码标签"""
- text = re.sub(r"\[c\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/c\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [c] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#inline-code
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L236
+ """
+ text = re.sub(r"\[c\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/c\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_list(cls, text: str) -> str:
- """解析 [list] 标签"""
- # 有序列表
+ """
+ Parse [list] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#formatted-lists
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L244
+ """
+ # ordedred list
pattern = r"\[list=1\](.*?)\[/list\]"
- text = re.sub(pattern, r"\1
", text, flags=re.DOTALL | re.IGNORECASE)
- # 无序列表
+ def replace_ordered(match):
+ return cls.make_tag("ol", match.group(1))
+
+ text = re.sub(pattern, replace_ordered, text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
+
+ # unordered list
pattern = r"\[list\](.*?)\[/list\]"
- text = re.sub(pattern, r"\1
", text, flags=re.DOTALL | re.IGNORECASE)
- # 列表项
+ def replace_unordered(match):
+ return cls.make_tag("ol", match.group(1), attributes={"class": "unordered"})
+
+ text = re.sub(
+ pattern,
+ replace_unordered,
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
+
+ # list item
pattern = r"\[\*\]\s*(.*?)(?=\[\*\]|\[/list\]|$)"
- text = re.sub(pattern, r"\1", text, flags=re.DOTALL | re.IGNORECASE)
+
+ def replace_item(match):
+ return cls.make_tag("li", match.group(1))
+
+ text = re.sub(pattern, replace_item, text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_notice(cls, text: str) -> str:
- """解析 [notice] 标签"""
+ """
+ Parse [notice] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#notice
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L264
+ """
pattern = r"\[notice\]\n*(.*?)\n*\[/notice\]"
- return re.sub(pattern, r'\1
', text, flags=re.DOTALL | re.IGNORECASE)
+
+ def replace_notice(match):
+ return cls.make_tag("div", match.group(1), attributes={"class": "well"})
+
+ return re.sub(
+ pattern,
+ replace_notice,
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
@classmethod
def _parse_profile(cls, text: str) -> str:
- """解析 [profile] 标签"""
+ """
+ Parse [profile] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#profile
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L273
+ """
pattern = r"\[profile(?:=(\d+))?\](.*?)\[/profile\]"
def replace_profile(match):
@@ -360,114 +590,207 @@ class BBCodeService:
username = match.group(2)
if user_id:
- return f'{username}'
+ return cls.make_tag(
+ "a",
+ username,
+ attributes={"href": f"/users/{user_id}", "class": "user-profile-link", "data-user-id": user_id},
+ )
else:
- return f'@{username}'
+ return cls.make_tag(
+ "a", f"@{username}", attributes={"href": f"/users/@{username}", "class": "user-profile-link"}
+ )
- return re.sub(pattern, replace_profile, text, flags=re.IGNORECASE)
+ return re.sub(pattern, replace_profile, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def _parse_quote(cls, text: str) -> str:
- """解析 [quote] 标签"""
+ """
+ Parse [quote] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#quote
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L285
+ """
# [quote="author"]content[/quote]
- pattern1 = r'\[quote="([^"]+)"\]\s*(.*?)\s*\[/quote\]'
- text = re.sub(pattern1, r"\1 wrote:
\2
", text, flags=re.DOTALL | re.IGNORECASE)
+ # Handle both raw quotes and HTML-escaped quotes (")
+ pattern1 = r'\[quote=(?:"|")(.+?)(?:"|")\]\s*(.*?)\s*\[/quote\]'
+
+ def replace_quote1(match):
+ author = match.group(1)
+ content = match.group(2)
+ heading = cls.make_tag("h4", f"{author} wrote:")
+ return cls.make_tag("blockquote", heading + content)
+
+ text = re.sub(
+ pattern1,
+ replace_quote1,
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
# [quote]content[/quote]
pattern2 = r"\[quote\]\s*(.*?)\s*\[/quote\]"
- text = re.sub(pattern2, r"\1
", text, flags=re.DOTALL | re.IGNORECASE)
+
+ def replace_quote2(match):
+ return cls.make_tag("blockquote", match.group(1))
+
+ text = re.sub(
+ pattern2,
+ replace_quote2,
+ text,
+ flags=re.DOTALL | re.IGNORECASE,
+ timeout=REGEX_TIMEOUT,
+ )
return text
@classmethod
def _parse_size(cls, text: str) -> str:
- """解析 [size] 标签"""
+ """
+ Parse [size] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#font-size
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L326
+ """
def replace_size(match):
size = int(match.group(1))
- # 限制字体大小范围 (30-200%)
+ # limit font size range (30-200%)
size = max(30, min(200, size))
- return f''
+ return cls.make_tag("span", "", attributes={"style": f"font-size:{size}%"})
pattern = r"\[size=(\d+)\]"
- text = re.sub(pattern, replace_size, text, flags=re.IGNORECASE)
- text = re.sub(r"\[/size\]", "", text, flags=re.IGNORECASE)
+ text = re.sub(pattern, replace_size, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/size\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_smilies(cls, text: str) -> str:
- """解析表情符号标签"""
- # 处理 phpBB 风格的表情符号标记
+ """
+ Parse smilies.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L296
+ """
+ # handle phpBB style smilies
pattern = r"
"
- return re.sub(pattern, r'
", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/spoiler\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [spoiler] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#spoiler
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L318
+ """
+ text = re.sub(r"\[spoiler\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/spoiler\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_strike(cls, text: str) -> str:
- """解析 [s] 和 [strike] 标签"""
- text = re.sub(r"\[s\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/s\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[strike\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/strike\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [s] and [strike] tags.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#strikethrough
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L301
+ """
+ text = re.sub(r"\[s\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/s\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[strike\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/strike\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_underline(cls, text: str) -> str:
- """解析 [u] 标签"""
- text = re.sub(r"\[u\]", "", text, flags=re.IGNORECASE)
- text = re.sub(r"\[/u\]", "", text, flags=re.IGNORECASE)
+ """
+ Parse [u] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#underline
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L310
+ """
+ text = re.sub(r"\[u\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
+ text = re.sub(r"\[/u\]", "", text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_url(cls, text: str) -> str:
- """解析 [url] 标签"""
+ """
+ Parse [url] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#url
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L337
+ """
# [url]http://example.com[/url]
pattern1 = r"\[url\]([^\[]+)\[/url\]"
- text = re.sub(pattern1, r'\1', text, flags=re.IGNORECASE)
+
+ def replace_url1(match):
+ url = match.group(1)
+ return cls.make_tag("a", url, attributes={"rel": "nofollow", "href": url})
+
+ text = re.sub(pattern1, replace_url1, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
# [url=http://example.com]text[/url]
pattern2 = r"\[url=([^\]]+)\](.*?)\[/url\]"
- text = re.sub(pattern2, r'\2', text, flags=re.IGNORECASE)
+
+ def replace_url2(match):
+ url = match.group(1)
+ content = match.group(2)
+ return cls.make_tag("a", content, attributes={"rel": "nofollow", "href": url})
+
+ text = re.sub(pattern2, replace_url2, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
return text
@classmethod
def _parse_youtube(cls, text: str) -> str:
- """解析 [youtube] 标签"""
+ """
+ Parse [youtube] tag.
+
+ Reference:
+ - https://osu.ppy.sh/wiki/en/BBCode#youtube
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L346
+ """
pattern = r"\[youtube\]([a-zA-Z0-9_-]{11})\[/youtube\]"
def replace_youtube(match):
video_id = match.group(1)
- return (
- f""
+ return cls.make_tag(
+ "iframe",
+ "",
+ attributes={
+ "class": "u-embed-wide u-embed-wide--bbcode",
+ "src": f"https://www.youtube.com/embed/{video_id}?rel=0",
+ "allowfullscreen": "",
+ },
)
- return re.sub(pattern, replace_youtube, text, flags=re.IGNORECASE)
+ return re.sub(pattern, replace_youtube, text, flags=re.IGNORECASE, timeout=REGEX_TIMEOUT)
@classmethod
def sanitize_html(cls, html_content: str) -> str:
"""
- 清理HTML内容,移除危险标签和属性
- 基于 osu-web 的安全策略
+ Clean and sanitize HTML content to prevent XSS attacks.
+ Uses bleach to allow only a safe subset of HTML tags and attributes.
Args:
- html_content: 要清理的HTML内容
+ html_content: Original HTML content
Returns:
- 清理后的安全HTML
+ Sanitized HTML content
"""
if not html_content:
return ""
- # 使用bleach清理HTML,配置CSS清理器以允许安全的样式
css_sanitizer = CSSSanitizer(
allowed_css_properties=[
"color",
@@ -510,66 +833,49 @@ class BBCodeService:
@classmethod
def process_userpage_content(cls, raw_content: str, max_length: int = 60000) -> dict[str, str]:
"""
- 处理用户页面内容
- 基于 osu-web 的处理流程
+ Process userpage BBCode content.
Args:
- raw_content: 原始BBCode内容
- max_length: 最大允许长度(字符数,支持多字节字符)
+ raw_content: Raw BBCode content
+ max_length: Maximum allowed length
Returns:
- 包含raw和html两个版本的字典
+ A dictionary containing both raw and html versions
"""
- # 检查内容是否为空或仅包含空白字符
if not raw_content or not raw_content.strip():
raise ContentEmptyError()
- # 检查长度限制(Python的len()本身支持Unicode字符计数)
content_length = len(raw_content)
if content_length > max_length:
raise ContentTooLongError(content_length, max_length)
- # 检查是否包含禁止的标签
content_lower = raw_content.lower()
for forbidden_tag in cls.FORBIDDEN_TAGS:
if f"[{forbidden_tag}" in content_lower or f"<{forbidden_tag}" in content_lower:
raise ForbiddenTagError(forbidden_tag)
- # 转换BBCode为HTML
html_content = cls.parse_bbcode(raw_content)
-
- # 清理HTML
safe_html = cls.sanitize_html(html_content)
- # 包装在 bbcode 容器中
- final_html = f'{safe_html}
'
+ # Wrap in a container div
+ final_html = cls.make_tag("div", safe_html, attributes={"class": "bbcode"})
return {"raw": raw_content, "html": final_html}
@classmethod
def validate_bbcode(cls, content: str) -> list[str]:
- """
- 验证BBCode语法并返回错误列表
- 基于 osu-web 的验证逻辑
-
- Args:
- content: 要验证的BBCode内容
-
- Returns:
- 错误消息列表
- """
errors = []
- # 检查内容是否仅包含引用(参考官方逻辑)
+ # check for content that is only quotes
content_without_quotes = cls._remove_block_quotes(content)
if content.strip() and not content_without_quotes.strip():
errors.append("Content cannot contain only quotes")
- # 检查标签配对
+ # check for balanced tags
tag_stack = []
tag_pattern = r"\[(/?)(\w+)(?:=[^\]]+)?\]"
- for match in re.finditer(tag_pattern, content, re.IGNORECASE):
+ for match in re.finditer(tag_pattern, content, re.IGNORECASE, timeout=REGEX_TIMEOUT):
is_closing = match.group(1) == "/"
tag_name = match.group(2).lower()
@@ -581,11 +887,11 @@ class BBCodeService:
else:
tag_stack.pop()
else:
- # 特殊处理自闭合标签(只有列表项 * 是真正的自闭合)
+ # Self-closing tags
if tag_name not in ["*"]:
tag_stack.append(tag_name)
- # 检查未关闭的标签
+ # check for any unclosed tags
for unclosed_tag in tag_stack:
errors.append(f"Unclosed tag '[{unclosed_tag}]'")
@@ -594,36 +900,39 @@ class BBCodeService:
@classmethod
def _remove_block_quotes(cls, text: str) -> str:
"""
- 移除引用块(参考 osu-web BBCodeFromDB::removeBlockQuotes)
+ Remove block quotes.
Args:
- text: 原始文本
+ text: Original text
Returns:
- 移除引用后的文本
+ Text with block quotes removed
+
+ Reference:
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L456
"""
- # 基于官方实现的简化版本
- # 移除 [quote]...[/quote] 和 [quote=author]...[/quote]
+ # remove [quote]...[/quote] blocks
pattern = r"\[quote(?:=[^\]]+)?\].*?\[/quote\]"
- result = re.sub(pattern, "", text, flags=re.DOTALL | re.IGNORECASE)
+ result = re.sub(pattern, "", text, flags=re.DOTALL | re.IGNORECASE, timeout=REGEX_TIMEOUT)
return result.strip()
@classmethod
def remove_bbcode_tags(cls, text: str) -> str:
"""
- 移除所有BBCode标签,只保留纯文本
- 用于搜索索引等场景
- 基于官方实现
+ Remove all BBCode tags, keeping only plain text.
+ Used for search indexing etc.
+
+ Reference:
+ - https://github.com/ppy/osu-web/blob/15e2d50067c8f5d3dfd2010a79a031efe0dfd10f/app/Libraries/BBCodeFromDB.php#L446
"""
- # 基于官方实现的完整BBCode标签模式
+ # remove all BBCode tags
pattern = (
r"\[/?(\*|\*:m|audio|b|box|color|spoilerbox|centre|center|code|email|heading|i|img|"
r"list|list:o|list:u|notice|profile|quote|s|strike|u|spoiler|size|url|youtube|c)"
- r"(=.*?(?=:))?(:[a-zA-Z0-9]{1,5})?\]"
+ r"(?:=.*?)?(:[a-zA-Z0-9]{1,5})?\]"
)
- return re.sub(pattern, "", text)
+ return re.sub(pattern, "", text, timeout=REGEX_TIMEOUT)
-# 服务实例
bbcode_service = BBCodeService()
diff --git a/pyproject.toml b/pyproject.toml
index e45efa3..6dc4d31 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,6 +31,7 @@ dependencies = [
"python-jose[cryptography]>=3.3.0",
"python-multipart>=0.0.6",
"redis>=5.0.1",
+ "regex>=2025.11.3",
"sentry-sdk[fastapi,httpx,loguru,sqlalchemy]>=2.34.1",
"sqlalchemy>=2.0.23",
"sqlmodel>=0.0.24",
diff --git a/uv.lock b/uv.lock
index 31b87e6..00575ed 100644
--- a/uv.lock
+++ b/uv.lock
@@ -745,6 +745,7 @@ dependencies = [
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" },
{ name = "redis" },
+ { name = "regex" },
{ name = "sentry-sdk", extra = ["fastapi", "httpx", "loguru", "sqlalchemy"] },
{ name = "sqlalchemy" },
{ name = "sqlmodel" },
@@ -794,6 +795,7 @@ requires-dist = [
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.3.0" },
{ name = "python-multipart", specifier = ">=0.0.6" },
{ name = "redis", specifier = ">=5.0.1" },
+ { name = "regex", specifier = ">=2025.11.3" },
{ name = "rosu-pp-py", marker = "extra == 'rosu'", specifier = ">=3.1.0" },
{ name = "sentry-sdk", extras = ["fastapi", "httpx", "loguru", "sqlalchemy"], specifier = ">=2.34.1" },
{ name = "sqlalchemy", specifier = ">=2.0.23" },
@@ -1673,6 +1675,84 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" },
]
+[[package]]
+name = "regex"
+version = "2025.11.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" },
+ { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" },
+ { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" },
+ { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" },
+ { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" },
+ { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" },
+ { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" },
+ { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" },
+ { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" },
+ { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" },
+ { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" },
+ { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" },
+ { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" },
+ { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" },
+ { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" },
+ { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" },
+ { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" },
+ { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" },
+ { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" },
+ { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" },
+ { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" },
+ { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" },
+ { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" },
+ { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" },
+ { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" },
+ { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" },
+ { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" },
+]
+
[[package]]
name = "requests"
version = "2.32.5"