repo: push

This commit is contained in:
2024-12-10 02:03:53 +07:00
commit 075a73145b
11 changed files with 493 additions and 0 deletions

13
.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Akademiya
books/
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.13

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"python.analysis.autoImportCompletions": true
}

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 tretrauit
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

20
README.md Normal file
View File

@ -0,0 +1,20 @@
# Akademiya
Unofficial API client written in Python for a certain book library.
## Installation
```bash
pip install git+https://git.tretrauit.me/tretrauit/akademiya
```
## Features
+ [x] Authentication
+ [x] Look up books
+ [x] Borrow books
+ [x] Download (encrypted) books
## License
[MIT](./LICENSE)

4
akademiya/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from akademiya.client import Client
__version__ = "0.1.0"
__all__ = ["Client"]

116
akademiya/__main__.py Normal file
View File

@ -0,0 +1,116 @@
from getpass import getpass
from pathlib import Path
from akademiya import __version__, Client
from traceback import print_exc
# Import readline if available (Unix-like systems)
try:
import readline # noqa: F401
except ImportError:
pass
base_path = Path("books")
base_path.mkdir(exist_ok=True)
def lookup_books(client: Client, name: str):
print(f"Looking up books with the name '{name}'...")
books = client.lookup_books(name)
for book in books:
print(f"Book: {book.product_title} (ID: {book.ID})")
print(f" + Author: {book.creator_names}")
def download_book(client: Client, book_id: int):
print(f"Fetching book with ID {book_id}...")
book = client.get_book_info(book_id)
print(f"Downloading book '{book.product_title}'...")
pdf_bytes = client.get_book_pdf(book)
if pdf_bytes is None:
print("The server didn't return any PDF data.")
return
pdf_path = base_path / f"{book.ID}.pdf"
with pdf_path.open("wb") as f:
f.write(pdf_bytes)
print(f"Book downloaded to '{pdf_path.resolve()}'")
def borrow_book(client: Client, book_id: int):
print(f"Borrowing book with ID {book_id}...")
if client.is_borrowing(book_id=book_id):
print("You are already borrowing this book.")
return
client.borrow_book(book_id)
print("Book borrowed successfully.")
def user_info(client: Client):
print(f"User ID: {client.user.id}")
print(f"Username: {client.user.username}")
print(f"Full Name: {client.user.full_name}")
print(f"Avatar URL: {client.user.avatar_url}")
print(f"Token: {client.user.token}")
def main():
print(
f"Akademiya CLI v{__version__} - https://git.tretrauit.me/tretrauit/akademiya"
)
print("Welcome to Akademiya CLI, an unofficial client for a book library.")
print(
"This client is no way affiliated with the official client itself, and should be used for educational purposes only."
)
client = Client()
print()
print("Authorization is required to access the library.")
username = input("Username: ")
password = getpass("Password (doesn't show on the console): ")
try:
client.login(username, password)
except Exception as e:
print(f"An error occurred while logging in: {e}")
return
print(f"Logged in as {client.user.full_name} (ID: {client.user.id})")
print("Type 'help' for a list of available commands.")
# Very simple command handling but idgaf.
while True:
try:
command_str = input("> ")
base_command = command_str.split(" ", 1)[0]
match base_command:
case "exit":
print("Exiting...")
break
case "help":
print("Available commands:")
print("exit - Exits the program")
print("help - Shows this help message")
print("lookup <book name> - Lookup books by name")
print("download <book ID> - Download a book by ID")
print("borrow <book ID> - Borrow a book by ID")
print("userinfo - Show the current user information")
case "lookup":
_, name = command_str.split(" ", 1)
lookup_books(client, name)
case "download":
_, book_id = command_str.split(" ", 1)
download_book(client, int(book_id))
case "borrow":
_, book_id = command_str.split(" ", 1)
borrow_book(client, int(book_id))
case "userinfo":
user_info(client)
case _:
print(
"Unknown command, type 'help' for a list of available commands."
)
except KeyboardInterrupt:
print("Exiting...")
break
except Exception:
print("An error occurred while executing the provided command.")
print_exc()
if __name__ == "__main__":
main()

224
akademiya/client.py Normal file
View File

