Compare commits

12 Commits

Author SHA1 Message Date
49abdd8160 Switch GitHub Actions workflow runner to ubuntu-slim 2025-11-04 22:51:38 +01:00
fe3181d9a3 Switch GitHub Actions workflow runner to ubuntu-latest 2025-11-04 22:50:00 +01:00
6acf030e74 Reduce logging verbosity in scrape.py by setting level to INFO 2025-11-04 22:47:11 +01:00
7f102a30ed Use compiled regex patterns in scrape.py and replace ad-hoc re.sub calls
- Add module-level compiled regexes (DISCORD_LINK_PATTERN, URL_PREFIX_PATTERN,
  NON_BREAKING_SPACE_PATTERN, EMPTY_CODE_BLOCK_PATTERN, SQUARE_BRACKETS_PATTERN,
  BALL_PATTERN, REFERENCE_MARK_PATTERN, ESCAPED_STAR_PATTERN,
  CIRCLED_NUMBER_PATTERNS)
- Use .sub on those compiled patterns in format_discord_links and
  generate_atom_feed instead of repeated re.sub calls
- Consolidate circled-number replacement and simplify escaped-star handling
- Improves readability and avoids recompiling regexes on each use
2025-11-04 22:45:49 +01:00
github-actions[bot]
27b14909df Updated files: articles_all.xml 2025-11-04 21:21:09 +00:00
b2d4ad5946 Remove actions/setup-python step and enable-cache option from CI workflow 2025-11-04 22:20:02 +01:00
8b505af889 Switch GitHub Actions workflow runner to ubuntu-slim 2025-11-04 22:13:08 +01:00
a09ea0bd9a Use anyio.Path for async filesystem ops, tighten formatting, and harden mdformat
- Switch to anyio.Path for non-blocking filesystem operations (mkdir, glob)
  so article directory creation and listing are async-friendly.
- Replace blocking sync glob with an async comprehension to build existing_files.
- Harden mdformat usage: call formatter inside try/except and fall back to
  unformatted markdown on error to avoid crashes from unsupported options.
- Set logging to DEBUG for more verbose output during runs.
- Miscellaneous cleanups: reformat imports/long lists, collapse multi-line
  constructs, and simplify timestamp parsing/formatting.
2025-11-04 22:11:59 +01:00
192615fddb Bump Python requirement to 3.14, relax markdown pin, and add Ruff config
- Update pyproject.toml to require Python >=3.14
- Remove explicit markdown minimum version (use unpinned "markdown")
- Add comprehensive [tool.ruff] configuration (select, pydocstyle convention, ignored rules, per-file ignores)
- Add Ruff isort setting to force single-line imports
2025-11-04 22:10:19 +01:00
572b863adb Run scraper every 15 minutes, bump workflow to Python 3.14, and use uv sync --all-groups -U 2025-11-04 21:56:48 +01:00
92078ed39a Remove uv.lock
Delete generated dependency lockfile (uv.lock) that should not be tracked.
2025-11-04 21:55:39 +01:00
282a5fdf75 Replace .gitignore with comprehensive Python template and ignore articles/ArticleMenu.json 2025-11-04 21:55:22 +01:00
6 changed files with 405 additions and 452 deletions

View File

@@ -2,35 +2,28 @@ name: Run Scraper
on: on:
schedule: schedule:
- cron: '0 * * * *' # Every hour - cron: "0 */15 * * *" # Every 15 minutes
workflow_dispatch: workflow_dispatch:
push: push:
paths: paths:
- 'scrape.py' - "scrape.py"
- '.github/workflows/main.yml' - ".github/workflows/main.yml"
jobs: jobs:
scrape: scrape:
runs-on: ubuntu-latest runs-on: ubuntu-slim
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.13'
- name: Install uv - name: Install uv
id: setup-uv id: setup-uv
uses: astral-sh/setup-uv@v4 uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Install dependencies - name: Install dependencies
run: uv sync run: uv sync --all-groups -U
- name: Run script - name: Run script
run: uv run python scrape.py run: uv run python scrape.py

220
.gitignore vendored
View File

@@ -1,6 +1,218 @@
articles/ArticleMenu.json # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.pyc *.py[codz]
*.pyo *$py.class
*.pyd
# C extensions
*.so
# Distribution / packaging
.Python .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
articles/ArticleMenu.json

6
articles_all.xml generated
View File

