chickensmoothie 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2026 Haru ハル
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
10
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
11
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
12
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
13
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
14
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
15
+ PERFORMANCE OF THIS SOFTWARE.
@@ -0,0 +1 @@
1
+ include RELEASING.md
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: chickensmoothie
3
+ Version: 0.1.0
4
+ Summary: Unofficial async Python client for Chicken Smoothie
5
+ Author: Haru
6
+ License-Expression: ISC
7
+ Project-URL: Repository, https://github.com/haruyuki/chickensmoothie
8
+ Project-URL: Issues, https://github.com/haruyuki/chickensmoothie/issues
9
+ Keywords: chickensmoothie,pets,archive,asyncio
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: aiohttp>=3.13.5
19
+ Requires-Dist: lxml>=6.0.4
20
+ Requires-Dist: cssselect>=1.4.0
21
+ Dynamic: license-file
22
+
23
+ # chickensmoothie
24
+
25
+ Unofficial standalone asynchronous client for Chicken Smoothie. Requires Python 3.11+.
26
+ Not affiliated with or endorsed by Chicken Smoothie.
27
+ No Discord, database, environment variables, or bot startup dependencies.
28
+
29
+ ## Installation
30
+
31
+ From a local checkout:
32
+
33
+ ```powershell
34
+ uv sync
35
+ ```
36
+
37
+ Or install with pip: `python -m pip install .`.
38
+ After the first PyPI release, users can install with `python -m pip install chickensmoothie`.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import asyncio
44
+ import chickensmoothie
45
+
46
+ async def main():
47
+ pet = await chickensmoothie.get_pet("IMAGE_K_ID", years=[2026])
48
+ matches = await chickensmoothie.get_archive("IMAGE_K_ID", years=[2026])
49
+ async with chickensmoothie.Client() as client:
50
+ # Login is needed for account-dependent opening information.
51
+ await client.login("username", "password")
52
+ status = await client.get_opening_status()
53
+ events = await client.get_archive_events(2026, "pets")
54
+ page = await client.get_archive_event(events[0], "pets", 2026)
55
+ for entry in page.entries:
56
+ print(entry)
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ All network methods are async. `get_pet` returns the first matching `Pet` or
62
+ `None`; `get_archive` returns all matching `Pet` archive records. IDs refer to
63
+ image URL `k` values, as used by the bot's identification database, not numeric
64
+ IDs for individually owned pets. Individual pet profiles are not supported.
65
+ Without `years`, lookups scan archives from 2008 through the current UTC year;
66
+ this can make many requests. Restrict years when possible. The bot retains its
67
+ SQLite index for fast identification instead of scanning on every command.
68
+
69
+ `get_archive_events(year, "items")` and `get_archive_event(event, "items", year)`
70
+ return item records, including both image IDs, name, year, event and URLs.
71
+ Archive extraction retains the bot's known duplicate-pet exclusions and
72
+ `pageSize=3000` behavior. Events beyond that server response limit are not paged.
73
+
74
+ Use one `Client` context for repeated calls. `Client(session)` accepts an
75
+ existing aiohttp session and never closes it. Credentials are explicit.
76
+ HTTP failures propagate as aiohttp exceptions; failed login raises
77
+ `AuthenticationError`. Missing archive matches return empty results, while
78
+ HTTP errors are never treated as missing pets.
79
+
80
+ The bot owns SQLite/MongoDB storage, RSS feed caching, reminder scheduling,
81
+ Discord formatting and image uploads. `NEWS_FEED_URL` exposes the news source
82
+ for other applications to use with their preferred RSS reader.
83
+
84
+ ## Development
85
+
86
+ Run offline regression tests: `uv run python -m unittest discover -s tests -v`.
87
+ Build release files: `uv build --no-sources`.
88
+ See [RELEASING.md](RELEASING.md) for validation and PyPI upload instructions.
89
+
90
+ ## License
91
+
92
+ ISC. See [LICENSE](LICENSE). This license applies to the package code, not to
93
+ Chicken Smoothie's images, content, or branding.
@@ -0,0 +1,71 @@
1
+ # chickensmoothie
2
+
3
+ Unofficial standalone asynchronous client for Chicken Smoothie. Requires Python 3.11+.
4
+ Not affiliated with or endorsed by Chicken Smoothie.
5
+ No Discord, database, environment variables, or bot startup dependencies.
6
+
7
+ ## Installation
8
+
9
+ From a local checkout:
10
+
11
+ ```powershell
12
+ uv sync
13
+ ```
14
+
15
+ Or install with pip: `python -m pip install .`.
16
+ After the first PyPI release, users can install with `python -m pip install chickensmoothie`.
17
+
18
+ ## Usage
19
+
20
+ ```python
21
+ import asyncio
22
+ import chickensmoothie
23
+
24
+ async def main():
25
+ pet = await chickensmoothie.get_pet("IMAGE_K_ID", years=[2026])
26
+ matches = await chickensmoothie.get_archive("IMAGE_K_ID", years=[2026])
27
+ async with chickensmoothie.Client() as client:
28
+ # Login is needed for account-dependent opening information.
29
+ await client.login("username", "password")
30
+ status = await client.get_opening_status()
31
+ events = await client.get_archive_events(2026, "pets")
32
+ page = await client.get_archive_event(events[0], "pets", 2026)
33
+ for entry in page.entries:
34
+ print(entry)
35
+
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ All network methods are async. `get_pet` returns the first matching `Pet` or
40
+ `None`; `get_archive` returns all matching `Pet` archive records. IDs refer to
41
+ image URL `k` values, as used by the bot's identification database, not numeric
42
+ IDs for individually owned pets. Individual pet profiles are not supported.
43
+ Without `years`, lookups scan archives from 2008 through the current UTC year;
44
+ this can make many requests. Restrict years when possible. The bot retains its
45
+ SQLite index for fast identification instead of scanning on every command.
46
+
47
+ `get_archive_events(year, "items")` and `get_archive_event(event, "items", year)`
48
+ return item records, including both image IDs, name, year, event and URLs.
49
+ Archive extraction retains the bot's known duplicate-pet exclusions and
50
+ `pageSize=3000` behavior. Events beyond that server response limit are not paged.
51
+
52
+ Use one `Client` context for repeated calls. `Client(session)` accepts an
53
+ existing aiohttp session and never closes it. Credentials are explicit.
54
+ HTTP failures propagate as aiohttp exceptions; failed login raises
55
+ `AuthenticationError`. Missing archive matches return empty results, while
56
+ HTTP errors are never treated as missing pets.
57
+
58
+ The bot owns SQLite/MongoDB storage, RSS feed caching, reminder scheduling,
59
+ Discord formatting and image uploads. `NEWS_FEED_URL` exposes the news source
60
+ for other applications to use with their preferred RSS reader.
61
+
62
+ ## Development
63
+
64
+ Run offline regression tests: `uv run python -m unittest discover -s tests -v`.
65
+ Build release files: `uv build --no-sources`.
66
+ See [RELEASING.md](RELEASING.md) for validation and PyPI upload instructions.
67
+
68
+ ## License
69
+
70
+ ISC. See [LICENSE](LICENSE). This license applies to the package code, not to
71
+ Chicken Smoothie's images, content, or branding.
@@ -0,0 +1,88 @@
1
+ # Publishing with GitHub Trusted Publishing
2
+
3
+ The workflow `.github/workflows/publish.yml` builds and tests the wheel, checks
4
+ both distributions with Twine, then uploads through PyPI Trusted Publishing.
5
+ No PyPI API tokens or GitHub repository secrets are needed. Twine remains a
6
+ local validation tool, not the authentication mechanism.
7
+
8
+ ## One-time setup
9
+
10
+ 1. Commit and push the workflow, tests, and packaging files to `main` in
11
+ `haruyuki/chickensmoothie`. The workflow must exist on the default branch
12
+ before GitHub shows its manual Run workflow button.
13
+ 2. In GitHub repository Settings > Environments, create `testpypi` and `pypi`.
14
+ Configure a required reviewer for `pypi` where supported. For a solo project,
15
+ allow self-review so you can approve your own releases.
16
+ 3. Create/verify your accounts and enable two-factor authentication on PyPI and
17
+ TestPyPI. They are separate accounts.
18
+ 4. At https://test.pypi.org/manage/account/publishing/, add a pending GitHub publisher:
19
+ - PyPI project name: `chickensmoothie`
20
+ - Owner: `haruyuki`
21
+ - Repository name: `chickensmoothie`
22
+ - Workflow name: `publish.yml` (filename only)
23
+ - Environment name: `testpypi`
24
+ 5. At https://pypi.org/manage/account/publishing/, enter the same values, except
25
+ environment name must be `pypi`.
26
+
27
+ If the project already exists under your account, add the publisher from that
28
+ project's Manage > Publishing page instead. A pending publisher creates the
29
+ project on first upload; it does not reserve the package name.
30
+
31
+ ## Test a release
32
+
33
+ 1. Commit the intended version in `pyproject.toml`; run `uv lock` after changing it.
34
+ 2. Push the commit. Open GitHub Actions > Publish package > Run workflow.
35
+ 3. Select your branch or tag, choose target `testpypi`, and run it.
36
+ 4. Review the successful run and https://test.pypi.org/project/chickensmoothie/.
37
+ 5. Verify installation in a separate local environment:
38
+
39
+ ```powershell
40
+ uv venv .venv-testpypi
41
+ uv pip install --python .venv-testpypi/Scripts/python.exe "aiohttp>=3.13.5" "lxml>=6.0.4" "cssselect>=1.4.0"
42
+ uv pip install --python .venv-testpypi/Scripts/python.exe --index-url https://test.pypi.org/simple/ --no-deps chickensmoothie==0.1.0
43
+ .venv-testpypi/Scripts/python.exe -I -m unittest discover -s tests -v
44
+ ```
45
+
46
+ Dependencies come from real PyPI; only this package comes from TestPyPI.
47
+ Replace `0.1.0` with your release version. Repeating a successful upload of the
48
+ same filenames fails; use a new version for changed releases.
49
+
50
+ ## Publish to PyPI
51
+
52
+ For version `0.1.0`, tag the exact tested commit and push the tag:
53
+
54
+ ```powershell
55
+ git tag v0.1.0
56
+ git push origin v0.1.0
57
+ ```
58
+
59
+ In GitHub Actions > Publish package > Run workflow, select tag `v0.1.0` under
60
+ Use workflow from, choose target `pypi`, and run. Approve the `pypi` environment
61
+ when prompted. The workflow rejects production runs unless the selected tag
62
+ matches `v` plus the version in `pyproject.toml`.
63
+
64
+ The workflow rebuilds the selected commit for each run; TestPyPI and PyPI runs
65
+ are not guaranteed to produce byte-identical archives. Neither ordinary pushes
66
+ nor creating a tag alone publishes anything: publication requires a manual run.
67
+
68
+ After success, users can run `python -m pip install chickensmoothie`.
69
+ For later releases, increase the version, test, create a matching tag, and repeat.
70
+ Already published distribution filenames cannot be reused, even after deletion.
71
+
72
+ ## Local checks (no upload)
73
+
74
+ ```powershell
75
+ uv sync --locked
76
+ uv run python -m unittest discover -s tests -v
77
+ uv build --no-sources
78
+ uvx twine check --strict dist/chickensmoothie-0.1.0-py3-none-any.whl dist/chickensmoothie-0.1.0.tar.gz
79
+ ```
80
+
81
+ Use only the files for the intended release when checking locally.
82
+ GitHub builds start in a fresh checkout and upload only that run's artifacts.
83
+
84
+ ## References
85
+
86
+ - https://docs.pypi.org/trusted-publishers/
87
+ - https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/
88
+ - https://docs.pypi.org/trusted-publishers/using-a-publisher/
@@ -0,0 +1,29 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0.3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "chickensmoothie"
7
+ version = "0.1.0"
8
+ description = "Unofficial async Python client for Chicken Smoothie"
9
+ readme = "README.md"
10
+ license = "ISC"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Haru" }]
13
+ keywords = ["chickensmoothie", "pets", "archive", "asyncio"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "Programming Language :: Python :: 3",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+ requires-python = ">=3.11"
22
+ dependencies = ["aiohttp>=3.13.5", "lxml>=6.0.4", "cssselect>=1.4.0"]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/haruyuki/chickensmoothie"
26
+ Issues = "https://github.com/haruyuki/chickensmoothie/issues"
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ """Reusable asynchronous Chicken Smoothie access."""
2
+
3
+ from .archive import ArchiveEvent, ArchivePage, Item, Pet
4
+ from .client import AuthenticationError, Client, NEWS_FEED_URL
5
+ from .pound import OpeningClosed, OpeningOpen, OpeningStatus
6
+ from .links import is_valid_chickensmoothie_link, parse_item_ids
7
+
8
+
9
+ async def get_pet(pet_id: str, *, years=None) -> Pet | None:
10
+ async with Client() as client:
11
+ return await client.get_pet(pet_id, years=years)
12
+
13
+
14
+ async def get_archive(pet_id: str, *, years=None) -> list[Pet]:
15
+ async with Client() as client:
16
+ return await client.get_archive(pet_id, years=years)
17
+
18
+
19
+ async def get_opening_status() -> OpeningStatus:
20
+ async with Client() as client:
21
+ return await client.get_opening_status()
22
+
23
+
24
+ __all__ = [
25
+ "Client",
26
+ "AuthenticationError",
27
+ "ArchiveEvent",
28
+ "ArchivePage",
29
+ "Pet",
30
+ "Item",
31
+ "OpeningClosed",
32
+ "OpeningOpen",
33
+ "OpeningStatus",
34
+ "NEWS_FEED_URL",
35
+ "get_pet",
36
+ "get_archive",
37
+ "get_opening_status",
38
+ "is_valid_chickensmoothie_link",
39
+ "parse_item_ids",
40
+ ]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+ from urllib.parse import parse_qs, unquote, urlparse
7
+
8
+ import lxml.html
9
+
10
+ MONTHS = {
11
+ "January",
12
+ "February",
13
+ "March",
14
+ "April",
15
+ "May",
16
+ "June",
17
+ "July",
18
+ "August",
19
+ "September",
20
+ "October",
21
+ "November",
22
+ "December",
23
+ }
24
+
25
+ CSS_PET_GROUP = ".archive-pet-tree-container"
26
+ CSS_ITEM_GROUP = ".archive-item-group"
27
+
28
+ EXCEPTIONS = {
29
+ "3B46301A6C8B850D87A730DA365B0960",
30
+ "E5FEFE44A3070BC9FC176503EC1A603F",
31
+ "0C1AFF9AEAA0953F1B1F9B818C2771C9",
32
+ "7C912BA5616D2E24E9F700D90E4BA2B6",
33
+ "905BB7DE4BC4E29D7FD2D1969667B568",
34
+ "773B14EEB416FA762C443D909FFED344",
35
+ "1C0DB4785FC78DF4395263D40261C614",
36
+ "5066110701B0AE95948A158F0B262EBB",
37
+ "5651A6C10C4D375A1901142C49C5C70C",
38
+ "8BED72498D055E55ABCA7AD29B180BF4",
39
+ }
40
+
41
+ Category = Literal["monthly", "special"]
42
+ TableType = Literal["pets", "items"]
43
+
44
+
45
+ @dataclass(slots=True)
46
+ class ArchiveEvent:
47
+ title: str
48
+ link: str
49
+
50
+
51
+ def get_event_title(link: str) -> str:
52
+ pattern = re.compile(r"^/archive/\d{4}/(?P<event>[^/]+)(?:/Items)?/?$")
53
+ match = pattern.match(link)
54
+ if not match:
55
+ return "Unknown Event"
56
+ return unquote(match.group("event"))
57
+
58
+
59
+ def get_category(event_title: str) -> Category:
60
+ return "monthly" if event_title in MONTHS else "special"
61
+
62
+
63
+ def parse_item_ids(image_link: str) -> tuple[int | None, int | None]:
64
+ parsed = urlparse(image_link)
65
+ pattern = re.compile(r"/item/(\d+)&p=(\d+)")
66
+ match = pattern.search(parsed.path)
67
+ if match:
68
+ return int(match.group(1)), int(match.group(2))
69
+
70
+ query = parse_qs(parsed.query)
71
+ left = query.get("lid", [None])[0] or query.get("l", [None])[0]
72
+ right = query.get("rid", [None])[0] or query.get("p", [None])[0]
73
+ if left and right:
74
+ return int(left), int(right)
75
+
76
+ return None, None
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Pet:
81
+ id: str
82
+ year: int
83
+ event: str
84
+ archive_url: str
85
+ image_url: str
86
+
87
+
88
+ @dataclass(frozen=True, slots=True)
89
+ class Item:
90
+ left_id: int
91
+ right_id: int
92
+ name: str
93
+ year: int
94
+ event: str
95
+ archive_url: str
96
+ image_url: str
97
+
98
+
99
+ @dataclass(slots=True)
100
+ class ArchivePage:
101
+ group_count: int
102
+ entries: list[Pet | Item]
103
+
104
+
105
+ def parse_archive(
106
+ text: str, event: ArchiveEvent, table: TableType, year: int
107
+ ) -> ArchivePage:
108
+ from urllib.parse import urljoin
109
+
110
+ if table not in ("pets", "items"):
111
+ raise ValueError("table must be 'pets' or 'items'")
112
+ dom = lxml.html.fromstring(text)
113
+ groups = dom.cssselect(CSS_PET_GROUP if table == "pets" else CSS_ITEM_GROUP)
114
+ entries = []
115
+ groups_per_page = 7 if table == "pets" else 10
116
+ base = event.link.split("?")[0]
117
+ for index, group in enumerate(groups):
118
+ offset = (index // groups_per_page) * groups_per_page
119
+ page = f"{base}?pageStart={offset}" if offset else base
120
+ if table == "pets":
121
+ for image in group.cssselect('img[alt="Pet"][src]'):
122
+ image_url = urljoin(event.link, image.get("src"))
123
+ pet_id = parse_qs(urlparse(image_url).query).get("k", [None])[0]
124
+ if pet_id and pet_id not in EXCEPTIONS:
125
+ entries.append(Pet(pet_id, year, event.title, page, image_url))
126
+ else:
127
+ for item in group.cssselect("li.item"):
128
+ images = item.cssselect("img[src]")
129
+ names = item.cssselect("div.item-name")
130
+ if not images or not names:
131
+ continue
132
+ image_url = urljoin(event.link, images[0].get("src"))
133
+ try:
134
+ left, right = parse_item_ids(image_url)
135
+ except ValueError:
136
+ continue
137
+ if left is not None and right is not None:
138
+ entries.append(
139
+ Item(
140
+ left,
141
+ right,
142
+ names[0].text_content().strip(),
143
+ year,
144
+ event.title,
145
+ page,
146
+ image_url,
147
+ )
148
+ )
149
+ return ArchivePage(len(groups), entries)
@@ -0,0 +1,132 @@
1
+ """HTTP access, with explicit session ownership and no application configuration."""
2
+
3
+ from datetime import datetime, timezone
4
+ from collections.abc import Iterable
5
+ from urllib.parse import urljoin, urlparse
6
+
7
+ import aiohttp
8
+ import lxml.html
9
+
10
+ from .archive import ArchiveEvent, Pet, TableType, get_event_title, parse_archive
11
+ from .pound import parse_opening_status
12
+
13
+ BASE_URL = "https://www.chickensmoothie.com"
14
+ NEWS_FEED_URL = "https://chickensmoothie.com/rss.php?feed=news"
15
+
16
+
17
+ class AuthenticationError(Exception):
18
+ """Chicken Smoothie did not accept the supplied credentials."""
19
+
20
+
21
+ class Client:
22
+ """Use as an async context manager; an injected session remains caller-owned."""
23
+
24
+ def __init__(self, session: aiohttp.ClientSession | None = None):
25
+ self._session = session
26
+ self._owns_session = session is None
27
+ self.username = None
28
+
29
+ async def __aenter__(self):
30
+ if self._session is None:
31
+ self._session = aiohttp.ClientSession(
32
+ headers={"User-Agent": "chickensmoothie-python/0.1.0"},
33
+ timeout=aiohttp.ClientTimeout(total=60),
34
+ )
35
+ if self._session.closed:
36
+ raise RuntimeError("Client session is closed")
37
+ return self
38
+
39
+ async def __aexit__(self, *args):
40
+ await self.close()
41
+
42
+ async def close(self):
43
+ if self._owns_session and self._session is not None:
44
+ await self._session.close()
45
+ self._session = None
46
+
47
+ def _require_session(self):
48
+ if self._session is None or self._session.closed:
49
+ raise RuntimeError("Use Client inside 'async with Client()'")
50
+ return self._session
51
+
52
+ async def _text(self, path):
53
+ url = urljoin(BASE_URL, path)
54
+ if urlparse(url).netloc != urlparse(BASE_URL).netloc:
55
+ raise ValueError("Expected a Chicken Smoothie URL")
56
+ async with self._require_session().get(url) as response:
57
+ response.raise_for_status()
58
+ return await response.text()
59
+
60
+ async def login(self, username: str, password: str) -> None:
61
+ if not username or not password:
62
+ raise ValueError("username and password are required")
63
+ payload = {
64
+ "username": username,
65
+ "password": password,
66
+ "redirect": "index.php",
67
+ "autologin": "on",
68
+ "login": "Login",
69
+ }
70
+ async with self._require_session().post(
71
+ BASE_URL + "/Forum/ucp.php?mode=login", data=payload
72
+ ) as response:
73
+ response.raise_for_status()
74
+ dom = lxml.html.fromstring(await response.text())
75
+ messages = dom.xpath('//div[@id="message"]//p/text()')
76
+ if not messages or messages[0] != "You have been successfully logged in.":
77
+ raise AuthenticationError("ChickenSmoothie login failed.")
78
+ self.username = username
79
+
80
+ async def is_logged_in(self, username: str | None = None) -> bool:
81
+ dom = lxml.html.fromstring(await self._text("/"))
82
+ labels = dom.xpath('//li[@class="icon-logout"]/a/text()')
83
+ expected = username or self.username
84
+ return bool(labels and (expected is None or expected in labels[0]))
85
+
86
+ async def get_opening_status(self):
87
+ return parse_opening_status(await self._text("/poundandlostandfound.php"))
88
+
89
+ async def get_archive_events(self, year: int, table: TableType = "pets"):
90
+ if table not in ("pets", "items"):
91
+ raise ValueError("table must be 'pets' or 'items'")
92
+ if not isinstance(year, int) or year < 2008:
93
+ raise ValueError("year must be an integer from 2008 onward")
94
+ path = f"/archive/{year}/" + ("Items/" if table == "items" else "")
95
+ dom = lxml.html.fromstring(await self._text(path))
96
+ return [
97
+ ArchiveEvent(
98
+ get_event_title(urlparse(a.get("href")).path),
99
+ urljoin(BASE_URL, a.get("href")),
100
+ )
101
+ for a in dom.cssselect("li.event > a[href]")
102
+ ]
103
+
104
+ async def get_archive_event(self, event: ArchiveEvent, table: TableType, year: int):
105
+ text = await self._text(event.link.split("?")[0] + "?pageSize=3000")
106
+ return parse_archive(text, event, table, year)
107
+
108
+ async def get_archive(
109
+ self, pet_id: str, *, years: Iterable[int] | None = None
110
+ ) -> list[Pet]:
111
+ """Find all archive records for an image's k ID."""
112
+ return [entry async for entry in self._iter_pet_matches(pet_id, years)]
113
+
114
+ async def _iter_pet_matches(self, pet_id: str, years: Iterable[int] | None):
115
+ if not isinstance(pet_id, str) or not pet_id.strip():
116
+ raise ValueError("pet_id must be a nonempty image k ID")
117
+ if years is None:
118
+ years = range(2008, datetime.now(timezone.utc).year + 1)
119
+ for year in dict.fromkeys(years):
120
+ for event in await self.get_archive_events(year):
121
+ page = await self.get_archive_event(event, "pets", year)
122
+ for entry in page.entries:
123
+ if isinstance(entry, Pet) and entry.id == pet_id:
124
+ yield entry
125
+
126
+ async def get_pet(
127
+ self, pet_id: str, *, years: Iterable[int] | None = None
128
+ ) -> Pet | None:
129
+ """Return the first archive record for an image k ID, or None."""
130
+ async for entry in self._iter_pet_matches(pet_id, years):
131
+ return entry
132
+ return None
@@ -0,0 +1,12 @@
1
+ from urllib.parse import urlparse
2
+ from .archive import parse_item_ids
3
+
4
+
5
+ def is_valid_chickensmoothie_link(link: str) -> bool:
6
+ parsed = urlparse(link)
7
+ return parsed.scheme in {"http", "https"} and parsed.hostname in {
8
+ "www.chickensmoothie.com",
9
+ "chickensmoothie.com",
10
+ "chickencdn.com",
11
+ "static.chickensmoothie.com",
12
+ }
@@ -0,0 +1,79 @@
1
+ import re
2
+ import math
3
+ from dataclasses import dataclass
4
+
5
+ import lxml.html
6
+
7
+
8
+ @dataclass
9
+ class OpeningClosed:
10
+ is_open: bool = False
11
+ event_type: str | None = None
12
+ remaining_minutes: int | None = None
13
+
14
+
15
+ @dataclass
16
+ class OpeningOpen:
17
+ is_open: bool = True
18
+ event_type: str | None = ""
19
+ remaining_count: int = 0
20
+
21
+
22
+ OpeningStatus = OpeningClosed | OpeningOpen
23
+
24
+
25
+ def parse_opening_status(text: str) -> OpeningStatus:
26
+ dom = lxml.html.fromstring(text)
27
+
28
+ event_type = get_event_type(
29
+ normalise(get_first_text(dom, '//div[@id="csbody"]//h2[1]/text()'))
30
+ )
31
+ has_pick_countdown = bool(dom.xpath('//*[@id="pound-pick-countdown"]'))
32
+
33
+ if has_pick_countdown:
34
+ remaining_count = extract_remaining_count(dom, event_type)
35
+ return OpeningOpen(
36
+ is_open=True, event_type=event_type, remaining_count=remaining_count
37
+ )
38
+
39
+ if match := re.search(r'"timeTillOpen_ms"\s*:\s*(\d+)', text):
40
+ remaining_ms = int(match.group(1))
41
+ return OpeningClosed(
42
+ is_open=False,
43
+ event_type=event_type,
44
+ remaining_minutes=max(0, math.floor(remaining_ms / 60000)),
45
+ )
46
+
47
+ return OpeningClosed(
48
+ is_open=False, event_type=None, remaining_minutes=None
49
+ ) # If both are closed
50
+
51
+
52
+ def normalise(value: str) -> str:
53
+ return " ".join(value.split())
54
+
55
+
56
+ def get_first_text(dom: lxml.html.HtmlElement, xpath: str) -> str:
57
+ values = dom.xpath(xpath)
58
+ return str(values[0]) if values else ""
59
+
60
+
61
+ def get_event_type(text: str) -> str | None:
62
+ lowered = text.lower()
63
+ if "lost and found" in lowered:
64
+ return "Lost and Found"
65
+ if "pound" in lowered:
66
+ return "Pound"
67
+ return None
68
+
69
+
70
+ def extract_remaining_count(dom: lxml.html.HtmlElement, event: str | None) -> int:
71
+ if event == "Pound":
72
+ remaining_text = normalise(dom.xpath('string(//*[@id="pets-remaining"])'))
73
+ else: # Lost and Found
74
+ remaining_text = normalise(dom.xpath('string(//*[@id="items-remaining"])'))
75
+
76
+ match = re.search(r"\d[\d,]*", remaining_text)
77
+ if match:
78
+ return int(match.group().replace(",", ""))
79
+ return 0
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: chickensmoothie
3
+ Version: 0.1.0
4
+ Summary: Unofficial async Python client for Chicken Smoothie
5
+ Author: Haru
6
+ License-Expression: ISC
7
+ Project-URL: Repository, https://github.com/haruyuki/chickensmoothie
8
+ Project-URL: Issues, https://github.com/haruyuki/chickensmoothie/issues
9
+ Keywords: chickensmoothie,pets,archive,asyncio
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Operating System :: OS Independent
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: aiohttp>=3.13.5
19
+ Requires-Dist: lxml>=6.0.4
20
+ Requires-Dist: cssselect>=1.4.0
21
+ Dynamic: license-file
22
+
23
+ # chickensmoothie
24
+
25
+ Unofficial standalone asynchronous client for Chicken Smoothie. Requires Python 3.11+.
26
+ Not affiliated with or endorsed by Chicken Smoothie.
27
+ No Discord, database, environment variables, or bot startup dependencies.
28
+
29
+ ## Installation
30
+
31
+ From a local checkout:
32
+
33
+ ```powershell
34
+ uv sync
35
+ ```
36
+
37
+ Or install with pip: `python -m pip install .`.
38
+ After the first PyPI release, users can install with `python -m pip install chickensmoothie`.
39
+
40
+ ## Usage
41
+
42
+ ```python
43
+ import asyncio
44
+ import chickensmoothie
45
+
46
+ async def main():
47
+ pet = await chickensmoothie.get_pet("IMAGE_K_ID", years=[2026])
48
+ matches = await chickensmoothie.get_archive("IMAGE_K_ID", years=[2026])
49
+ async with chickensmoothie.Client() as client:
50
+ # Login is needed for account-dependent opening information.
51
+ await client.login("username", "password")
52
+ status = await client.get_opening_status()
53
+ events = await client.get_archive_events(2026, "pets")
54
+ page = await client.get_archive_event(events[0], "pets", 2026)
55
+ for entry in page.entries:
56
+ print(entry)
57
+
58
+ asyncio.run(main())
59
+ ```
60
+
61
+ All network methods are async. `get_pet` returns the first matching `Pet` or
62
+ `None`; `get_archive` returns all matching `Pet` archive records. IDs refer to
63
+ image URL `k` values, as used by the bot's identification database, not numeric
64
+ IDs for individually owned pets. Individual pet profiles are not supported.
65
+ Without `years`, lookups scan archives from 2008 through the current UTC year;
66
+ this can make many requests. Restrict years when possible. The bot retains its
67
+ SQLite index for fast identification instead of scanning on every command.
68
+
69
+ `get_archive_events(year, "items")` and `get_archive_event(event, "items", year)`
70
+ return item records, including both image IDs, name, year, event and URLs.
71
+ Archive extraction retains the bot's known duplicate-pet exclusions and
72
+ `pageSize=3000` behavior. Events beyond that server response limit are not paged.
73
+
74
+ Use one `Client` context for repeated calls. `Client(session)` accepts an
75
+ existing aiohttp session and never closes it. Credentials are explicit.
76
+ HTTP failures propagate as aiohttp exceptions; failed login raises
77
+ `AuthenticationError`. Missing archive matches return empty results, while
78
+ HTTP errors are never treated as missing pets.
79
+
80
+ The bot owns SQLite/MongoDB storage, RSS feed caching, reminder scheduling,
81
+ Discord formatting and image uploads. `NEWS_FEED_URL` exposes the news source
82
+ for other applications to use with their preferred RSS reader.
83
+
84
+ ## Development
85
+
86
+ Run offline regression tests: `uv run python -m unittest discover -s tests -v`.
87
+ Build release files: `uv build --no-sources`.
88
+ See [RELEASING.md](RELEASING.md) for validation and PyPI upload instructions.
89
+
90
+ ## License
91
+
92
+ ISC. See [LICENSE](LICENSE). This license applies to the package code, not to
93
+ Chicken Smoothie's images, content, or branding.
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ MANIFEST.in
3
+ README.md
4
+ RELEASING.md
5
+ pyproject.toml
6
+ src/chickensmoothie/__init__.py
7
+ src/chickensmoothie/archive.py
8
+ src/chickensmoothie/client.py
9
+ src/chickensmoothie/links.py
10
+ src/chickensmoothie/pound.py
11
+ src/chickensmoothie.egg-info/PKG-INFO
12
+ src/chickensmoothie.egg-info/SOURCES.txt
13
+ src/chickensmoothie.egg-info/dependency_links.txt
14
+ src/chickensmoothie.egg-info/requires.txt
15
+ src/chickensmoothie.egg-info/top_level.txt
16
+ tests/test_chickensmoothie.py
@@ -0,0 +1,3 @@
1
+ aiohttp>=3.13.5
2
+ lxml>=6.0.4
3
+ cssselect>=1.4.0
@@ -0,0 +1 @@
1
+ chickensmoothie
@@ -0,0 +1,105 @@
1
+ import unittest
2
+ from unittest.mock import AsyncMock, MagicMock, patch
3
+
4
+ import aiohttp
5
+ from chickensmoothie import Client, AuthenticationError, Pet
6
+ from chickensmoothie.archive import ArchiveEvent, parse_archive, parse_item_ids
7
+ from chickensmoothie.pound import parse_opening_status
8
+
9
+
10
+ class ParsingTests(unittest.TestCase):
11
+ def test_opening_states(self):
12
+ open_state = parse_opening_status(
13
+ '<div id="csbody"><h2>Pound</h2></div><span id="pound-pick-countdown"></span><b id="pets-remaining">1,234 pets</b>'
14
+ )
15
+ self.assertTrue(open_state.is_open)
16
+ self.assertEqual(open_state.remaining_count, 1234)
17
+ closed = parse_opening_status(
18
+ '<div id="csbody"><h2>Lost and Found</h2></div><script>{"timeTillOpen_ms": 119999}</script>'
19
+ )
20
+ self.assertFalse(closed.is_open)
21
+ self.assertEqual(closed.remaining_minutes, 1)
22
+ self.assertIsNone(parse_opening_status("<html>Closed</html>").event_type)
23
+
24
+ def test_pet_page_links_and_missing_ids(self):
25
+ group = '<div class="archive-pet-tree-container"><img alt="Pet" src="/pet.php?k=ABC"><img alt="Pet" src="/invalid"></div>'
26
+ page = parse_archive(
27
+ group * 8,
28
+ ArchiveEvent(
29
+ "January", "https://www.chickensmoothie.com/archive/2026/January/"
30
+ ),
31
+ "pets",
32
+ 2026,
33
+ )
34
+ self.assertEqual(page.group_count, 8)
35
+ self.assertEqual(len(page.entries), 8)
36
+ self.assertTrue(page.entries[-1].archive_url.endswith("?pageStart=7"))
37
+ self.assertEqual(page.entries[0].id, "ABC")
38
+
39
+ def test_items(self):
40
+ html = '<div class="archive-item-group"><li class="item"><img src="https://chickencdn.com/item/12&amp;p=34"><div class="item-name"> Hat </div></li><li class="item"></li></div>'
41
+ page = parse_archive(
42
+ html,
43
+ ArchiveEvent(
44
+ "January", "https://www.chickensmoothie.com/archive/2026/January/Items/"
45
+ ),
46
+ "items",
47
+ 2026,
48
+ )
49
+ self.assertEqual(len(page.entries), 1)
50
+ self.assertEqual(
51
+ (page.entries[0].left_id, page.entries[0].right_id, page.entries[0].name),
52
+ (12, 34, "Hat"),
53
+ )
54
+ self.assertEqual(
55
+ parse_item_ids("https://example.com/item.php?lid=12&rid=34"), (12, 34)
56
+ )
57
+
58
+
59
+ class ClientTests(unittest.IsolatedAsyncioTestCase):
60
+ async def test_session_ownership(self):
61
+ session = MagicMock(closed=False)
62
+ session.close = AsyncMock()
63
+ async with Client(session):
64
+ pass
65
+ session.close.assert_not_awaited()
66
+ with patch(
67
+ "chickensmoothie.client.aiohttp.ClientSession", return_value=session
68
+ ):
69
+ async with Client():
70
+ pass
71
+ session.close.assert_awaited_once()
72
+
73
+ async def test_http_failure_propagates(self):
74
+ session = MagicMock(closed=False)
75
+ response = MagicMock()
76
+ response.raise_for_status.side_effect = aiohttp.ClientError("failure")
77
+ session.get.return_value.__aenter__ = AsyncMock(return_value=response)
78
+ with self.assertRaises(aiohttp.ClientError):
79
+ await Client(session).get_archive_events(2026)
80
+
81
+ async def test_login_failure(self):
82
+ session = MagicMock(closed=False)
83
+ response = MagicMock()
84
+ response.text = AsyncMock(
85
+ return_value='<div id="message"><p>Wrong password</p></div>'
86
+ )
87
+ session.post.return_value.__aenter__ = AsyncMock(return_value=response)
88
+ with self.assertRaises(AuthenticationError):
89
+ await Client(session).login("user", "password")
90
+
91
+ async def test_archive_lookup(self):
92
+ client = Client()
93
+ event = ArchiveEvent(
94
+ "January", "https://www.chickensmoothie.com/archive/2026/January/"
95
+ )
96
+ page = parse_archive(
97
+ '<div class="archive-pet-tree-container"><img alt="Pet" src="/pet.php?k=ABC"></div>',
98
+ event,
99
+ "pets",
100
+ 2026,
101
+ )
102
+ client.get_archive_events = AsyncMock(return_value=[event])
103
+ client.get_archive_event = AsyncMock(return_value=page)
104
+ self.assertIsInstance(await client.get_pet("ABC", years=[2026]), Pet)
105
+ self.assertIsNone(await client.get_pet("missing", years=[2026]))