@ -0,0 +1,224 @@
import inspect
import requests
from akademiya import constants
from dataclasses import dataclass
from urllib.parse import quote
@dataclass
class BookPdf:
"""
The PDF file of the book.
Only useful fields are covered, the rest are ignored.
"""
FileTitle: str
ID: int
Position: int
Type: str
Url: str
@classmethod
def from_dict(self, data: dict) -> "Book":
# Ty https://stackoverflow.com/a/55096964
return self(
**{k: v for k, v in data.items() if k in inspect.signature(self).parameters}
)
@dataclass
class Book:
"""
The product, or in our case the book.
Only useful fields are covered, the rest are ignored.
"""
Author: str | None
CoverPicName: str
ID: int
Status: int
# Category fields
category_id: int
category_name: str | None
category_type: str | None
collection_name: int
creator_names: str | None
# Product fields
product_code: str | None
product_cover: str
product_description: str | None
product_pdf: list[BookPdf] | None
product_title: str
publisher_name: str
@classmethod
def from_dict(self, data: dict) -> "Book":
# Ty https://stackoverflow.com/a/55096964
return_val = self(
**{k: v for k, v in data.items() if k in inspect.signature(self).parameters}
)
if (data["product_pdf"]) is not None:
return_val.product_pdf = [
BookPdf.from_dict(pdf) for pdf in data["product_pdf"]
]
return return_val
@dataclass
class User:
full_name: str
id: int
avatar_url: str
token: str
username: str
class Client:
"""
Client for interacting with the book library server.
"""
def __init__(self):
self._session = requests.Session()
# Update User-Agent to mask as the official client (although it's not necessary)
self._session.headers.update({"User-Agent": constants.USER_AGENT})
self._app_version: float = None
self.user: User = None
self.set_app_version()
def borrow_book(self, book_id: int):
rsp = self._session.get(
f"{constants.ENDPOINT}/API/cbs/User/Payment/{self.user.token}/{book_id}/{constants.MAGIC_KEY}/1/1/"
)
rsp.raise_for_status()
result = rsp.json()["BookBorrowsResult"]
if result["Message"] == "Mượn sách thành công.":
return
raise Exception(f"Failed to borrow book: {result['Message']}")
def get_book_info(self, book_id: int):
rsp = self._session.get(
f"{constants.ENDPOINT}/API/cbs/products/getinfo.json/{book_id}/1/"
)
rsp.raise_for_status()
# GetProductByIDResult is the root
result = rsp.json()["GetProductByIDResult"]
if result["ValidateMessage"]["Message"] != "Lấy dữ liệu thành công":
raise Exception(f"Lookup failed: {result['ValidateMessage']['Message']}")
return Book.from_dict(result)
def get_book_pdf(self, book: Book):
"""
Downloads the PDF of the given book.
Args:
book (Book): The book to download the PDF from.
Returns:
bytes: The PDF data if successful, None otherwise.
"""
if book.product_pdf is None:
return None
if not self.is_borrowing(book.ID):
raise Exception("You must borrow the book before downloading the PDF.")
pdf = book.product_pdf[0]
rsp = self._session.get(
f"{pdf.Url}{book.product_code}/{constants.MAGIC_KEY}/APPWINDOW.1.0/{self.user.token}/{self.user.username}/1/1/"
)
rsp.raise_for_status()
# Comment so Pylance can stfu
if not isinstance(rsp.content, bytes):
raise Exception("Expected bytes, got something else.")
try:
data = rsp.json()
raise Exception(f"Failed to get PDF: {data['ValidateMessage']['Message']}")
except Exception:
pass
return rsp.content
def is_borrowing(self, book_id: int) -> bool:
"""
Checks if the user is borrowing a book.
Args:
book_id (int): The ID of the book to check.
Returns:
bool: True if the user is borrowing the book, False otherwise."""
rsp = self._session.get(
f"{constants.ENDPOINT}/API/cbs/User/Payment/Date/{self.user.username}/{book_id}/"
)
rsp.raise_for_status()
result = rsp.json()["BorrowsDateResult"]
if result["ValidateMessage"]["Message"] == "Sách chưa mượn hoặc đã trả!":
return False
return True
def login(self, username: str, password: str):
"""
Logs in the user with the given credentials.
You must call this method before calling any other methods.
Args:
username (str): The username.
password (str): The password.
"""
# Why did they put the username & password in the URL? :)
rsp = self._session.get(
f"{constants.ENDPOINT}/Api/loginrs/{username}/{password}/WINDOWS/"
)
rsp.raise_for_status()
data = rsp.json()
result = data["GetPatronByAccountResult"]
if result["ValidateMessage"]["Message"] != "Đăng nhập thành công!":
raise Exception(f"Login failed: {result['ValidateMessage']['Message']}")
self.user = User(
full_name=result["FullName"],
id=result["Id"],
avatar_url=result["ImageUrl"],
token=result["token"],
username=username,
)
def lookup_books(self, name: str):
"""
Looks up books by name.
Although this method provides book information, if you need more detailed information for a
specific book then you should use `get_book_info` along with the given book ID.
Args:
name (str): The name to search for.
Returns:
list[Product]: A list of products (books) that match the search query.
"""
# What in the fuck is /0/0/1/20/1 :)
rsp = self._session.get(
f"{constants.ENDPOINT}/API/cbs/products/Search/{quote(name)}/0/0/1/20/1/{self.user.token}/"
)
rsp.raise_for_status()
# SearchEngResult is the root
result = rsp.json()["SearchEngResult"]
if result["ValidateMessage"]["Message"] != "Tra cứu dữ liệu thành công":
raise Exception(f"Lookup failed: {result['ValidateMessage']['Message']}")
products: list[Book] = []
for product_dict in result["ProductList"]:
product = Book.from_dict(product_dict)
products.append(product)
return products
def set_app_version(self):
"""
Sets the app version to the latest version available on the server.
This method is called automatically when the client is created.
"""
rsp = self._session.get(
f"{constants.ENDPOINT}/API/cbs/products/getversionapp/windows/"
)
rsp.raise_for_status()
self._app_version = float(rsp.json()["Result"])

3
akademiya/constants.py Normal file
View File

@ -0,0 +1,3 @@
USER_AGENT = "Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.2; WOW64; Trident/7.0; .NET4.0C; .NET4.0E; .NET CLR 2.0.50727; .NET CLR 3.0.30729; .NET CLR 3.5.30729; Tablet PC 2.0)"
ENDPOINT = "http://bookworm.vnu.edu.vn"
MAGIC_KEY = "CA72dacfce21b55c"

9
pyproject.toml Normal file
View File

@ -0,0 +1,9 @@
[project]
name = "akademiya"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.32.3",
]

79
uv.lock generated Normal file
View File

@ -0,0 +1,79 @@
version = 1
requires-python = ">=3.13"
[[package]]
name = "akademiya"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "requests" },
]
[package.metadata]
requires-dist = [{ name = "requests", specifier = ">=2.32.3" }]
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 },
{ url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 },
{ url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 },
{ url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 },
{ url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 },
{ url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 },
{ url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 },
{ url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 },
{ url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 },
{ url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 },
{ url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 },
{ url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 },
{ url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 },
{ url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 },
{ url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 },
{ url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "urllib3"
version = "2.2.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 },
]