@@ -19259,7 +19259,7 @@
</li> </li>
</ol> </ol>
<h1>Other</h1> <h1>Other</h1>
<ol start="01"> <ol>
<li> <li>
<p>Increased the redemption limit in the &#34;Item Exchange - Oscillated Coral&#34; Store page for convene items including Radiant Tide, Forging Tide, and Lustrous Tide from 6 to 7 per version.</p> <p>Increased the redemption limit in the &#34;Item Exchange - Oscillated Coral&#34; Store page for convene items including Radiant Tide, Forging Tide, and Lustrous Tide from 6 to 7 per version.</p>
</li> </li>
@@ -19299,7 +19299,7 @@
</ol> </ol>
<p>Bug Fixes</p> <p>Bug Fixes</p>
<p>Resonators</p> <p>Resonators</p>
<ol start="01"> <ol>
<li> <li>
<p>Fixed the issue where Yinlin&#39;s voice line did not play when opening Supply Chests.</p> <p>Fixed the issue where Yinlin&#39;s voice line did not play when opening Supply Chests.</p>
</li> </li>
@@ -19360,7 +19360,7 @@
<li>Fixed the issue where certain actions would increase the Shell Credit costs when leveling up Echoes and Resonators. To make up for any inconvenience this may have caused, we will issue Shell Credit x300,000 as compensation.</li> <li>Fixed the issue where certain actions would increase the Shell Credit costs when leveling up Echoes and Resonators. To make up for any inconvenience this may have caused, we will issue Shell Credit x300,000 as compensation.</li>
</ol> </ol>
<p>Eligibility: Create a character and unlock the Mail function before 2024-06-28 06:00 (UTC+8). The compensation is valid until 2024-08-15 06:00 (UTC+8).</p> <p>Eligibility: Create a character and unlock the Mail function before 2024-06-28 06:00 (UTC+8). The compensation is valid until 2024-08-15 06:00 (UTC+8).</p>
<ol start="02"> <ol start="2">
<li> <li>
<p>Fixed the issue where the Custom Markers on the map would shift abnormally in certain situations.</p> <p>Fixed the issue where the Custom Markers on the map would shift abnormally in certain situations.</p>
</li> </li>

View File

