diff --git a/.github/workflows/post_discussions.yml b/.github/workflows/post_discussions.yml new file mode 100644 index 0000000..3ae6bc9 --- /dev/null +++ b/.github/workflows/post_discussions.yml @@ -0,0 +1,47 @@ +name: Post contest discussions + +on: + schedule: + - cron: '50 13 * * 6' + workflow_dispatch: + inputs: + contest_id: + description: 'Contest ID to backfill (e.g. abc467). Leave empty for automatic detection.' + required: false + +permissions: + contents: read + discussions: write + +concurrency: + group: post-discussions + cancel-in-progress: false + +jobs: + post-discussions: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Install dependencies + run: uv sync + + - name: Post discussions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTEST_ID: ${{ github.event.inputs.contest_id }} + run: | + if [ -n "$CONTEST_ID" ]; then + uv run python -m contest_discussions.main --contest-id "$CONTEST_ID" + else + uv run python -m contest_discussions.main + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dbbd4f4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.worktrees/ +.venv/ +__pycache__/ +*.pyc diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-1.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-1.md new file mode 100644 index 0000000..718f08e --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-1.md @@ -0,0 +1,172 @@ +# Phase 1: プロジェクト初期化 + GITHUB_TOKEN 権限検証 + +**目的:** uv プロジェクトの雛形を作り、「Actions 標準の `GITHUB_TOKEN` + `permissions: discussions: write` で Discussion 作成が可能」という設計上の前提を、実際にワークフローを1回動かして検証する。ここで検証できなければ、後続フェーズの認証方式を PAT に差し替える必要があるため、最初に確認する。 + +**注意:** このフェーズの最終ステップ (ワークフローの手動実行) は、公開リポジトリに実際の Discussion を作成する。**ユーザーの明示的な許可を得てから実行すること。** 無断でリモートに push したりワークフローを実行したりしない。 + +--- + +### Task 1: uv プロジェクトの初期化 + +**Files:** +- Create: `pyproject.toml` + +- [ ] **Step 1: pyproject.toml を作成** + +```toml +[project] +name = "contest-discussions" +version = "0.1.0" +description = "Automatically create GitHub Discussions for each ABC problem after the contest ends." +readme = "README.md" +requires-python = ">=3.13,<4.0" +dependencies = [ + "requests>=2.32", + "selectolax>=0.4.6", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "responses>=0.25", +] + +[tool.pytest.ini_options] +pythonpath = ["src"] +``` + +- [ ] **Step 2: 依存関係をインストール** + +Run: `uv sync` +Expected: `uv.lock` が生成され、`.venv/` が作成される + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore: initialize uv project for contest discussion automation" +``` + +--- + +### Task 2: パッケージ雛形の作成 + +**Files:** +- Create: `src/contest_discussions/__init__.py` + +- [ ] **Step 1: 空の `__init__.py` を作成** + +```python +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/contest_discussions/__init__.py +git commit -m "chore: add contest_discussions package skeleton" +``` + +--- + +### Task 3: 定数モジュールの作成 + +**Files:** +- Create: `src/contest_discussions/constants.py` + +- [ ] **Step 1: 定数を定義** + +```python +"""Fixed IDs and text for the NoviSteps-Writeups repository. + +See: https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups +""" + +# `gh api graphql -f query='query { repository(owner: "AtCoder-NoviSteps", name: "NoviSteps-Writeups") { id } }'` +REPOSITORY_ID = "R_kgDOTNgHyw" + +# "General" discussion category. +# `gh api graphql -f query='query { repository(owner: "AtCoder-NoviSteps", name: "NoviSteps-Writeups") { discussionCategories(first: 10) { nodes { id name } } } }'` +GENERAL_CATEGORY_ID = "DIC_kwDOTNgHy84DAe9x" + +DISCUSSION_BODY = "問題の感想や気づきを投稿・共有するスペースです" + +# 週次 cron が1回失敗しても次回実行で埋め合わせられるよう、直近何件のABCを +# 重複チェック対象にするか。3件あれば3週分の取りこぼしまでカバーできる。 +CONTESTS_TO_CHECK = 3 +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/contest_discussions/constants.py +git commit -m "feat: add repository/category constants" +``` + +--- + +### Task 4: GITHUB_TOKEN の Discussion 書き込み権限を検証するワークフロー + +**Files:** +- Create: `.github/workflows/smoke_test_discussion.yml` + +- [ ] **Step 1: workflow_dispatch のみで動く検証用ワークフローを作成** + +```yaml +name: Smoke test - GITHUB_TOKEN discussions:write + +on: + workflow_dispatch: + +permissions: + discussions: write + +jobs: + smoke-test: + runs-on: ubuntu-latest + steps: + - name: Create a throwaway test discussion + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api graphql -f query=' + mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repositoryId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { id url } + } + }' \ + -f repositoryId="R_kgDOTNgHyw" \ + -f categoryId="DIC_kwDOTNgHy84DAe9x" \ + -f title="[smoke test] GITHUB_TOKEN discussions:write check (safe to delete)" \ + -f body="Created by CI to verify that the default GITHUB_TOKEN can create Discussions. Safe to delete." +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/smoke_test_discussion.yml +git commit -m "ci: add smoke test workflow for GITHUB_TOKEN discussions permission" +``` + +- [ ] **Step 3 (ユーザー確認必須・手動): push してワークフローを実行** + +ここは実際に公開リポジトリへ push し、ワークフローを実行して Discussion を1件作成する。**必ずユーザーに実行してよいか確認してから進めること。** + +```bash +git push -u origin +gh workflow run smoke_test_discussion.yml +gh run watch +``` + +- [ ] **Step 4: 結果を確認** + +- 成功した場合: NoviSteps-Writeups の Discussions に "[smoke test] ..." が作成されているので、`gh api graphql` の `deleteDiscussion` mutation か GitHub UI から削除する。以降のフェーズは `GITHUB_TOKEN` + `permissions: discussions: write` の方針で進める。 +- 失敗した場合 (権限エラー等): `plan.md` の「却下した代替案」を更新し、Fine-grained PAT をリポジトリシークレット (`GITHUB_DISCUSSIONS_TOKEN` 等) として発行する方針に切り替える。Phase 4/6 の認証部分をその前提で書き直す。 + +- [ ] **Step 5: 検証用ワークフローを削除 (恒久ワークフローに置き換えるため)** + +Phase 6 で正式なワークフローに差し替えるため、このスモークテスト用ファイルは Phase 6 で削除する (このフェーズでは残しておいてよい)。 diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-2.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-2.md new file mode 100644 index 0000000..10d3209 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-2.md @@ -0,0 +1,233 @@ +# Phase 2: `atcoder.py` — 問題一覧の取得ロジック + +**目的:** `https://atcoder.jp/contests/{contest_id}/tasks` をスクレイピングして、そのコンテストの問題一覧 (`problem_index`, `name` など) を取得する。共同開発者が提供した PoC (`fetch_tasks_from_atcoder.py`) をベースに、テスト可能な形にリファクタしつつエラーハンドリングを追加する。 + +**参考:** PoC は `docs/dev-notes/2026-06-18/toggle-neutral-badge/fetch_tasks_from_atcoder.py` で共有されたもの (作業後に削除済み)。ロジックの骨格 (最初の `tbody` から各行の1列目 `` と2列目テキストを抽出) を踏襲する。 + +--- + +### Task 1: テスト用HTML固定データを用意 + +**Files:** +- Create: `tests/fixtures/abc_tasks_sample.html` + +- [ ] **Step 1: AtCoderのタスク表を模したHTMLを作成** + +実際の `atcoder.jp/contests/abc467/tasks` の構造 (1列目: 問題記号へのリンク、2列目: 問題名) を模したサンプル。 + +```html + + + + + + + + + + + + + + + + + + + + + + + + + + +
AObesity1001 sec1024 MB
BKeep the Change1502 sec1024 MB
FEmail Scheduling Optimization5002 sec1024 MB
+ + +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/fixtures/abc_tasks_sample.html +git commit -m "test: add AtCoder tasks page HTML fixture" +``` + +--- + +### Task 2: `fetch_tasks` の失敗するテストを書く + +**Files:** +- Create: `tests/test_atcoder.py` + +- [ ] **Step 1: テストを書く** + +```python +from pathlib import Path + +import responses + +from contest_discussions.atcoder import fetch_tasks + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@responses.activate +def test_fetch_tasks_parses_task_table(): + html = (FIXTURES_DIR / "abc_tasks_sample.html").read_text(encoding="utf-8") + responses.get("https://atcoder.jp/contests/abc467/tasks", body=html) + + tasks = fetch_tasks("abc467") + + assert tasks == [ + { + "id": "abc467_a", + "contest_id": "abc467", + "problem_index": "A", + "name": "Obesity", + }, + { + "id": "abc467_b", + "contest_id": "abc467", + "problem_index": "B", + "name": "Keep the Change", + }, + { + "id": "abc467_f", + "contest_id": "abc467", + "problem_index": "F", + "name": "Email Scheduling Optimization", + }, + ] + + +@responses.activate +def test_fetch_tasks_raises_when_no_task_table_found(): + responses.get( + "https://atcoder.jp/contests/abc999/tasks", + body="Not Found", + ) + + try: + fetch_tasks("abc999") + assert False, "expected ValueError" + except ValueError as error: + assert "abc999" in str(error) + + +@responses.activate +def test_fetch_tasks_skips_malformed_rows(): + html = """ + + + + + + +
broken row without link
AObesity
+ """ + responses.get("https://atcoder.jp/contests/abc467/tasks", body=html) + + tasks = fetch_tasks("abc467") + + assert len(tasks) == 1 + assert tasks[0]["problem_index"] == "A" +``` + +- [ ] **Step 2: 実行して失敗を確認** + +Run: `uv run pytest tests/test_atcoder.py -v` +Expected: `ModuleNotFoundError: No module named 'contest_discussions.atcoder'` で FAIL + +--- + +### Task 3: `fetch_tasks` を実装 + +**Files:** +- Create: `src/contest_discussions/atcoder.py` + +- [ ] **Step 1: 実装** + +```python +"""Fetches contest/task metadata directly from the AtCoder website.""" + +import re + +import requests +from selectolax.parser import HTMLParser + +REQUEST_TIMEOUT_SECONDS = 10 + +_TASK_HREF_PATTERN = re.compile(r"^/contests/([^/]+)/tasks/([^/]+)$") + + +def fetch_tasks(contest_id: str) -> list[dict[str, str]]: + """Fetches the task list for a contest from its AtCoder tasks page. + + Returns a list of dicts with keys: id, contest_id, problem_index, name. + Raises ValueError if the task table cannot be found on the page. + """ + url = f"https://atcoder.jp/contests/{contest_id}/tasks" + response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + + tree = HTMLParser(response.text) + tbody = tree.css_first("tbody") + + if tbody is None: + raise ValueError(f"No task table found on the tasks page for {contest_id}") + + tasks = [] + + for row in tbody.css("tr"): + task = _parse_row(row) + + if task is not None: + tasks.append(task) + + return tasks + + +def _parse_row(row) -> dict[str, str] | None: + cells = row.css("td") + + if len(cells) < 2: + return None + + link = cells[0].css_first("a") + + if link is None or "href" not in link.attributes: + return None + + match = _TASK_HREF_PATTERN.match(link.attributes["href"]) + + if match is None: + return None + + contest_id, task_id = match.groups() + problem_index = cells[0].text(strip=True) + name = cells[1].text(strip=True) + + if not problem_index or not name: + return None + + return { + "id": task_id, + "contest_id": contest_id, + "problem_index": problem_index, + "name": name, + } +``` + +- [ ] **Step 2: テストを実行して成功を確認** + +Run: `uv run pytest tests/test_atcoder.py -v` +Expected: 3 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/contest_discussions/atcoder.py tests/test_atcoder.py +git commit -m "feat: fetch task list from AtCoder tasks page" +``` diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-3.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-3.md new file mode 100644 index 0000000..faba7d5 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-3.md @@ -0,0 +1,163 @@ +# Phase 3: `atcoder.py` — 終了済み最新コンテストの判定ロジック + +**目的:** AtCoder Problems の軽量な `contests.json` から、終了済みの ABC のうち直近 `CONTESTS_TO_CHECK` 件の contest_id を特定する。cron が1回失敗しても次回実行で取りこぼしを埋め合わせられるよう、複数件を対象にする (設計根拠は `plan.md` 参照)。 + +--- + +### Task 1: テスト用JSON固定データを用意 + +**Files:** +- Create: `tests/fixtures/contests_sample.json` + +- [ ] **Step 1: 固定データを作成** + +`now = 2026-07-21T00:00:00Z` (epoch `1784592000`) を基準に、終了済みのABC3件・未来のABC1件・ABC以外1件を含める。 + +**注意:** `start_epoch_second + duration_second` が必ず `now` (`1784592000`) 以下になるように、abc465/466/467 は3週間隔で `now` より前の日付にしてある。abc468 だけ `now` より後 (未来) にして、未終了として除外されることを確認する。 + +```json +[ + { + "id": "abc465", + "start_epoch_second": 1782392400, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 465", + "rate_change": "~ 1999" + }, + { + "id": "abc466", + "start_epoch_second": 1782997200, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 466", + "rate_change": "~ 1999" + }, + { + "id": "abc467", + "start_epoch_second": 1783602000, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 467", + "rate_change": "~ 1999" + }, + { + "id": "abc468", + "start_epoch_second": 1784811600, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 468", + "rate_change": "~ 1999" + }, + { + "id": "arc199", + "start_epoch_second": 1782997200, + "duration_second": 7200, + "title": "AtCoder Regular Contest 199", + "rate_change": "All" + } +] +``` + +- [ ] **Step 2: Commit** + +```bash +git add tests/fixtures/contests_sample.json +git commit -m "test: add AtCoder Problems contests.json fixture" +``` + +--- + +### Task 2: 失敗するテストを書く + +**Files:** +- Modify: `tests/test_atcoder.py` + +- [ ] **Step 1: テストを追加** + +```python +import json +from datetime import datetime, timezone + +from contest_discussions.atcoder import find_recently_finished_abc_ids + +# 以下を test_atcoder.py の末尾に追加 + + +@responses.activate +def test_find_recently_finished_abc_ids_returns_oldest_first(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + now = datetime(2026, 7, 21, tzinfo=timezone.utc) + result = find_recently_finished_abc_ids(now=now, limit=3) + + # abc468 is in the future, arc199 is not an ABC -> excluded. + # Among the finished ABCs (465, 466, 467), the 3 most recent, oldest first. + assert result == ["abc465", "abc466", "abc467"] + + +@responses.activate +def test_find_recently_finished_abc_ids_respects_limit(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + now = datetime(2026, 7, 21, tzinfo=timezone.utc) + result = find_recently_finished_abc_ids(now=now, limit=1) + + assert result == ["abc467"] +``` + +- [ ] **Step 2: 実行して失敗を確認** + +Run: `uv run pytest tests/test_atcoder.py -v` +Expected: `ImportError: cannot import name 'find_recently_finished_abc_ids'` で FAIL + +--- + +### Task 3: `find_recently_finished_abc_ids` を実装 + +**Files:** +- Modify: `src/contest_discussions/atcoder.py` + +- [ ] **Step 1: 実装を追加** + +```python +# atcoder.py の先頭に追加 +from datetime import datetime, timezone + +CONTESTS_JSON_URL = "https://kenkoooo.com/atcoder/resources/contests.json" + +_ABC_ID_PATTERN = re.compile(r"^abc\d{3}$") + + +def find_recently_finished_abc_ids( + now: datetime | None = None, limit: int = 3 +) -> list[str]: + """Returns up to `limit` most recently finished ABC contest_ids, oldest first.""" + if now is None: + now = datetime.now(timezone.utc) + + response = requests.get(CONTESTS_JSON_URL, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + contests = response.json() + + now_epoch = now.timestamp() + finished_abc = [ + contest + for contest in contests + if _ABC_ID_PATTERN.match(contest["id"]) + and contest["start_epoch_second"] + contest["duration_second"] <= now_epoch + ] + finished_abc.sort(key=lambda contest: contest["start_epoch_second"]) + + return [contest["id"] for contest in finished_abc[-limit:]] +``` + +- [ ] **Step 2: テストを実行して成功を確認** + +Run: `uv run pytest tests/test_atcoder.py -v` +Expected: 5 tests PASS (Phase 2 の3件 + 今回の2件) + +- [ ] **Step 3: Commit** + +```bash +git add src/contest_discussions/atcoder.py tests/test_atcoder.py +git commit -m "feat: find recently finished ABC contest ids" +``` diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-4.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-4.md new file mode 100644 index 0000000..ec12409 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-4.md @@ -0,0 +1,198 @@ +# Phase 4: `github_client.py` — 既存Discussion確認・作成 + +**目的:** GitHub GraphQL API を使って (1) "General" カテゴリの既存Discussionタイトル一覧を取得する関数、(2) 新規Discussionを作成する関数を実装する。 + +GraphQL のスキーマは `gh api graphql` で事前に introspection 済み: +- `createDiscussion` mutation の入力: `repositoryId`, `categoryId`, `title`, `body` (すべて必須) +- `Repository.discussions` の引数: `categoryId`, `first`, `orderBy` など + +--- + +### Task 1: 失敗するテストを書く + +**Files:** +- Create: `tests/test_github_client.py` + +- [ ] **Step 1: テストを書く** + +```python +import responses + +from contest_discussions.github_client import ( + GITHUB_GRAPHQL_URL, + create_discussion, + existing_discussion_titles, +) + + +@responses.activate +def test_existing_discussion_titles_returns_titles(): + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "node": { + "discussions": { + "nodes": [ + {"title": "ABC 467 A - Obesity"}, + {"title": "ABC 467 B - Keep the Change"}, + ] + } + } + } + }, + ) + + titles = existing_discussion_titles("fake-token") + + assert titles == {"ABC 467 A - Obesity", "ABC 467 B - Keep the Change"} + + +@responses.activate +def test_existing_discussion_titles_raises_on_graphql_errors(): + responses.post( + GITHUB_GRAPHQL_URL, + json={"errors": [{"message": "Bad credentials"}]}, + ) + + try: + existing_discussion_titles("fake-token") + assert False, "expected RuntimeError" + except RuntimeError as error: + assert "Bad credentials" in str(error) + + +@responses.activate +def test_create_discussion_returns_url(): + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "createDiscussion": { + "discussion": { + "id": "D_test123", + "url": "https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups/discussions/23", + } + } + } + }, + ) + + url = create_discussion("fake-token", "ABC 467 G - Many Sweets Problem", "body text") + + assert url == "https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups/discussions/23" +``` + +- [ ] **Step 2: 実行して失敗を確認** + +Run: `uv run pytest tests/test_github_client.py -v` +Expected: `ModuleNotFoundError: No module named 'contest_discussions.github_client'` で FAIL + +--- + +### Task 2: `github_client.py` を実装 + +**Files:** +- Create: `src/contest_discussions/github_client.py` + +- [ ] **Step 1: 実装** + +```python +"""Minimal GitHub GraphQL client for the NoviSteps-Writeups Discussions API.""" + +import requests + +from contest_discussions.constants import GENERAL_CATEGORY_ID, REPOSITORY_ID + +GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" +REQUEST_TIMEOUT_SECONDS = 10 + +_EXISTING_TITLES_QUERY = """ +query($repositoryId: ID!, $categoryId: ID!) { + node(id: $repositoryId) { + ... on Repository { + discussions(categoryId: $categoryId, first: 100, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { title } + } + } + } +} +""" + +_CREATE_DISCUSSION_MUTATION = """ +mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repositoryId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { id url } + } +} +""" + + +def existing_discussion_titles(token: str) -> set[str]: + """Returns the titles of the most recent Discussions in the General category. + + Only the first 100 (no pagination) — accepted limitation given weekly ABC + volume; revisit if the General category ever accumulates enough non-contest + discussions to push older unposted contest titles out of this window. + """ + data = _post_graphql( + token, + _EXISTING_TITLES_QUERY, + {"repositoryId": REPOSITORY_ID, "categoryId": GENERAL_CATEGORY_ID}, + ) + nodes = data["node"]["discussions"]["nodes"] + + return {node["title"] for node in nodes} + + +def create_discussion(token: str, title: str, body: str) -> str: + """Creates a Discussion in the General category and returns its URL.""" + data = _post_graphql( + token, + _CREATE_DISCUSSION_MUTATION, + { + "repositoryId": REPOSITORY_ID, + "categoryId": GENERAL_CATEGORY_ID, + "title": title, + "body": body, + }, + ) + + return data["createDiscussion"]["discussion"]["url"] + + +def _post_graphql(token: str, query: str, variables: dict) -> dict: + response = requests.post( + GITHUB_GRAPHQL_URL, + json={"query": query, "variables": variables}, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + + if "errors" in payload: + raise RuntimeError(f"GitHub GraphQL API returned errors: {payload['errors']}") + + return payload["data"] +``` + +- [ ] **Step 2: テストを実行して成功を確認** + +Run: `uv run pytest tests/test_github_client.py -v` +Expected: 3 tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/contest_discussions/github_client.py tests/test_github_client.py +git commit -m "feat: add GitHub GraphQL client for discussion query/creation" +``` diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-5.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-5.md new file mode 100644 index 0000000..e910508 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-5.md @@ -0,0 +1,233 @@ +# Phase 5: `main.py` — オーケストレーション + CLI + +**目的:** Phase 2〜4 で作った部品を繋げ、「終了済み最新ABCを探す → 問題一覧を取る → 未投稿のものだけDiscussionを作る」を1本のスクリプトにする。タイトル組み立て、タスク単位のスキップ・エラー握りつぶし (継続) ロジックがこのフェーズの核心。 + +--- + +### Task 1: 失敗するテストを書く + +**Files:** +- Create: `tests/test_main.py` + +- [ ] **Step 1: テストを書く** + +`atcoder` / `github_client` をモジュール単位でモンキーパッチし、`main.run()` のオーケストレーションだけを検証する。 + +```python +from contest_discussions import main + + +def test_build_title_formats_correctly(): + task = {"problem_index": "F", "name": "Email Scheduling Optimization"} + assert main.build_title("abc467", task) == "ABC 467 F - Email Scheduling Optimization" + + +def test_run_skips_tasks_that_already_have_a_discussion(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc467"] + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: [ + {"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}, + {"id": "abc467_b", "contest_id": "abc467", "problem_index": "B", "name": "Keep the Change"}, + ], + ) + monkeypatch.setattr( + main.github_client, + "existing_discussion_titles", + lambda token: {"ABC 467 A - Obesity"}, + ) + created = [] + monkeypatch.setattr( + main.github_client, + "create_discussion", + lambda token, title, body: created.append(title) or "https://example.invalid", + ) + + main.run("fake-token") + + assert created == ["ABC 467 B - Keep the Change"] + + +def test_run_continues_after_a_single_task_creation_failure(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc467"] + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: [ + {"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}, + {"id": "abc467_b", "contest_id": "abc467", "problem_index": "B", "name": "Keep the Change"}, + ], + ) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + created = [] + + def fake_create_discussion(token, title, body): + if title == "ABC 467 A - Obesity": + raise RuntimeError("boom") + created.append(title) + return "https://example.invalid" + + monkeypatch.setattr(main.github_client, "create_discussion", fake_create_discussion) + + main.run("fake-token") # must not raise + + assert created == ["ABC 467 B - Keep the Change"] + + +def test_run_continues_after_a_single_contest_fetch_failure(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc466", "abc467"] + ) + + def fake_fetch_tasks(contest_id): + if contest_id == "abc466": + raise ValueError("no task table") + return [{"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}] + + monkeypatch.setattr(main.atcoder, "fetch_tasks", fake_fetch_tasks) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + created = [] + monkeypatch.setattr( + main.github_client, + "create_discussion", + lambda token, title, body: created.append(title) or "https://example.invalid", + ) + + main.run("fake-token") # must not raise + + assert created == ["ABC 467 A - Obesity"] + + +def test_run_uses_explicit_contest_id_when_given(monkeypatch): + called_with = [] + monkeypatch.setattr( + main.atcoder, + "find_recently_finished_abc_ids", + lambda limit: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: called_with.append(contest_id) or [], + ) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + main.run("fake-token", contest_id="abc468") + + assert called_with == ["abc468"] +``` + +- [ ] **Step 2: 実行して失敗を確認** + +Run: `uv run pytest tests/test_main.py -v` +Expected: `ModuleNotFoundError: No module named 'contest_discussions.main'` で FAIL + +--- + +### Task 2: `main.py` を実装 + +**Files:** +- Create: `src/contest_discussions/main.py` + +- [ ] **Step 1: 実装** + +```python +"""Orchestrates finding recently finished ABC contests and posting Discussions +for any problems that don't have one yet.""" + +import argparse +import os +import sys + +from contest_discussions import atcoder, github_client +from contest_discussions.constants import CONTESTS_TO_CHECK, DISCUSSION_BODY + + +def build_title(contest_id: str, task: dict) -> str: + number = int(contest_id[3:]) + return f"ABC {number} {task['problem_index']} - {task['name']}" + + +def run(token: str, contest_id: str | None = None) -> None: + contest_ids = ( + [contest_id] + if contest_id + else atcoder.find_recently_finished_abc_ids(limit=CONTESTS_TO_CHECK) + ) + + if not contest_ids: + print("No finished ABC contests found.") + return + + existing_titles = github_client.existing_discussion_titles(token) + + for cid in contest_ids: + try: + tasks = atcoder.fetch_tasks(cid) + except Exception as error: + print(f"Failed to fetch tasks for {cid}: {error}", file=sys.stderr) + continue + + for task in tasks: + title = build_title(cid, task) + + if title in existing_titles: + continue + + try: + url = github_client.create_discussion(token, title, DISCUSSION_BODY) + except Exception as error: + print(f"Failed to create discussion for '{title}': {error}", file=sys.stderr) + continue + + print(f"Created: {url}") + existing_titles.add(title) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Post ABC contest discussions to NoviSteps-Writeups." + ) + parser.add_argument( + "--contest-id", + default=None, + help="Contest ID to backfill (e.g. abc467). If omitted, automatically " + "detects recently finished ABCs.", + ) + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN") + + if not token: + raise SystemExit("GITHUB_TOKEN environment variable is not set.") + + run(token, contest_id=args.contest_id) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: テストを実行して成功を確認** + +Run: `uv run pytest tests/test_main.py -v` +Expected: 5 tests PASS + +- [ ] **Step 3: 全テストを実行** + +Run: `uv run pytest -v` +Expected: Phase 2〜5 の全テストが PASS + +- [ ] **Step 4: Commit** + +```bash +git add src/contest_discussions/main.py tests/test_main.py +git commit -m "feat: orchestrate finding and posting ABC discussions" +``` diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-6.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-6.md new file mode 100644 index 0000000..d92adf5 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-6.md @@ -0,0 +1,99 @@ +# Phase 6: GitHub Actions ワークフロー配線 + 手動E2E検証 + +**目的:** cron + `workflow_dispatch` で `contest_discussions.main` を実行する本番ワークフローを用意し、実際に1回動かして end-to-end で確認する。 + +**注意:** このフェーズの最終ステップは実際に公開リポジトリへ Discussion を作成する。**ユーザーの明示的な許可を得てから実行すること。** + +--- + +### Task 1: 本番ワークフローの作成 + +**Files:** +- Create: `.github/workflows/post_discussions.yml` +- Delete: `.github/workflows/smoke_test_discussion.yml` (Phase 1 で作成した検証用ワークフロー) + +- [ ] **Step 1: ワークフローを作成** + +cron は「ABCは毎週土曜21:00 JST開始・100分」という前提で、終了 (22:40 JST = 13:40 UTC) の10分後に設定する。開催時間が変則的な回もあるため、`CONTESTS_TO_CHECK` による取りこぼし救済 (Phase 3) が効いてくる。 + +**注意:** `astral-sh/setup-uv@v3` の `python-version` 入力がこのバージョンでサポートされているか、実装時に確認すること。未対応の場合は `actions/setup-python` を別ステップで追加するか `uv python install 3.13` を実行する。 + +```yaml +name: Post contest discussions + +on: + schedule: + - cron: '50 13 * * 6' + workflow_dispatch: + inputs: + contest_id: + description: 'Contest ID to backfill (e.g. abc467). Leave empty for automatic detection.' + required: false + +permissions: + contents: read + discussions: write + +jobs: + post-discussions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + python-version: "3.13" + + - name: Install dependencies + run: uv sync + + - name: Post discussions + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTEST_ID: ${{ github.event.inputs.contest_id }} + run: | + if [ -n "$CONTEST_ID" ]; then + uv run python -m contest_discussions.main --contest-id "$CONTEST_ID" + else + uv run python -m contest_discussions.main + fi +``` + +- [ ] **Step 2: 検証用ワークフローを削除** + +```bash +git rm .github/workflows/smoke_test_discussion.yml +``` + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/post_discussions.yml +git commit -m "ci: add scheduled workflow to post ABC contest discussions" +``` + +--- + +### Task 2 (ユーザー確認必須・手動): 実際に1回動かして確認 + +- [ ] **Step 1: push** + +```bash +git push -u origin +``` + +- [ ] **Step 2: 既に Discussion が存在しない過去のABC (または amount 上限内で自然に検出される最新ABC) を対象に手動実行** + +```bash +gh workflow run post_discussions.yml +gh run watch +``` + +- [ ] **Step 3: 結果を確認** + +- NoviSteps-Writeups の Discussions ページで、実際に問題ごとのDiscussionが作成されたことを確認する +- タイトル・本文が既存の手動投稿と同じ形式になっているか目視確認する +- ワークフローのログで、重複スキップ (`title in existing_titles`) が正しく機能しているか確認する + +- [ ] **Step 4: 問題があれば修正してから Phase 7 へ** diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-7.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-7.md new file mode 100644 index 0000000..6265968 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/phase-7.md @@ -0,0 +1,44 @@ +# Phase 7: リファクタサイクル (AGENTS.md 必須ステップ) + +**目的:** AtCoderNoviSteps の `AGENTS.md` が定める「全フェーズ完了後の必須リファクタサイクル」を実施する。 + +--- + +### Task 1: 学びの言語化 + +- [ ] **Step 1: 実装中に判明した非自明な制約・詰まりどころを `plan.md` に追記する** + +例: `GITHUB_TOKEN` の `discussions: write` 権限が実際に機能したか / しなかったか (Phase 1 の検証結果)、AtCoderのページ構造で PoC と異なった点、など。 + +- [ ] **Step 2: 残タスクがあれば `plan.md` に書く** + +例: `CONTESTS_TO_CHECK` の値が実運用で適切だったか、cron時刻の調整が必要かどうか。 + +--- + +### Task 2: phase-N.md の破棄 + +- [ ] **Step 1: phase-1.md 〜 phase-7.md を削除** + +```bash +git rm docs/dev-notes/2026-07-21/contest-discussion-automation/phase-*.md +git commit -m "docs: discard phase files after implementation" +``` + +--- + +### Task 3: CodeRabbit レビュー (実施可能な場合のみ) + +**注記:** `coderabbit review --plain` は通常PRに対して実行する。このリポジトリは今回が最初のPRになる可能性が高く、CodeRabbit が未連携の場合は本タスクをスキップし、その旨を `plan.md` に記録する。 + +- [ ] **Step 1: PRを作成後、実行** + +```bash +coderabbit review --plain +``` + +- [ ] **Step 2: `critical` / `high` / `potential_issue` (medium) の指摘を `plan.md` の `## CodeRabbit Findings` セクションに転記する** + +- [ ] **Step 3: どの指摘を直すかはユーザーが判断する。ここでは一方的に直さない。** + +`nitpick` レベルの指摘はPRのCIに任せる。 diff --git a/docs/dev-notes/2026-07-21/contest-discussion-automation/plan.md b/docs/dev-notes/2026-07-21/contest-discussion-automation/plan.md new file mode 100644 index 0000000..1bd8132 --- /dev/null +++ b/docs/dev-notes/2026-07-21/contest-discussion-automation/plan.md @@ -0,0 +1,75 @@ +# コンテスト議論自動投稿 実装計画 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** ABC (AtCoder Beginner Contest) が終了した直後に、各問題ごとの GitHub Discussion を自動作成する。 + +**Architecture:** NoviSteps-Writeups リポジトリ単独で完結する GitHub Actions (cron) から Python スクリプトを実行する。AtCoder の問題ページを直接スクレイピングして問題一覧を取得し、GitHub GraphQL API (`createDiscussion`) で Discussion を作成する。NoviSteps 本体 (AtCoderNoviSteps) のコード・DB・シークレットには一切触れない。 + +**Tech Stack:** Python 3.13 (uv管理) / requests / selectolax / pytest / responses (HTTPモック) / GitHub Actions + +--- + +## 概要 + +現在、NoviSteps-Writeups の Discussion (感想・気づき投稿用) は ABC の問題ごとに手動 (または個別のアドホックな操作) で作成されている。これを自動化する。 + +当初案は NoviSteps 本体 (AtCoderNoviSteps) の管理画面「タスクインポート」実行時にフックする設計だったが、共同開発者のレビューで以下の指摘を受け、設計を変更した。 + +- 管理画面からの実行はUIから想像しづらく、テスト・デバッグの難易度が高い +- 本体アプリに GitHub 書き込み用シークレットを持たせると攻撃面が増える +- 本来のゴールは「タスクインポート時」ではなく「コンテスト終了後すぐに投稿」なので、コンテストの開催スケジュールに合わせた cron の方が目的に忠実 + +## 設計根拠 + +- **起点を Writeups リポジトリの GitHub Actions (cron) にする**: NoviSteps 本体から完全に切り離すことで、シークレット管理・テスト・デバッグが Writeups リポジトリ単独で完結する。 +- **データ取得元は AtCoder の問題ページを直接スクレイピング**: 共同開発者が動作確認済みの PoC (`fetch_tasks_from_atcoder.py`) をベースにする。kenkoooo Problems API (`problems.json`) は全コンテスト分を含み巨大なため個別コンテスト取得に不向き。一方 `contests.json` は軽量なため、「終了済み最新コンテストの特定」にのみ利用する。 +- **GITHUB_TOKEN (Actions 標準トークン) を使う、専用 PAT は作らない**: GitHub Actions の `permissions: discussions: write` で GraphQL `createDiscussion` mutation が実行できる。同一リポジトリ内への書き込みなので、追加のシークレット発行は不要 (Phase 1 で実際に動作検証する)。 +- **Discussion タイトルは `ABC {回数} {problem_index} - {name}` 形式**: 既存の手動投稿 (`ABC 467 F - Email Scheduling Optimization` など) と一致させる。`name` は問題ページの2列目テキストをそのまま使うため、正規表現によるパースが不要。 +- **重複防止はタスク単位**: 「直近 N 件の終了済み ABC」それぞれについて、Discussion タイトルが既存かどうかを個別にチェックしてから作成する。コンテスト単位 (「このコンテストは投稿済みか」) ではなく問題単位でチェックすることで、途中で失敗したコンテスト (一部の問題だけ投稿済み) も次回実行で自動的に埋められる。 +- **失敗時の扱い**: 1問題分の Discussion 作成に失敗しても他の問題の処理は継続する (ログのみ記録)。 + +## 却下した代替案 + +| 案 | 却下理由 | +|---|---| +| NoviSteps 本体の管理画面タスクインポート時にフック (当初案) | UIから見えない副作用になる、本体に書き込み用シークレットを持たせる必要がある、テストの難易度が高い | +| kenkoooo Problems API (`problems.json`) から問題名を取得 | 全コンテスト分を含み巨大でAPI応答が重い。個別コンテスト取得用のエンドポイントが存在しない | +| コンテスト単位の重複チェック (「このコンテストは投稿済みか」を1回だけ判定) | 一部の問題だけ投稿に失敗した場合、次回実行で残りが永久にスキップされてしまう | +| 専用 Fine-grained PAT をリポジトリシークレットとして発行 | Actions 標準の `GITHUB_TOKEN` + `permissions: discussions: write` で足りる可能性が高く、余計なシークレット管理を増やしたくない (動作しない場合のみ Phase 1 でフォールバック) | + +## ファイル構成 + +``` +NoviSteps-Writeups/ +├── pyproject.toml +├── uv.lock +├── src/ +│ └── contest_discussions/ +│ ├── __init__.py +│ ├── constants.py # REPOSITORY_ID, GENERAL_CATEGORY_ID, DISCUSSION_BODY, CONTESTS_TO_CHECK +│ ├── atcoder.py # fetch_tasks(), find_recently_finished_abc_ids() +│ ├── github_client.py # existing_discussion_titles(), create_discussion() +│ └── main.py # 全体のオーケストレーション + CLI エントリポイント +├── tests/ +│ ├── fixtures/ +│ │ └── abc467_tasks.html # atcoder.jp のタスク表HTMLの実物サンプル (テスト用) +│ ├── test_atcoder.py +│ ├── test_github_client.py +│ └── test_main.py +└── .github/ + └── workflows/ + └── post_discussions.yml +``` + +## フェーズ一覧 + +1. [Phase 1](phase-1.md): プロジェクト初期化 + GITHUB_TOKEN の Discussion 書き込み権限を検証 +2. [Phase 2](phase-2.md): `atcoder.py` — 問題一覧の取得ロジック +3. [Phase 3](phase-3.md): `atcoder.py` — 終了済み最新コンテストの判定ロジック +4. [Phase 4](phase-4.md): `github_client.py` — 既存Discussion確認・作成 +5. [Phase 5](phase-5.md): `main.py` — オーケストレーション + CLI +6. [Phase 6](phase-6.md): GitHub Actions ワークフロー配線 + 手動E2E検証 +7. [Phase 7](phase-7.md): リファクタサイクル (AGENTS.md 必須ステップ) + +各フェーズは低リスク (ローカルで完結するテスト) から高リスク (実際にDiscussionを作成するE2E検証) の順に並べている。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a8801a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "contest-discussions" +version = "0.1.0" +description = "Automatically create GitHub Discussions for each ABC problem after the contest ends." +readme = "README.md" +requires-python = ">=3.13,<4.0" +dependencies = [ + "requests>=2.32", + "selectolax>=0.4.6", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "responses>=0.25", +] + +[tool.pytest.ini_options] +pythonpath = ["src"] diff --git a/src/contest_discussions/__init__.py b/src/contest_discussions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/contest_discussions/atcoder.py b/src/contest_discussions/atcoder.py new file mode 100644 index 0000000..730fee6 --- /dev/null +++ b/src/contest_discussions/atcoder.py @@ -0,0 +1,117 @@ +"""Fetches contest schedule metadata from the AtCoder Problems API and +per-contest task lists directly from the AtCoder website. +""" + +import re +from datetime import datetime, timezone + +import requests +from selectolax.parser import HTMLParser + +REQUEST_TIMEOUT_SECONDS = 10 + +CONTESTS_JSON_URL = "https://kenkoooo.com/atcoder/resources/contests.json" + +_TASK_HREF_PATTERN = re.compile(r"^/contests/([^/]+)/tasks/([^/]+)$") +_ABC_ID_PATTERN = re.compile(r"^abc\d{3}$") + + +def fetch_tasks(contest_id: str) -> list[dict[str, str]]: + """Fetches the task list for a contest from its AtCoder tasks page. + + Returns a list of dicts with keys: id, contest_id, problem_index, name. + Raises ValueError if the task table cannot be found on the page. + Network/HTTP failures propagate as requests.RequestException (e.g. + requests.HTTPError, requests.Timeout) — callers should catch that. + """ + url = f"https://atcoder.jp/contests/{contest_id}/tasks" + response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + + # Parse from bytes so selectolax's own charset sniffing applies, instead of + # trusting requests' Content-Type-derived (and sometimes absent) encoding. + tree = HTMLParser(response.content) + tbody = tree.css_first("tbody") + + if tbody is None: + raise ValueError(f"No task table found on the tasks page for {contest_id}") + + tasks = [] + + for row in tbody.css("tr"): + task = _parse_row(row, contest_id) + + if task is not None: + tasks.append(task) + + return tasks + + +def _parse_row(row, contest_id: str) -> dict[str, str] | None: + cells = row.css("td") + + if len(cells) < 2: + return None + + link = cells[0].css_first("a") + + if link is None or "href" not in link.attributes: + return None + + match = _TASK_HREF_PATTERN.match(link.attributes["href"]) + + if match is None: + return None + + href_contest_id, task_id = match.groups() + + if href_contest_id != contest_id: + return None + + problem_index = link.text(strip=True) + name = cells[1].text(strip=True) + + if not problem_index or not name: + return None + + return { + "id": task_id, + "contest_id": contest_id, + "problem_index": problem_index, + "name": name, + } + + +def find_recently_finished_abc_ids( + now: datetime | None = None, limit: int = 3 +) -> list[str]: + """Returns up to `limit` most recently finished ABC contest_ids, oldest first. + + `now` must be timezone-aware (defaults to the current UTC time). + Network/HTTP failures propagate as requests.RequestException (e.g. + requests.HTTPError, requests.Timeout) — callers should catch that. + Assumes well-formed contests.json entries; a missing "id"/"start_epoch_second"/ + "duration_second" key raises KeyError. + """ + if now is None: + now = datetime.now(timezone.utc) + elif now.tzinfo is None or now.utcoffset() is None: + raise ValueError("now must be timezone-aware") + + response = requests.get(CONTESTS_JSON_URL, timeout=REQUEST_TIMEOUT_SECONDS) + response.raise_for_status() + contests = response.json() + + now_epoch = now.timestamp() + finished_abc = [ + contest + for contest in contests + if _ABC_ID_PATTERN.match(contest["id"]) + and contest["start_epoch_second"] + contest["duration_second"] <= now_epoch + ] + finished_abc.sort(key=lambda contest: contest["start_epoch_second"]) + + if limit <= 0: + return [] + + return [contest["id"] for contest in finished_abc[-limit:]] diff --git a/src/contest_discussions/constants.py b/src/contest_discussions/constants.py new file mode 100644 index 0000000..aab75be --- /dev/null +++ b/src/contest_discussions/constants.py @@ -0,0 +1,17 @@ +"""Fixed IDs and text for the NoviSteps-Writeups repository. + +See: https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups +""" + +# `gh api graphql -f query='query { repository(owner: "AtCoder-NoviSteps", name: "NoviSteps-Writeups") { id } }'` +REPOSITORY_ID = "R_kgDOTNgHyw" + +# "General" discussion category. +# `gh api graphql -f query='query { repository(owner: "AtCoder-NoviSteps", name: "NoviSteps-Writeups") { discussionCategories(first: 10) { nodes { id name } } } }'` +GENERAL_CATEGORY_ID = "DIC_kwDOTNgHy84DAe9x" + +DISCUSSION_BODY = "問題の感想や気づきを投稿・共有するスペースです" + +# How many recent ABCs to check for missing discussions. Covers up to 3 missed +# weekly cron runs in a row, so a single transient failure self-heals next run. +CONTESTS_TO_CHECK = 3 diff --git a/src/contest_discussions/github_client.py b/src/contest_discussions/github_client.py new file mode 100644 index 0000000..9f501ad --- /dev/null +++ b/src/contest_discussions/github_client.py @@ -0,0 +1,106 @@ +"""Minimal GitHub GraphQL client for the NoviSteps-Writeups Discussions API.""" + +import requests + +from contest_discussions.constants import GENERAL_CATEGORY_ID, REPOSITORY_ID + +GITHUB_GRAPHQL_URL = "https://api.github.com/graphql" +REQUEST_TIMEOUT_SECONDS = 10 + +_EXISTING_TITLES_QUERY = """ +query($repositoryId: ID!, $categoryId: ID!, $after: String) { + node(id: $repositoryId) { + ... on Repository { + discussions(categoryId: $categoryId, first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { title } + pageInfo { hasNextPage endCursor } + } + } + } +} +""" + +_CREATE_DISCUSSION_MUTATION = """ +mutation($repositoryId: ID!, $categoryId: ID!, $title: String!, $body: String!) { + createDiscussion(input: { + repositoryId: $repositoryId, + categoryId: $categoryId, + title: $title, + body: $body + }) { + discussion { id url } + } +} +""" + + +def existing_discussion_titles(token: str) -> set[str]: + """Returns all Discussion titles in the General category. + + Discussions are fetched in pages of 100 to ensure older titles are also + considered during backfills. + Network/HTTP failures propagate as requests.RequestException (e.g. + requests.HTTPError, requests.Timeout) — callers should catch that. + """ + titles = set() + after = None + + while True: + data = _post_graphql( + token, + _EXISTING_TITLES_QUERY, + { + "repositoryId": REPOSITORY_ID, + "categoryId": GENERAL_CATEGORY_ID, + "after": after, + }, + ) + discussions = data["node"]["discussions"] + titles.update(node["title"] for node in discussions["nodes"]) + + page_info = discussions["pageInfo"] + if not page_info["hasNextPage"]: + return titles + + after = page_info["endCursor"] + if after is None: + raise RuntimeError("GitHub GraphQL API returned no cursor for the next page") + + +def create_discussion(token: str, title: str, body: str) -> str: + """Creates a Discussion in the General category and returns its URL. + + Network/HTTP failures propagate as requests.RequestException (e.g. + requests.HTTPError, requests.Timeout) — callers should catch that. + """ + data = _post_graphql( + token, + _CREATE_DISCUSSION_MUTATION, + { + "repositoryId": REPOSITORY_ID, + "categoryId": GENERAL_CATEGORY_ID, + "title": title, + "body": body, + }, + ) + + return data["createDiscussion"]["discussion"]["url"] + + +def _post_graphql(token: str, query: str, variables: dict) -> dict: + response = requests.post( + GITHUB_GRAPHQL_URL, + json={"query": query, "variables": variables}, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + response.raise_for_status() + payload = response.json() + + if "errors" in payload: + raise RuntimeError(f"GitHub GraphQL API returned errors: {payload['errors']}") + + return payload["data"] diff --git a/src/contest_discussions/main.py b/src/contest_discussions/main.py new file mode 100644 index 0000000..e416afe --- /dev/null +++ b/src/contest_discussions/main.py @@ -0,0 +1,96 @@ +"""Orchestrates finding recently finished ABC contests and posting Discussions +for any problems that don't have one yet.""" + +import argparse +import logging +import os + +from contest_discussions import atcoder, github_client +from contest_discussions.constants import CONTESTS_TO_CHECK, DISCUSSION_BODY + +logger = logging.getLogger(__name__) + + +def build_title(contest_id: str, task: dict) -> str: + number = int(contest_id[3:]) + return f"ABC {number} {task['problem_index']} - {task['name']}" + + +def run(token: str, contest_id: str | None = None) -> None: + """Posts a Discussion for every task missing one, across the target contests. + + Per-contest fetch failures and per-task creation failures are caught and + logged, not raised — one bad contest or task must not stop the others. + Raises RuntimeError if every attempted contest failed to fetch, since that + points to a systemic issue (e.g. AtCoder page structure changed) rather + than a one-off, and must surface as a failed run instead of a silent + no-op success. + """ + contest_ids = ( + [contest_id] + if contest_id + else atcoder.find_recently_finished_abc_ids(limit=CONTESTS_TO_CHECK) + ) + + if not contest_ids: + print("No finished ABC contests found.") + return + + existing_titles = github_client.existing_discussion_titles(token) + + fetch_failures = 0 + + for cid in contest_ids: + try: + tasks = atcoder.fetch_tasks(cid) + except Exception: + logger.exception("Failed to fetch tasks for %s", cid) + fetch_failures += 1 + continue + + for task in tasks: + title = build_title(cid, task) + + if title in existing_titles: + continue + + try: + url = github_client.create_discussion(token, title, DISCUSSION_BODY) + except Exception: + logger.exception("Failed to create discussion for '%s'", title) + continue + + print(f"Created: {url}") + existing_titles.add(title) + + if fetch_failures == len(contest_ids): + raise RuntimeError( + f"Failed to fetch tasks for all {len(contest_ids)} contest(s) " + "attempted this run; see logged exceptions above for details." + ) + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + parser = argparse.ArgumentParser( + description="Post ABC contest discussions to NoviSteps-Writeups." + ) + parser.add_argument( + "--contest-id", + default=None, + help="Contest ID to backfill (e.g. abc467). If omitted, automatically " + "detects recently finished ABCs.", + ) + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN") + + if not token: + raise SystemExit("GITHUB_TOKEN environment variable is not set.") + + run(token, contest_id=args.contest_id) + + +if __name__ == "__main__": + main() diff --git a/tests/fixtures/abc_tasks_sample.html b/tests/fixtures/abc_tasks_sample.html new file mode 100644 index 0000000..0933e47 --- /dev/null +++ b/tests/fixtures/abc_tasks_sample.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +
AObesity1001 sec1024 MB
BKeep the Change1502 sec1024 MB
FEmail Scheduling Optimization5002 sec1024 MB
+ + diff --git a/tests/fixtures/contests_sample.json b/tests/fixtures/contests_sample.json new file mode 100644 index 0000000..1c0f3b1 --- /dev/null +++ b/tests/fixtures/contests_sample.json @@ -0,0 +1,37 @@ +[ + { + "id": "abc465", + "start_epoch_second": 1782392400, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 465", + "rate_change": "~ 1999" + }, + { + "id": "abc466", + "start_epoch_second": 1782997200, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 466", + "rate_change": "~ 1999" + }, + { + "id": "abc467", + "start_epoch_second": 1783602000, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 467", + "rate_change": "~ 1999" + }, + { + "id": "abc468", + "start_epoch_second": 1784811600, + "duration_second": 6000, + "title": "AtCoder Beginner Contest 468", + "rate_change": "~ 1999" + }, + { + "id": "arc199", + "start_epoch_second": 1782997200, + "duration_second": 7200, + "title": "AtCoder Regular Contest 199", + "rate_change": "All" + } +] diff --git a/tests/test_atcoder.py b/tests/test_atcoder.py new file mode 100644 index 0000000..5e4861e --- /dev/null +++ b/tests/test_atcoder.py @@ -0,0 +1,116 @@ +import json +from datetime import datetime, timezone +from pathlib import Path + +import responses +import pytest + +from contest_discussions.atcoder import fetch_tasks, find_recently_finished_abc_ids + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@responses.activate +def test_fetch_tasks_parses_task_table(): + html = (FIXTURES_DIR / "abc_tasks_sample.html").read_text(encoding="utf-8") + responses.get("https://atcoder.jp/contests/abc467/tasks", body=html) + + tasks = fetch_tasks("abc467") + + assert tasks == [ + { + "id": "abc467_a", + "contest_id": "abc467", + "problem_index": "A", + "name": "Obesity", + }, + { + "id": "abc467_b", + "contest_id": "abc467", + "problem_index": "B", + "name": "Keep the Change", + }, + { + "id": "abc467_f", + "contest_id": "abc467", + "problem_index": "F", + "name": "Email Scheduling Optimization", + }, + ] + + +@responses.activate +def test_fetch_tasks_raises_when_no_task_table_found(): + responses.get( + "https://atcoder.jp/contests/abc999/tasks", + body="Not Found", + ) + + try: + fetch_tasks("abc999") + assert False, "expected ValueError" + except ValueError as error: + assert "abc999" in str(error) + + +@responses.activate +def test_fetch_tasks_skips_malformed_rows(): + html = """ + + + + + + +
broken row without link
AObesity
+ """ + responses.get("https://atcoder.jp/contests/abc467/tasks", body=html) + + tasks = fetch_tasks("abc467") + + assert len(tasks) == 1 + assert tasks[0]["problem_index"] == "A" + + +@responses.activate +def test_find_recently_finished_abc_ids_returns_oldest_first(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + now = datetime(2026, 7, 21, tzinfo=timezone.utc) + result = find_recently_finished_abc_ids(now=now, limit=3) + + # abc468 is in the future, arc199 is not an ABC -> excluded. + # Among the finished ABCs (465, 466, 467), the 3 most recent, oldest first. + assert result == ["abc465", "abc466", "abc467"] + + +@responses.activate +def test_find_recently_finished_abc_ids_respects_limit(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + now = datetime(2026, 7, 21, tzinfo=timezone.utc) + result = find_recently_finished_abc_ids(now=now, limit=1) + + assert result == ["abc467"] + + +@responses.activate +def test_find_recently_finished_abc_ids_returns_empty_for_zero_limit(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + now = datetime(2026, 7, 21, tzinfo=timezone.utc) + result = find_recently_finished_abc_ids(now=now, limit=0) + + assert result == [] + + +@responses.activate +def test_find_recently_finished_abc_ids_rejects_naive_datetime(): + contests = json.loads((FIXTURES_DIR / "contests_sample.json").read_text(encoding="utf-8")) + responses.get("https://kenkoooo.com/atcoder/resources/contests.json", json=contests) + + with pytest.raises(ValueError, match="timezone-aware"): + find_recently_finished_abc_ids(now=datetime(2026, 7, 21), limit=3) diff --git a/tests/test_github_client.py b/tests/test_github_client.py new file mode 100644 index 0000000..4eba819 --- /dev/null +++ b/tests/test_github_client.py @@ -0,0 +1,100 @@ +import responses + +from contest_discussions.github_client import ( + GITHUB_GRAPHQL_URL, + create_discussion, + existing_discussion_titles, +) + + +@responses.activate +def test_existing_discussion_titles_returns_titles(): + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "node": { + "discussions": { + "nodes": [ + {"title": "ABC 467 A - Obesity"}, + {"title": "ABC 467 B - Keep the Change"}, + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + }, + ) + + titles = existing_discussion_titles("fake-token") + + assert titles == {"ABC 467 A - Obesity", "ABC 467 B - Keep the Change"} + + +@responses.activate +def test_existing_discussion_titles_paginates_all_discussions(): + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "node": { + "discussions": { + "nodes": [{"title": "Old discussion"}], + "pageInfo": {"hasNextPage": True, "endCursor": "cursor-1"}, + } + } + } + }, + ) + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "node": { + "discussions": { + "nodes": [{"title": "Older discussion"}], + "pageInfo": {"hasNextPage": False, "endCursor": "cursor-2"}, + } + } + } + }, + ) + + titles = existing_discussion_titles("fake-token") + + assert titles == {"Old discussion", "Older discussion"} + + +@responses.activate +def test_existing_discussion_titles_raises_on_graphql_errors(): + responses.post( + GITHUB_GRAPHQL_URL, + json={"errors": [{"message": "Bad credentials"}]}, + ) + + try: + existing_discussion_titles("fake-token") + assert False, "expected RuntimeError" + except RuntimeError as error: + assert "Bad credentials" in str(error) + + +@responses.activate +def test_create_discussion_returns_url(): + responses.post( + GITHUB_GRAPHQL_URL, + json={ + "data": { + "createDiscussion": { + "discussion": { + "id": "D_test123", + "url": "https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups/discussions/23", + } + } + } + }, + ) + + url = create_discussion("fake-token", "ABC 467 G - Many Sweets Problem", "body text") + + assert url == "https://github.com/AtCoder-NoviSteps/NoviSteps-Writeups/discussions/23" diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..b94d8e6 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,186 @@ +import pytest + +from contest_discussions import main + + +def test_build_title_formats_correctly(): + task = {"problem_index": "F", "name": "Email Scheduling Optimization"} + assert main.build_title("abc467", task) == "ABC 467 F - Email Scheduling Optimization" + + +def test_run_skips_tasks_that_already_have_a_discussion(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc467"] + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: [ + {"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}, + {"id": "abc467_b", "contest_id": "abc467", "problem_index": "B", "name": "Keep the Change"}, + ], + ) + monkeypatch.setattr( + main.github_client, + "existing_discussion_titles", + lambda token: {"ABC 467 A - Obesity"}, + ) + created = [] + monkeypatch.setattr( + main.github_client, + "create_discussion", + lambda token, title, body: created.append(title) or "https://example.invalid", + ) + + main.run("fake-token") + + assert created == ["ABC 467 B - Keep the Change"] + + +def test_run_continues_after_a_single_task_creation_failure(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc467"] + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: [ + {"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}, + {"id": "abc467_b", "contest_id": "abc467", "problem_index": "B", "name": "Keep the Change"}, + ], + ) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + created = [] + + def fake_create_discussion(token, title, body): + if title == "ABC 467 A - Obesity": + raise RuntimeError("boom") + created.append(title) + return "https://example.invalid" + + monkeypatch.setattr(main.github_client, "create_discussion", fake_create_discussion) + + main.run("fake-token") # must not raise + + assert created == ["ABC 467 B - Keep the Change"] + + +def test_run_continues_after_a_single_contest_fetch_failure(monkeypatch): + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc466", "abc467"] + ) + + def fake_fetch_tasks(contest_id): + if contest_id == "abc466": + raise ValueError("no task table") + return [{"id": "abc467_a", "contest_id": "abc467", "problem_index": "A", "name": "Obesity"}] + + monkeypatch.setattr(main.atcoder, "fetch_tasks", fake_fetch_tasks) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + created = [] + monkeypatch.setattr( + main.github_client, + "create_discussion", + lambda token, title, body: created.append(title) or "https://example.invalid", + ) + + main.run("fake-token") # must not raise + + assert created == ["ABC 467 A - Obesity"] + + +def test_run_raises_when_every_contest_fails_to_fetch(monkeypatch): + # A systemic failure (e.g. AtCoder page structure changed) must surface + # as a failed run, not a silent no-op success. + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc466", "abc467"] + ) + + def always_fails(contest_id): + raise ValueError("no task table") + + monkeypatch.setattr(main.atcoder, "fetch_tasks", always_fails) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + with pytest.raises(RuntimeError): + main.run("fake-token") + + +def test_run_uses_explicit_contest_id_when_given(monkeypatch): + called_with = [] + monkeypatch.setattr( + main.atcoder, + "find_recently_finished_abc_ids", + lambda limit: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: called_with.append(contest_id) or [], + ) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + main.run("fake-token", contest_id="abc468") + + assert called_with == ["abc468"] + + +def test_run_skips_duplicate_title_within_the_same_run(monkeypatch): + # A task appearing twice in one contest's fetch (e.g. a page glitch) must + # only produce one Discussion — the second attempt should see the title + # already added to existing_titles by the first successful creation. + duplicated_task = { + "id": "abc467_a", + "contest_id": "abc467", + "problem_index": "A", + "name": "Obesity", + } + monkeypatch.setattr( + main.atcoder, "find_recently_finished_abc_ids", lambda limit: ["abc467"] + ) + monkeypatch.setattr( + main.atcoder, + "fetch_tasks", + lambda contest_id: [duplicated_task, duplicated_task], + ) + monkeypatch.setattr(main.github_client, "existing_discussion_titles", lambda token: set()) + + call_count = 0 + + def fake_create_discussion(token, title, body): + nonlocal call_count + call_count += 1 + return "https://example.invalid" + + monkeypatch.setattr(main.github_client, "create_discussion", fake_create_discussion) + + main.run("fake-token") + + assert call_count == 1 + + +def test_main_exits_when_github_token_missing(monkeypatch): + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + monkeypatch.setattr("sys.argv", ["main.py"]) + + with pytest.raises(SystemExit): + main.main() + + +def test_main_passes_token_and_contest_id_to_run(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "env-token") + monkeypatch.setattr("sys.argv", ["main.py", "--contest-id", "abc999"]) + + captured = {} + + def fake_run(token, contest_id=None): + captured["token"] = token + captured["contest_id"] = contest_id + + monkeypatch.setattr(main, "run", fake_run) + + main.main() + + assert captured == {"token": "env-token", "contest_id": "abc999"} diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..7282db1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,266 @@ +version = 1 +revision = 3 +requires-python = ">=3.13, <4.0" + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contest-discussions" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "requests" }, + { name = "selectolax" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "responses" }, +] + +[package.metadata] +requires-dist = [ + { name = "requests", specifier = ">=2.32" }, + { name = "selectolax", specifier = ">=0.4.6" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "responses", specifier = ">=0.25" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "responses" +version = "0.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f0/1a/4af3e6d659394b809838490b144e4ab8d7ed3b9fecc7ca78f5d2f79b1a3d/responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8", size = 84030, upload-time = "2026-07-03T16:44:50.325Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/28/693e1d9ebf72baa062ded80d837a035b86ce75eda5a269379e9e2b1008a8/responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364", size = 35609, upload-time = "2026-07-03T16:44:49.1Z" }, +] + +[[package]] +name = "selectolax" +version = "0.4.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/6c/aec38dfee314a38cb7c0940fe055b22f22627b3e0a216772c24372eef3a9/selectolax-0.4.11.tar.gz", hash = "sha256:2b565ddabce6c9a7b73fa28a39acf8f411a084fa2f169234ec2470f552d4421d", size = 4883455, upload-time = "2026-07-15T07:25:30.588Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/b5/6e0653d45b8d138b3fc37b37780b989761fb486e7c002aa413eb89d3ad64/selectolax-0.4.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5153157ed60d968ed303acbfd2c8762fa0c0462e2663bd04466471c565deb88a", size = 2250779, upload-time = "2026-07-15T07:24:26.769Z" }, + { url = "https://files.pythonhosted.org/packages/53/c5/c367cf0583799d8c32555c4fa3b900b1e8de1aef07fb009c488a615b6ed0/selectolax-0.4.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:386494779e5464e587ed4dc076e1c48c24ebaf2da1e3a249690551d1f97fe8ed", size = 2300206, upload-time = "2026-07-15T07:24:28.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/61/956974dc429e3df99814d1ba5629a324eef366e2116b030fdd5354713402/selectolax-0.4.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47be0e591125484de14ff0c9aaaa814dd4a4019de35eabe360e88169a263a2b5", size = 2382455, upload-time = "2026-07-15T07:24:29.907Z" }, + { url = "https://files.pythonhosted.org/packages/51/f6/626716e2730f396bd81b853b37e9eeddd3a847730efff7548ad6d695c6e8/selectolax-0.4.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8f014f328f6f79a364293bca54d43af1cec776dc10a5302054a54b5fb2d8675", size = 2431069, upload-time = "2026-07-15T07:24:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/48/f6/acb03eb9e468f74fab17c655761179022fed57bfb1b25ff741e8c0c6a06c/selectolax-0.4.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3f6fac361b5f415c409dfd1a07dd0e9a5899d10daba8d88ce16bd552b0e06f2", size = 2387626, upload-time = "2026-07-15T07:24:33.029Z" }, + { url = "https://files.pythonhosted.org/packages/5a/08/e242e5785e049499771ac5e560112396d244e6142348eaf1c70849f83a66/selectolax-0.4.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8121f4cbfe870d9ad24ae418a735d918d55844e905c3270077e97f4e579770f", size = 2449451, upload-time = "2026-07-15T07:24:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/15/5d/b5dfbde64d622cc94d2136edb0caaafb5779ebbd884f6ae9c041d8dfa669/selectolax-0.4.11-cp313-cp313-win32.whl", hash = "sha256:bde121202b33e6041e9d2db1d62e7466b5883fd1c441eb96ff68d3ea015cfcbd", size = 1763387, upload-time = "2026-07-15T07:24:36.239Z" }, + { url = "https://files.pythonhosted.org/packages/6e/90/2888c831ebd473b6c17486d805a16925187c743964bbf895ec421c1cf2ab/selectolax-0.4.11-cp313-cp313-win_amd64.whl", hash = "sha256:5c7a91fbe1a94849d85228897c416ab9b4518bea6b04dce8ef8acd825ec80e9d", size = 1882102, upload-time = "2026-07-15T07:24:37.847Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/e78be8710bf162b43d6336ee354fbe21ea712284bd0bf58c67e15264862d/selectolax-0.4.11-cp313-cp313-win_arm64.whl", hash = "sha256:597b8e065978be200c598ae6d682496d96fbce14d34b5d519e93cf5b6be5fb60", size = 1817155, upload-time = "2026-07-15T07:24:39.354Z" }, + { url = "https://files.pythonhosted.org/packages/08/5a/ba94f50ca5a6a0af65e8d47147bbe9f6ad11c408fd03c832ea737836d3eb/selectolax-0.4.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:663ff792f92ed749cfcf452ac19aff5da74b05521e7daacb3b74388deb14d117", size = 2266464, upload-time = "2026-07-15T07:24:41.038Z" }, + { url = "https://files.pythonhosted.org/packages/12/fe/f4d7d554cd7db415c831c8fb5a2b6bbbe3bdf5a49c8f417a6093d4618d6c/selectolax-0.4.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5ce592a92fceeca2694b369a83ad72891a9c356f668718fe7e1c83eea407bb4", size = 2317609, upload-time = "2026-07-15T07:24:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/96/d6/9d702075634c1a38517a8af4242346bf0e65f206703037b56cf8da114eec/selectolax-0.4.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0f56c49161b18621ac452e42e02b0c5c61ba4c21095cfff3990e040bd9a043c", size = 2382277, upload-time = "2026-07-15T07:24:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/84/c3/f541806ec7bdd0ce8ec69351572d2f2b3919264818cd5bb792482684d492/selectolax-0.4.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:224682039ca13eb822be626e49a03592ee2b8557bcdc6381e49417a995170c94", size = 2430423, upload-time = "2026-07-15T07:24:45.937Z" }, + { url = "https://files.pythonhosted.org/packages/70/81/533fa254be8e63b1c0fbe261ba4e2c1ca86357a4844b0830a0d7ae0985f9/selectolax-0.4.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bd843540a197a33049a08fd80e59bfeafbaa688e632d53a05a9b65af5e88296f", size = 2404012, upload-time = "2026-07-15T07:24:47.774Z" }, + { url = "https://files.pythonhosted.org/packages/25/5a/3fc3de5bfdc70af07d55bdc17837b5fd4ae6229444868f057085addd9a18/selectolax-0.4.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b842c829f916fecb51f0f55882eca3e2ad49e85388178f14ae6fe0912be0a57", size = 2466775, upload-time = "2026-07-15T07:24:49.387Z" }, + { url = "https://files.pythonhosted.org/packages/f2/42/62c66067cbd3c360f762ac6964793091ea0371b3527ca2bf90955fb0b6f3/selectolax-0.4.11-cp314-cp314-win32.whl", hash = "sha256:d33e2ed75cc33e7af3fd50521c33e7d8634fae23bc197a6cee6a5015e056eef6", size = 1875717, upload-time = "2026-07-15T07:24:50.996Z" }, + { url = "https://files.pythonhosted.org/packages/14/b5/6d9ed39e909752645798c1469fb9443c0880ede999e63241ee89e91c7a54/selectolax-0.4.11-cp314-cp314-win_amd64.whl", hash = "sha256:e5929cbe3eedfaf51a09ec89642ab5355b703486d43bcf3c8f0c27d6043a488d", size = 1994595, upload-time = "2026-07-15T07:24:53.143Z" }, + { url = "https://files.pythonhosted.org/packages/49/f9/f172cfe8c29e295b9d7bc79e5b071937470f74311cd04dc3090d4166520a/selectolax-0.4.11-cp314-cp314-win_arm64.whl", hash = "sha256:466daca0599408c9d2cad7658a68490facc5c9b8d0f41ac5d17948914f57306f", size = 1928531, upload-time = "2026-07-15T07:24:55.539Z" }, + { url = "https://files.pythonhosted.org/packages/97/e9/6289d23fa4e5ccd5570a31c9180616a2e3c87ec565f7887bcfbca6204b6d/selectolax-0.4.11-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:086ca6f7e4c475bfff871ec1448ae5d342d43d6a2ca2cea65160d01b3a6a75ec", size = 2281363, upload-time = "2026-07-15T07:24:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/06/c4/1fbf3624f9e52dadda8471dfb68eaf6021e819b827cdb62ce878fa28f469/selectolax-0.4.11-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b530a2c4fad7400af27b2b7e0333c1318ecb5f5dc38e8a141dbe3bd81b398fdf", size = 2325491, upload-time = "2026-07-15T07:24:58.969Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ba/25710a259ecb2b66b9168956b768a2651533c8ea813da9decb0e0f3ee39a/selectolax-0.4.11-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3637d21f7fe60fbd6ca3dbc67a1747f6a55a9389114d72f06b5d69ba2beddf01", size = 2387575, upload-time = "2026-07-15T07:25:00.788Z" }, + { url = "https://files.pythonhosted.org/packages/bc/73/331f83e64e3a17478e832308248345d5224957eb7a62dad2e7fc5daa15b3/selectolax-0.4.11-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fad5b1065f73eeaa07ea343cbc548aaa9f9a5c359c3bdd8d98f5d80b61550d1c", size = 2439126, upload-time = "2026-07-15T07:25:02.574Z" }, + { url = "https://files.pythonhosted.org/packages/d0/33/ab29a558dc65d3a1e28c217b62605b5135123ad89f1f825c8b741366e0fc/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1da54e42ab99b9191269306e13c0fd67ada1c6654e8dc8d74fac615931dd3c62", size = 2412927, upload-time = "2026-07-15T07:25:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b6/e774ec9179d7524adf47d7187b3e4e630104e149b2fbcbfe06088a3f4847/selectolax-0.4.11-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28915b8fa90c1c3cb585858a6d24d433a3f38ea514aea59013bdb0930d9f6025", size = 2475264, upload-time = "2026-07-15T07:25:05.996Z" }, + { url = "https://files.pythonhosted.org/packages/97/14/0b4865125e777c9d852c9e388c1165e2ef4d7f1fb46596b13a1c02153fe7/selectolax-0.4.11-cp314-cp314t-win32.whl", hash = "sha256:1a6deb4464198ac67f32e56c4463aedf3e1d834b458eaac5b5b5b1ef02dcf15e", size = 1898010, upload-time = "2026-07-15T07:25:07.859Z" }, + { url = "https://files.pythonhosted.org/packages/40/1a/88db3237f2fb357119164c4f5a33a659615e3d10dd0f773d092341ee0cc4/selectolax-0.4.11-cp314-cp314t-win_amd64.whl", hash = "sha256:41f388c26304c1d840f5ee5e07c06bb9388ec834d10fec60dc148f22f98efd38", size = 2019721, upload-time = "2026-07-15T07:25:09.471Z" }, + { url = "https://files.pythonhosted.org/packages/37/03/193913c0f3d37c1e8d66ebfa0f139b2f286f70ec285907aa98b44a620447/selectolax-0.4.11-cp314-cp314t-win_arm64.whl", hash = "sha256:9077fa36e99ef4bb801194ff8f492f67279c0562e7cdfa9b4d06f5c010131969", size = 1950774, upload-time = "2026-07-15T07:25:11.533Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]