From e0c3e06ffef0ac0b7f7afe2ce0665314ed77e89b Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 12 Dec 2025 19:50:29 +0800 Subject: [PATCH] fix(bbcode): fix ReDos vulnerabilities in BBCodeService (#96) * fix(bbcode): fix ReDos of imagemap parsing * fix(bbcode): use `regex` and add timeout to avoid too long time to parse * feat(bbcode): use `make_tag` to generate HTML tags * docs(bbcode): add docstrings for BBCodeService * fix(user): validate BBCode content before processing userpage update * fix(bbcode): catch timeout errors in BBCode parsing with MaliciousBBCodeError * fix(bbcode): resolve reviews * fix(bbcode): use `make_tag` in `_parse_size` Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix(bbcode): fix using `make_tag` in `_parse_size` --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/models/userpage.py | 9 + app/router/private/user.py | 5 + app/service/bbcode_service.py | 769 ++++++++++++++++++++++++---------- pyproject.toml | 1 + uv.lock | 80 ++++ 5 files changed, 634 insertions(+), 230 deletions(-) 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}" + @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"