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.
- chickensmoothie-0.1.0/LICENSE +15 -0
- chickensmoothie-0.1.0/MANIFEST.in +1 -0
- chickensmoothie-0.1.0/PKG-INFO +93 -0
- chickensmoothie-0.1.0/README.md +71 -0
- chickensmoothie-0.1.0/RELEASING.md +88 -0
- chickensmoothie-0.1.0/pyproject.toml +29 -0
- chickensmoothie-0.1.0/setup.cfg +4 -0
- chickensmoothie-0.1.0/src/chickensmoothie/__init__.py +40 -0
- chickensmoothie-0.1.0/src/chickensmoothie/archive.py +149 -0
- chickensmoothie-0.1.0/src/chickensmoothie/client.py +132 -0
- chickensmoothie-0.1.0/src/chickensmoothie/links.py +12 -0
- chickensmoothie-0.1.0/src/chickensmoothie/pound.py +79 -0
- chickensmoothie-0.1.0/src/chickensmoothie.egg-info/PKG-INFO +93 -0
- chickensmoothie-0.1.0/src/chickensmoothie.egg-info/SOURCES.txt +16 -0
- chickensmoothie-0.1.0/src/chickensmoothie.egg-info/dependency_links.txt +1 -0
- chickensmoothie-0.1.0/src/chickensmoothie.egg-info/requires.txt +3 -0
- chickensmoothie-0.1.0/src/chickensmoothie.egg-info/top_level.txt +1 -0
- chickensmoothie-0.1.0/tests/test_chickensmoothie.py +105 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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&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]))
|