commit 075a73145bdca807b3e921128a332b2a09e11ad8 Author: Nguyễn Thế Hưng Date: Tue Dec 10 02:03:53 2024 +0700 repo: push diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a530e14 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Akademiya +books/ + +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b881eff --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.autoImportCompletions": true +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f367b9c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3c379a --- /dev/null +++ b/README.md @@ -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) diff --git a/akademiya/__init__.py b/akademiya/__init__.py new file mode 100644 index 0000000..3ef9ad4 --- /dev/null +++ b/akademiya/__init__.py @@ -0,0 +1,4 @@ +from akademiya.client import Client + +__version__ = "0.1.0" +__all__ = ["Client"] diff --git a/akademiya/__main__.py b/akademiya/__main__.py new file mode 100644 index 0000000..5195df7 --- /dev/null +++ b/akademiya/__main__.py @@ -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 - Lookup books by name") + print("download - Download a book by ID") + print("borrow - 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() diff --git a/akademiya/client.py b/akademiya/client.py new file mode 100644 index 0000000..173ef87 --- /dev/null +++ b/akademiya/client.py @@ -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"]) diff --git a/akademiya/constants.py b/akademiya/constants.py new file mode 100644 index 0000000..f94022e --- /dev/null +++ b/akademiya/constants.py @@ -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" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..bbdbe5a --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3cfe7ca --- /dev/null +++ b/uv.lock @@ -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 }, +]