@@ -3,13 +3,67 @@ name = "wutheringwaves"
version = "0.1.0" version = "0.1.0"
description = "Wuthering Waves archive" description = "Wuthering Waves archive"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.14"
dependencies = [ dependencies = [
"aiofiles", "aiofiles",
"beautifulsoup4", "beautifulsoup4",
"httpx", "httpx",
"markdown>=3.8", "markdown",
"markdownify", "markdownify",
"markupsafe", "markupsafe",
"mdformat", "mdformat",
] ]
[tool.ruff]
preview = true
line-length = 160
lint.select = ["ALL"]
lint.pydocstyle.convention = "google"
lint.ignore = [
"ANN201", # Checks that public functions and methods have return type annotations.
"ARG001", # Checks for the presence of unused arguments in function definitions.
"B008", # Allow Form() as a default value
"CPY001", # Missing copyright notice at top of file
"D100", # Checks for undocumented public module definitions.
"D101", # Checks for undocumented public class definitions.
"D102", # Checks for undocumented public method definitions.
"D104", # Missing docstring in public package.
"D105", # Missing docstring in magic method.
"D105", # pydocstyle - missing docstring in magic method
"D106", # Checks for undocumented public class definitions, for nested classes.
"ERA001", # Found commented-out code
"FBT003", # Checks for boolean positional arguments in function calls.
"FIX002", # Line contains TODO
"G002", # Allow % in logging
"PGH003", # Check for type: ignore annotations that suppress all type warnings, as opposed to targeting specific type warnings.
"PLR6301", # Checks for the presence of unused self parameter in methods definitions.
"RUF029", # Checks for functions declared async that do not await or otherwise use features requiring the function to be declared async.
"TD003", # Checks that a TODO comment is associated with a link to a relevant issue or ticket.
"PLR0913", # Checks for function definitions that include too many arguments.
"PLR0917", # Checks for function definitions that include too many positional arguments.
"N815", # Checks for class variable names that follow the mixedCase convention.
# Conflicting lint rules when using Ruff's formatter
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"COM812", # Checks for the absence of trailing commas.
"COM819", # Checks for the presence of prohibited trailing commas.
"D206", # Checks for docstrings that are indented with tabs.
"D300", # Checks for docstrings that use '''triple single quotes''' instead of """triple double quotes""".
"E111", # Checks for indentation with a non-multiple of 4 spaces.
"E114", # Checks for indentation of comments with a non-multiple of 4 spaces.
"E117", # Checks for over-indented code.
"ISC001", # Checks for implicitly concatenated strings on a single line.
"ISC002", # Checks for implicitly concatenated strings that span multiple lines.
"Q000", # Checks for inline strings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.inline-quotes option.
"Q001", # Checks for multiline strings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.multiline-quotes setting.
"Q002", # Checks for docstrings that use single quotes or double quotes, depending on the value of the lint.flake8-quotes.docstring-quotes setting.
"Q003", # Checks for strings that include escaped quotes, and suggests changing the quote style to avoid the need to escape them.
"W191", # Checks for indentation that uses tabs.
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101", "D103", "PLR2004"]
[tool.ruff.lint.isort]
force-single-line = true

319
scrape.py
View File

@@ -1,4 +1,4 @@
import asyncio # noqa: CPY001, D100 import asyncio
import json import json
import logging import logging
import os import os
@@ -6,57 +6,52 @@ import re
import shutil import shutil
import subprocess # noqa: S404 import subprocess # noqa: S404
import time import time
from datetime import UTC, datetime from datetime import UTC
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal from typing import TYPE_CHECKING
from typing import Any
from typing import Literal
import aiofiles import aiofiles
import anyio
import httpx import httpx
import markdown import markdown
import mdformat import mdformat
from markdownify import MarkdownConverter # pyright: ignore[reportMissingTypeStubs] from bs4 import BeautifulSoup
from markupsafe import Markup, escape from markdownify import MarkdownConverter
from markupsafe import Markup
from markupsafe import escape
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Coroutine from collections.abc import Coroutine
logging.basicConfig( logging.basicConfig(level=logging.INFO, format="%(message)s")
level=logging.INFO,
format="%(message)s",
)
logger: logging.Logger = logging.getLogger("wutheringwaves") logger: logging.Logger = logging.getLogger("wutheringwaves")
# Compile regex patterns for better performance DISCORD_LINK_PATTERN: re.Pattern[str] = re.compile(r'\[([^\]]+)\]\((https?://[^\s)]+) "\2"\)')
DISCORD_LINK_PATTERN = re.compile(r'\[([^\]]+)\]\((https?://[^\s)]+) "\2"\)') URL_PREFIX_PATTERN: re.Pattern[str] = re.compile(r"^https?://(www\.)?")
SQUARE_BRACKETS_PATTERN = re.compile(r"^\s*\[([^\]]+)\]\s*$", re.MULTILINE) NON_BREAKING_SPACE_PATTERN: re.Pattern[str] = re.compile(r"\xa0")
BALL_PATTERN = re.compile(r"\s*(.*?)\n", re.MULTILINE) EMPTY_CODE_BLOCK_PATTERN: re.Pattern[str] = re.compile(r"```[ \t]*\n[ \t]*\n```")
REFERENCE_MARK_PATTERN = re.compile(r"^\s*\s*(\S.*?)\s*$", re.MULTILINE) SQUARE_BRACKETS_PATTERN: re.Pattern[str] = re.compile(r"^\s*\[([^\]]+)\]\s*$", re.MULTILINE)
ESCAPED_STAR_PATTERN = re.compile(r"\\\*(.*)", re.MULTILINE) BALL_PATTERN: re.Pattern[str] = re.compile(r"\s*(.*?)\n", re.MULTILINE)
NON_BREAKING_SPACE_PATTERN = re.compile(r"[\xa0\u2002\u2003\u2009]") # Various nbsp characters REFERENCE_MARK_PATTERN: re.Pattern[str] = re.compile(r"^\s*※\s*(\S.*?)\s*$", re.MULTILINE)
EMPTY_CODE_BLOCK_PATTERN = re.compile(r"```[ \t]*\n[ \t]*\n```") ESCAPED_STAR_PATTERN: re.Pattern[str] = re.compile(r"\\\*(.*)", re.MULTILINE)
# Circled number patterns - precompile for better performance CIRCLED_NUMBER_PATTERNS: dict[str, tuple[re.Pattern[str], str]] = {
CIRCLED_NUMBERS = { "": (re.compile(r"^\s*①\s*(.*?)\s*$", re.MULTILINE), "1"),
"": ("1", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "2"),
"": ("2", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "3"),
"": ("3", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "4"),
"": ("4", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "5"),
"": ("5", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "6"),
"": ("6", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "7"),
"": ("7", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "8"),
"": ("8", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "9"),
"": ("9", re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE)), "": (re.compile(r"^\s*\s*(.*?)\s*$", re.MULTILINE), "10"),
"": ("10", re.compile(r"^\s*⑩\s*(.*?)\s*$", re.MULTILINE)),
} }
# Markdown converter instance - reuse instead of creating for each article
MARKDOWN_CONVERTER = MarkdownConverter(
heading_style="ATX",
strip=["pre", "code"],
)
async def fetch_json(url: str, client: httpx.AsyncClient) -> dict[Any, Any] | None: async def fetch_json(url: str, client: httpx.AsyncClient) -> dict[Any, Any] | None:
"""Fetch JSON data from a URL. """Fetch JSON data from a URL.
@@ -112,9 +107,7 @@ def set_file_timestamp(filepath: Path, timestamp_str: str) -> bool:
""" """
try: try:
# Parse the timestamp string # Parse the timestamp string
dt: datetime = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace( dt: datetime = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
tzinfo=UTC
)
# Convert to Unix timestamp # Convert to Unix timestamp
timestamp: float = dt.timestamp() timestamp: float = dt.timestamp()
@@ -145,9 +138,7 @@ def get_file_timestamp(timestamp_str: str) -> float:
try: try:
# Parse the timestamp string # Parse the timestamp string
dt: datetime = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace( dt: datetime = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
tzinfo=UTC
)
# Convert to Unix timestamp # Convert to Unix timestamp
return dt.timestamp() return dt.timestamp()
except ValueError: except ValueError:
@@ -166,13 +157,7 @@ def commit_file_with_timestamp(filepath: Path) -> bool: # noqa: PLR0911
""" """
# Check in Git history if we already have this file # Check in Git history if we already have this file
git_log_cmd: list[str] = [ git_log_cmd: list[str] = ["git", "log", "--pretty=format:%H", "--follow", str(filepath)]
"git",
"log",
"--pretty=format:%H",
"--follow",
str(filepath),
]
try: try:
git_log_output: str = subprocess.check_output(git_log_cmd, text=True).strip() # noqa: S603 git_log_output: str = subprocess.check_output(git_log_cmd, text=True).strip() # noqa: S603
if git_log_output: if git_log_output:
@@ -196,25 +181,14 @@ def commit_file_with_timestamp(filepath: Path) -> bool: # noqa: PLR0911
# Get the file's modification time # Get the file's modification time
timestamp: float = filepath.stat().st_mtime timestamp: float = filepath.stat().st_mtime
git_time: str = datetime.fromtimestamp(timestamp, tz=UTC).strftime( git_time: str = datetime.fromtimestamp(timestamp, tz=UTC).strftime("%Y-%m-%dT%H:%M:%S")
"%Y-%m-%dT%H:%M:%S"
)
# Stage the file # Stage the file
subprocess.run([git_executable, "add", str(filepath)], check=True, text=True) # noqa: S603 subprocess.run([git_executable, "add", str(filepath)], check=True, text=True) # noqa: S603
# Commit the file with the modification time as the commit time # Commit the file with the modification time as the commit time
env: dict[str, str] = { env: dict[str, str] = {**os.environ, "GIT_AUTHOR_DATE": git_time, "GIT_COMMITTER_DATE": git_time}
**os.environ, subprocess.run([git_executable, "commit", "-m", f"Add {filepath.name}"], check=True, env=env, text=True) # noqa: S603
"GIT_AUTHOR_DATE": git_time,
"GIT_COMMITTER_DATE": git_time,
}
subprocess.run( # noqa: S603
[git_executable, "commit", "-m", f"Add {filepath.name}"],
check=True,
env=env,
text=True,
)
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
logger.exception("Subprocess error occurred while committing the file.") logger.exception("Subprocess error occurred while committing the file.")
return False return False
@@ -251,9 +225,7 @@ def add_articles_to_readme(articles: dict[Any, Any] | None = None) -> None:
# Create new content # Create new content
new_lines: list[str] = [] new_lines: list[str] = []
if articles_section_index >= 0: if articles_section_index >= 0:
new_lines = lines[ new_lines = lines[: articles_section_index + 1] # Keep everything up to "## Articles"
: articles_section_index + 1
] # Keep everything up to "## Articles"
else: else:
new_lines = lines new_lines = lines
if new_lines and not new_lines[-1].endswith("\n"): if new_lines and not new_lines[-1].endswith("\n"):
@@ -262,17 +234,11 @@ def add_articles_to_readme(articles: dict[Any, Any] | None = None) -> None:
# Add articles # Add articles
new_lines.append("\n") # Add a blank line after the heading new_lines.append("\n") # Add a blank line after the heading
for article in sorted( for article in sorted(articles, key=lambda x: x.get("createTime", ""), reverse=True):
articles, key=lambda x: x.get("createTime", ""), reverse=True
):
article_id: str = str(article.get("articleId", "")) article_id: str = str(article.get("articleId", ""))
article_title: str = article.get("articleTitle", "No Title") article_title: str = article.get("articleTitle", "No Title")
article_url: str = ( article_url: str = f"https://wutheringwaves.kurogames.com/en/main/news/detail/{article_id}"
f"https://wutheringwaves.kurogames.com/en/main/news/detail/{article_id}" new_lines.append(f"- [{article_title}]({article_url}) [[json]](articles/{article_id}.json)\n")
)
new_lines.append(
f"- [{article_title}]({article_url}) [[json]](articles/{article_id}.json)\n",
)
# Add articles directory section # Add articles directory section
new_lines.append("\n## Articles Directory\n\n") new_lines.append("\n## Articles Directory\n\n")
@@ -349,12 +315,14 @@ def format_discord_links(md: str) -> str:
def repl(match: re.Match[str]) -> str: def repl(match: re.Match[str]) -> str:
url: str | Any = match.group(2) url: str | Any = match.group(2)
display: str = re.sub(pattern=r"^https?://(www\.)?", repl="", string=url) display: str = URL_PREFIX_PATTERN.sub("", url)
return f"[{display}]({url})" return f"[{display}]({url})"
# Before: [Link](https://example.com "Link") # Before: [Link](https://example.com "Link")
# After: [Link](https://example.com) # After: [Link](https://example.com)
return DISCORD_LINK_PATTERN.sub(repl, md) formatted_links_md: str = DISCORD_LINK_PATTERN.sub(repl, md)
return formatted_links_md
def handle_stars(text: str) -> str: def handle_stars(text: str) -> str:
@@ -395,7 +363,7 @@ def handle_stars(text: str) -> str:
return "\n\n".join(output) return "\n\n".join(output)
def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str: # noqa: PLR0914, PLR0915 def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str: # noqa: C901, PLR0914, PLR0915
"""Generate an Atom feed from a list of articles. """Generate an Atom feed from a list of articles.
Args: Args:
@@ -413,11 +381,7 @@ def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str:
if articles: if articles:
latest_entry = articles[0].get("createTime", "") latest_entry = articles[0].get("createTime", "")
if latest_entry: if latest_entry:
latest_entry = ( latest_entry = datetime.strptime(str(latest_entry), "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).isoformat()
datetime.strptime(str(latest_entry), "%Y-%m-%d %H:%M:%S")
.replace(tzinfo=UTC)
.isoformat()
)
for article in articles: for article in articles:
article_id: str = str(article.get("articleId", "")) article_id: str = str(article.get("articleId", ""))
@@ -434,65 +398,72 @@ def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str:
if not article_content: if not article_content:
article_content = article_title article_content = article_title
article_content_converted = str(MARKDOWN_CONVERTER.convert(article_content).strip()) # type: ignore # noqa: PGH003 converter: MarkdownConverter = MarkdownConverter(heading_style="ATX", strip=["pre", "code"])
article_content_converted = str(converter.convert(article_content).strip())
if not article_content_converted: if not article_content_converted:
msg: str = f"Article content is empty for article ID: {article_id}" msg: str = f"Article content is empty for article ID: {article_id}"
logger.warning(msg) logger.warning(msg)
article_content_converted = "No content available" article_content_converted = "No content available"
# Combine non-breaking space replacements in one pass # Remove non-breaking spaces
content = NON_BREAKING_SPACE_PATTERN.sub(" ", article_content_converted) xa0_removed: str = NON_BREAKING_SPACE_PATTERN.sub(" ", article_content_converted) # Replace non-breaking spaces with regular spaces
# Remove empty code blocks
content = EMPTY_CODE_BLOCK_PATTERN.sub("", content)
# [How to Update] should be # How to Update
content = SQUARE_BRACKETS_PATTERN.sub(r"# \1", content)
content = handle_stars(content)
# If `● Word` is in the content, replace it `## Word` instead
content = BALL_PATTERN.sub(r"\n\n## \1\n\n", content)
# If `※ Word` is in the content, replace it `* word * ` instead
content = REFERENCE_MARK_PATTERN.sub(r"\n\n*\1*\n\n", content)
# Replace circled Unicode numbers with plain numbered text (using precompiled patterns)
for number, pattern in CIRCLED_NUMBERS.values():
content = pattern.sub(rf"\n\n{number}. \1\n\n", content)
content = ESCAPED_STAR_PATTERN.sub(r"* \1", content)
markdown_formatted: str = mdformat.text( # type: ignore # noqa: PGH003 # Replace non-breaking spaces with regular spaces
content, non_breaking_space_removed: str = xa0_removed.replace(" ", " ") # noqa: RUF001
options={
"number": True, # Allow 1., 2., 3. numbering # Remove code blocks that has only spaces and newlines inside them
}, empty_code_block_removed: str = EMPTY_CODE_BLOCK_PATTERN.sub("", non_breaking_space_removed)
# [How to Update] should be # How to Update
square_brackets_converted: str = SQUARE_BRACKETS_PATTERN.sub(r"# \1", empty_code_block_removed)
stars_converted: str = handle_stars(square_brackets_converted)
# If `● Word` is in the content, replace it `## Word` instead with regex
ball_converted: str = BALL_PATTERN.sub(r"\n\n## \1\n\n", stars_converted)
# If `※ Word` is in the content, replace it `* word * ` instead with regex
reference_mark_converted: str = REFERENCE_MARK_PATTERN.sub(r"\n\n*\1*\n\n", ball_converted)
# Replace circled Unicode numbers (①-⑩) with plain numbered text (e.g., "1. ", "2. ", ..., "10. ")
for pattern, number in CIRCLED_NUMBER_PATTERNS.values():
reference_mark_converted = pattern.sub(
rf"\n\n{number}. \1\n\n",
reference_mark_converted,
)
space_before_star_added: str = ESCAPED_STAR_PATTERN.sub(
r"* \1",
reference_mark_converted,
) )
# Format Markdown safely. mdformat doesn't support a "number" option here,
# and unknown options can raise at runtime. We avoid passing invalid options
# and fall back to the raw text if formatting fails for any reason.
try:
formatter: Any = mdformat # Help the type checker by treating mdformat as Any here
markdown_formatted: str = str(formatter.text(space_before_star_added))
except Exception:
logger.exception("mdformat failed; using unformatted markdown text")
markdown_formatted = space_before_star_added
links_fixed: str = format_discord_links(markdown_formatted) links_fixed: str = format_discord_links(markdown_formatted)
article_escaped: Markup = escape(links_fixed) article_escaped: Markup = escape(links_fixed)
article_url: str = ( article_url: str = f"https://wutheringwaves.kurogames.com/en/main/news/detail/{article_id}"
f"https://wutheringwaves.kurogames.com/en/main/news/detail/{article_id}"
)
article_create_time: str = article.get("createTime", "") article_create_time: str = article.get("createTime", "")
published: str = "" published: str = ""
updated: str = latest_entry updated: str = latest_entry
if article_create_time: if article_create_time:
timestamp: datetime = datetime.strptime( timestamp: datetime = datetime.strptime(str(article_create_time), "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC)
str(article_create_time), "%Y-%m-%d %H:%M:%S"
).replace(tzinfo=UTC)
iso_time: str = timestamp.isoformat() iso_time: str = timestamp.isoformat()
published = f"<published>{iso_time}</published>" published = f"<published>{iso_time}</published>"
updated = iso_time updated = iso_time
article_category: str = article.get("articleTypeName", "Wuthering Waves") article_category: str = article.get("articleTypeName", "Wuthering Waves")
category: str = ( category: str = f'<category term="{escape(article_category)}"/>' if article_category else ""
f'<category term="{escape(article_category)}"/>' if article_category else ""
)
html: str = markdown.markdown( html: str = markdown.markdown(
text=article_escaped, text=article_escaped,
@@ -526,7 +497,7 @@ def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str:
html_file: Path = html_dir / f"{article_id}.html" html_file: Path = html_dir / f"{article_id}.html"
if not html_file.is_file(): if not html_file.is_file():
with html_file.open("w", encoding="utf-8") as f: with html_file.open("w", encoding="utf-8") as f:
f.write(html) f.write(str(BeautifulSoup(html, "html.parser").prettify()))
logger.info("Saved HTML for article %s to %s", article_id, html_file) logger.info("Saved HTML for article %s to %s", article_id, html_file)
# Set the file timestamp # Set the file timestamp
@@ -553,35 +524,12 @@ def generate_atom_feed(articles: list[dict[Any, Any]], file_name: str) -> str:
</author> </author>
{"".join(atom_entries)} {"".join(atom_entries)}
</feed> </feed>
""" # noqa: E501 """
return atom_feed return atom_feed
def load_all_articles(output_dir: Path) -> list[dict[Any, Any]]: def create_atom_feeds(output_dir: Path) -> None:
"""Load all article JSON files from the output directory.
Args:
output_dir (Path): The directory containing article JSON files.
Returns:
list[dict[Any, Any]]: List of article data dictionaries.
"""
articles: list[dict[Any, Any]] = []
for file in output_dir.glob("*.json"):
if file.stem == "ArticleMenu":
continue
with file.open("r", encoding="utf-8") as f:
try:
article_data: dict[Any, Any] = json.load(f)
articles.append(article_data)
except json.JSONDecodeError:
logger.exception("Error decoding JSON from %s", file)
continue
return articles
def create_atom_feeds(articles: list[dict[Any, Any]], output_dir: Path) -> None:
"""Create Atom feeds for the articles. """Create Atom feeds for the articles.
Current feeds are: Current feeds are:
@@ -589,16 +537,28 @@ def create_atom_feeds(articles: list[dict[Any, Any]], output_dir: Path) -> None:
- All articles - All articles
Args: Args:
articles (list[dict[Any, Any]]): List of article data.
output_dir (Path): The directory to save the RSS feed files. output_dir (Path): The directory to save the RSS feed files.
""" """
if not articles: menu_data: list[dict[Any, Any]] = []
logger.error("Can't create Atom feeds, no articles provided") # Load data from all the articles
for file in output_dir.glob("*.json"):
if file.stem == "ArticleMenu":
continue
with file.open("r", encoding="utf-8") as f:
try:
article_data: dict[Any, Any] = json.load(f)
menu_data.append(article_data)
except json.JSONDecodeError:
logger.exception("Error decoding JSON from %s", file)
continue
if not menu_data:
logger.error("Can't create Atom feeds, no articles found in %s", output_dir)
return return
articles_sorted: list[dict[Any, Any]] = sorted( articles_sorted: list[dict[Any, Any]] = sorted(
articles, menu_data,
key=lambda x: get_file_timestamp(x.get("createTime", "")), key=lambda x: get_file_timestamp(x.get("createTime", "")),
reverse=True, reverse=True,
) )
@@ -614,9 +574,7 @@ def create_atom_feeds(articles: list[dict[Any, Any]], output_dir: Path) -> None:
article_create_time: str = article.get("createTime", "") article_create_time: str = article.get("createTime", "")
logger.info("\tArticle ID: %s, Date: %s", article_id, article_create_time) logger.info("\tArticle ID: %s, Date: %s", article_id, article_create_time)
atom_feed: str = generate_atom_feed( atom_feed: str = generate_atom_feed(articles=latest_articles, file_name=atom_feed_path.name)
articles=latest_articles, file_name=atom_feed_path.name
)
with atom_feed_path.open("w", encoding="utf-8") as f: with atom_feed_path.open("w", encoding="utf-8") as f:
f.write(atom_feed) f.write(atom_feed)
logger.info( logger.info(
@@ -627,9 +585,7 @@ def create_atom_feeds(articles: list[dict[Any, Any]], output_dir: Path) -> None:
# Create the Atom feed for all articles # Create the Atom feed for all articles
atom_feed_path_all: Path = Path("articles_all.xml") atom_feed_path_all: Path = Path("articles_all.xml")
atom_feed_all_articles: str = generate_atom_feed( atom_feed_all_articles: str = generate_atom_feed(articles=articles_sorted, file_name=atom_feed_path_all.name)
articles=articles_sorted, file_name=atom_feed_path_all.name
)
with atom_feed_path_all.open("w", encoding="utf-8") as f: with atom_feed_path_all.open("w", encoding="utf-8") as f:
f.write(atom_feed_all_articles) f.write(atom_feed_all_articles)
logger.info("Created Atom feed for all articles: %s", atom_feed_path_all) logger.info("Created Atom feed for all articles: %s", atom_feed_path_all)
@@ -692,13 +648,11 @@ async def main() -> Literal[1, 0]:
""" """
# Setup # Setup
current_time = int(time.time() * 1000) # Current time in milliseconds current_time = int(time.time() * 1000) # Current time in milliseconds
base_url = ( base_url = "https://hw-media-cdn-mingchao.kurogame.com/akiwebsite/website2.0/json/G152/en"
"https://hw-media-cdn-mingchao.kurogame.com/akiwebsite/website2.0/json/G152/en"
)
article_menu_url: str = f"{base_url}/ArticleMenu.json?t={current_time}" article_menu_url: str = f"{base_url}/ArticleMenu.json?t={current_time}"
article_base_url: str = f"{base_url}/article/" article_base_url: str = f"{base_url}/article/"
output_dir = Path("articles") output_dir = Path("articles")
output_dir.mkdir(exist_ok=True) await anyio.Path(output_dir).mkdir(exist_ok=True)
logger.info("Fetching article menu from %s", article_menu_url) logger.info("Fetching article menu from %s", article_menu_url)
@@ -716,29 +670,19 @@ async def main() -> Literal[1, 0]:
# Extract article IDs # Extract article IDs
logger.info("Extracting article IDs...") logger.info("Extracting article IDs...")
article_ids: list[str] = [ article_ids: list[str] = [str(item["articleId"]) for item in menu_data if item.get("articleId")]
str(item["articleId"]) for item in menu_data if item.get("articleId")
]
if not article_ids: if not article_ids:
logger.warning( logger.warning("No article IDs found. Please check the JSON structure of ArticleMenu.json.")
"No article IDs found. Please check the JSON structure of ArticleMenu.json."
)
logger.warning("Full menu response for debugging:") logger.warning("Full menu response for debugging:")
logger.warning(json.dumps(menu_data, indent=2)) logger.warning(json.dumps(menu_data, indent=2))
return 1 return 1
# Get list of already downloaded article IDs # Get list of already downloaded article IDs
existing_files: list[str] = [ existing_files: list[str] = [file.stem async for file in anyio.Path(output_dir).glob("*.json") if file.stem != "ArticleMenu"]
file.stem
for file in output_dir.glob("*.json")
if file.stem != "ArticleMenu"
]
# Filter out already downloaded articles # Filter out already downloaded articles
new_article_ids: list[str] = [ # Filter out already downloaded articles
article_id for article_id in article_ids if article_id not in existing_files new_article_ids: list[str] = [article_id for article_id in article_ids if article_id not in existing_files]
]
if new_article_ids: if new_article_ids:
logger.info("Found %s new articles to download", len(new_article_ids)) logger.info("Found %s new articles to download", len(new_article_ids))
@@ -746,18 +690,14 @@ async def main() -> Literal[1, 0]:
# Download each new article # Download each new article
download_tasks: list[Coroutine[Any, Any, dict[Any, Any] | None]] = [] download_tasks: list[Coroutine[Any, Any, dict[Any, Any] | None]] = []
for article_id in new_article_ids: for article_id in new_article_ids:
article_url: str = ( article_url: str = f"{article_base_url}{article_id}.json?t={current_time}"
f"{article_base_url}{article_id}.json?t={current_time}"
)
output_file: Path = output_dir / f"{article_id}.json" output_file: Path = output_dir / f"{article_id}.json"
logger.info("Downloading article %s from %s", article_id, article_url) logger.info("Downloading article %s from %s", article_id, article_url)
download_tasks.append(fetch_json(article_url, client)) download_tasks.append(fetch_json(article_url, client))
# Wait for all downloads to complete # Wait for all downloads to complete
results: list[dict[Any, Any] | BaseException | None] = await asyncio.gather( results: list[dict[Any, Any] | BaseException | None] = await asyncio.gather(*download_tasks, return_exceptions=True)
*download_tasks, return_exceptions=True
)
# Process the downloaded articles # Process the downloaded articles
for i, result in enumerate(results): for i, result in enumerate(results):
@@ -769,27 +709,18 @@ async def main() -> Literal[1, 0]:
continue continue
if not result: if not result:
logger.warning( logger.warning("Downloaded article %s is empty or invalid", article_id)
"Downloaded article %s is empty or invalid", article_id
)
continue continue
# Save the article JSON # Save the article JSON
if isinstance(result, dict) and await save_prettified_json( if isinstance(result, dict) and await save_prettified_json(result, output_file):
result, output_file logger.info("Successfully downloaded and prettified %s", output_file)
):
logger.info(
"Successfully downloaded and prettified %s", output_file
)
else: else:
logger.info("No new articles to download") logger.info("No new articles to download")
# Load all articles once for efficient processing
all_articles = load_all_articles(output_dir)
add_data_to_articles(menu_data, output_dir) add_data_to_articles(menu_data, output_dir)
add_articles_to_readme(menu_data) add_articles_to_readme(menu_data)
create_atom_feeds(all_articles, output_dir) create_atom_feeds(output_dir)
batch_process_timestamps(menu_data, output_dir) batch_process_timestamps(menu_data, output_dir)
logger.info("Script finished. Articles are in the '%s' directory.", output_dir) logger.info("Script finished. Articles are in the '%s' directory.", output_dir)

237
uv.lock generated
View File

@@ -1,237 +0,0 @@
version = 1
revision = 2
requires-python = ">=3.13"
[[package]]
name = "aiofiles"
version = "24.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" },
]
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" },
]
[[package]]
name = "beautifulsoup4"
version = "4.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "soupsieve" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067, upload-time = "2025-04-15T17:05:13.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285, upload-time = "2025-04-15T17:05:12.221Z" },
]
[[package]]
name = "certifi"
version = "2025.7.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
]
[[package]]
name = "markdown"
version = "3.8.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/c2/4ab49206c17f75cb08d6311171f2d65798988db4360c4d1485bd0eedd67c/markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45", size = 362071, upload-time = "2025-06-19T17:12:44.483Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/2b/34cc11786bc00d0f04d0f5fdc3a2b1ae0b6239eef72d3d345805f9ad92a1/markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24", size = 106827, upload-time = "2025-06-19T17:12:42.994Z" },
]
[[package]]
name = "markdown-it-py"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
]
[[package]]
name = "markdownify"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beautifulsoup4" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2f/78/c48fed23c7aebc2c16049062e72de1da3220c274de59d28c942acdc9ffb2/markdownify-1.1.0.tar.gz", hash = "sha256:449c0bbbf1401c5112379619524f33b63490a8fa479456d41de9dc9e37560ebd", size = 17127, upload-time = "2025-03-05T11:54:40.574Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/11/b751af7ad41b254a802cf52f7bc1fca7cabe2388132f2ce60a1a6b9b9622/markdownify-1.1.0-py3-none-any.whl", hash = "sha256:32a5a08e9af02c8a6528942224c91b933b4bd2c7d078f9012943776fc313eeef", size = 13901, upload-time = "2025-03-05T11:54:39.454Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" },
{ url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" },
{ url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" },
{ url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" },
{ url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" },
{ url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" },
{ url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" },
{ url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" },
{ url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" },
{ url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" },
{ url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" },
{ url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" },
{ url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" },
{ url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" },
{ url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" },
]
[[package]]
name = "mdformat"
version = "0.7.22"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/eb/b5cbf2484411af039a3d4aeb53a5160fae25dd8c84af6a4243bc2f3fedb3/mdformat-0.7.22.tar.gz", hash = "sha256:eef84fa8f233d3162734683c2a8a6222227a229b9206872e6139658d99acb1ea", size = 34610, upload-time = "2025-01-30T18:00:51.418Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/6f/94a7344f6d634fe3563bea8b33bccedee37f2726f7807e9a58440dc91627/mdformat-0.7.22-py3-none-any.whl", hash = "sha256:61122637c9e1d9be1329054f3fa216559f0d1f722b7919b060a8c2a4ae1850e5", size = 34447, upload-time = "2025-01-30T18:00:48.708Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "soupsieve"
version = "2.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418, upload-time = "2025-04-20T18:50:08.518Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677, upload-time = "2025-04-20T18:50:07.196Z" },
]
[[package]]
name = "typing-extensions"
version = "4.14.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" },
]
[[package]]
name = "wutheringwaves"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "aiofiles" },
{ name = "beautifulsoup4" },
{ name = "httpx" },
{ name = "markdown" },
{ name = "markdownify" },
{ name = "markupsafe" },
{ name = "mdformat" },
]
[package.metadata]
requires-dist = [
{ name = "aiofiles" },
{ name = "beautifulsoup4" },
{ name = "httpx" },
{ name = "markdown", specifier = ">=3.8" },
{ name = "markdownify" },
{ name = "markupsafe" },
{ name = "mdformat" },
]