├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── docs ├── api │ ├── clients │ │ ├── abc.md │ │ ├── clients.md │ │ └── http_client.md │ ├── errors.md │ ├── index.md │ ├── models │ │ ├── base.md │ │ ├── books.md │ │ ├── http.md │ │ ├── pages.md │ │ ├── posts.md │ │ ├── tags.md │ │ └── users.md │ ├── paginators │ │ ├── abc.md │ │ └── paginators.md │ ├── types.md │ └── utils.md ├── authorization.md ├── clients │ ├── ai-client.md │ ├── assets │ │ └── restriction.png │ ├── book-client.md │ ├── index.md │ ├── post-client.md │ ├── tag-client.md │ └── user-client.md ├── icon.png ├── index.md └── logo.png ├── mkdocs.yml ├── pyproject.toml ├── requirements-dev.txt ├── requirements-docs.txt ├── requirements-socks.txt ├── requirements.txt ├── sankaku ├── __init__.py ├── clients │ ├── __init__.py │ ├── abc.py │ ├── clients.py │ └── http_client.py ├── constants.py ├── errors.py ├── models │ ├── __init__.py │ ├── base.py │ ├── books.py │ ├── http.py │ ├── pages.py │ ├── posts.py │ ├── tags.py │ └── users.py ├── paginators │ ├── __init__.py │ ├── abc.py │ └── paginators.py ├── typedefs.py ├── types.py └── utils.py ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── models ├── __init__.py ├── test_books.py ├── test_posts.py ├── test_tags.py └── test_users.py ├── test_clients.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual env 2 | virtualenv/ 3 | venv*/ 4 | 5 | # Cache 6 | *.pyc 7 | .idea/ 8 | __pycache__/ 9 | *.sh 10 | .mypy_cache/ 11 | .pytest_cache/ 12 | .ruff_cache/ 13 | *.log 14 | .logs/ 15 | 16 | # Tests 17 | test.py 18 | 19 | # Packaging 20 | *.egg-info/ 21 | dist/ 22 | build/ 23 | 24 | # mkdocs 25 | /site 26 | 27 | .python-version 28 | .env 29 | .secrets 30 | .coverage 31 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to sankaku 2 | 3 | All contributions to sankaku library are welcome. Below will be described 4 | contribution guidelines. 5 | 6 | ## Reporting a bug 7 | 8 | When reporting a bug, please do the following: 9 | 10 | - Include the code, which caused the bug to happen; 11 | - Describe what you expected to happen; 12 | - Describe what actually happened; 13 | - Provide a stacktrace with bug information. 14 | 15 | ## Fixing bugs, adding new features, etc. 16 | 17 | Before sending your changes to `main` branch of repository, please do the 18 | following things: 19 | 20 | - Create a fork of sankaku repository; 21 | - Create a branch from `main`; 22 | - Install additional dependencies to your project path via `pip install sankaku[dev]`; 23 | - Develop bug fixes, new features, etc. 24 | - Make sure that your changes are compatible with Python 3.8+; 25 | 26 | If you fixed a bug, developed a new feature, etc: 27 | 28 | - Run library tests with pytests: `pytest -svv tests` 29 | - Run static type checking with pyright: `pyright sankaku`; 30 | - Run linting with ruff: `ruff sankaku`; 31 | 32 | > It is allowed to suppress some error messages from ruff and pyright in cases 33 | when you're sure that such errors are unnecessary or incorrect, but in general 34 | code should be formatted according to these messages. 35 | 36 | After all test cases, static type checks and linting passed, please check 37 | 'Code style guide' section and apply changes to your code using `yapf` or on 38 | your own. 39 | 40 | Send a pull request with changes to `main` repository of sankaku. 41 | 42 | ## Code style guide 43 | 44 | - PEP8 should be followed as much as possible; 45 | - Preferable line length is 79, but it is allowed to write code up to 88 symbols 46 | in one line in cases when splitting code on separate lines looks 'ugly'; 47 | - All objects defined as public in module, should be declared in `__all__`; 48 | - All public functions and methods should have docstrings; 49 | - All public classes should have docstrings as well, either in `__init__` method 50 | (if it is presented) or after class declaration otherwise; 51 | - All functions and methods should have type hints. 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 zerex290 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Sankaku Complex 5 | 6 |
sankaku
7 |

8 |

For real men of culture

9 | 10 | ## About 11 | 12 | Asynchronous API wrapper for [Sankaku Complex](https://beta.sankakucomplex.com) 13 | with *type-hinting*, pydantic *data validation* and an optional *logging support* 14 | with loguru. 15 | 16 | ### Features 17 | 18 | - Type-hints 19 | - Deserialization of raw json data thanks to pydantic models 20 | - Enumerations for API request parameters to provide better user experience 21 | 22 | ### Useful links 23 | 24 | - [Documentation](https://zerex290.github.io/sankaku) 25 | - [API Reference](https://zerex290.github.io/sankaku/api) 26 | - [Source code](https://github.com/zerex290/sankaku) 27 | 28 | ## Requirements 29 | 30 | - Python 3.8+ 31 | - aiohttp 32 | - pydantic 33 | - loguru 34 | - aiohttp-retry 35 | - typing_extensions; python_version < '3.10' 36 | 37 | ## Installation 38 | 39 | ### Installation with pip 40 | 41 | To install sankaku via pip write following line of code in your terminal: 42 | 43 | ```commandline 44 | pip install sankaku 45 | ``` 46 | 47 | ### Installation with Docker 48 | 49 | To install the sankaku via Docker, you can follow these steps: 50 | 51 | #### Step 1: Install Docker 52 | 53 | Ensure that Docker is installed on your machine. If Docker is not already 54 | installed, you can download and install it from the official 55 | [Docker website](https://www.docker.com/get-started). 56 | 57 | #### Step 2: Use docker to install sankaku 58 | 59 | Open a command prompt. Navigate to the directory where you want 60 | to install sankaku. Type the following command: 61 | 62 | ```commandline 63 | git clone https://github.com/zerex290/sankaku.git 64 | cd sankaku 65 | docker run -it --name sankaku -w /opt -v$(pwd):/opt python:3 bash 66 | ``` 67 | 68 | ## Usage example 69 | 70 | It's very simple to use and doesn't require to always keep opened browser page 71 | with documentation because all methods are self-explanatory: 72 | 73 | ```py 74 | import asyncio 75 | from sankaku import SankakuClient 76 | 77 | async def main(): 78 | client = SankakuClient() 79 | 80 | post = await client.get_post(25742064) 81 | print(f"Rating: {post.rating} | Created: {post.created_at}") 82 | # "Rating: Rating.QUESTIONABLE | Created: 2021-08-01 23:18:52+03:00" 83 | 84 | await client.login(access_token="token") 85 | # Or you can authorize by credentials: 86 | # await client.login(login="nickname or email", password="password") 87 | 88 | # Get the first 100 posts which have been added to favorites of the 89 | # currently logged-in user: 90 | async for post in client.get_favorited_posts(100): 91 | print(post) 92 | 93 | # Get every 3rd book from book pages, starting with 100th and ending with 94 | # 400th book: 95 | async for book in client.browse_books(100, 401, 3): # range specified in 96 | print(book) # same way as with 'range()' 97 | 98 | asyncio.run(main()) 99 | ``` 100 | 101 | ## Contributing 102 | 103 | Feel free to contribute to sankaku after reading [CONTRIBUTING](CONTRIBUTING.md) file. 104 | -------------------------------------------------------------------------------- /docs/api/clients/abc.md: -------------------------------------------------------------------------------- 1 | # Documentation for `abc.py` 2 | 3 | ::: sankaku.clients.abc.ABCHttpClient 4 | options: 5 | members: 6 | - close 7 | - request 8 | 9 | --- 10 | 11 | ::: sankaku.clients.abc.ABCClient 12 | options: 13 | members: 14 | - login -------------------------------------------------------------------------------- /docs/api/clients/clients.md: -------------------------------------------------------------------------------- 1 | # Documentation for `clients.py` 2 | 3 | ::: sankaku.clients.clients.BaseClient 4 | options: 5 | members: 6 | - login 7 | 8 | --- 9 | 10 | ::: sankaku.clients.clients.PostClient 11 | options: 12 | members: 13 | - browse_posts 14 | - get_favorited_posts 15 | - get_top_posts 16 | - get_popular_posts 17 | - get_recommended_posts 18 | - get_similar_posts 19 | - get_post_comments 20 | - get_post 21 | 22 | --- 23 | 24 | ::: sankaku.clients.clients.AIClient 25 | options: 26 | members: 27 | - browse_ai_posts 28 | - get_ai_post 29 | 30 | --- 31 | 32 | ::: sankaku.clients.clients.TagClient 33 | options: 34 | members: 35 | - browse_tags 36 | - get_tag 37 | 38 | --- 39 | 40 | ::: sankaku.clients.clients.BookClient 41 | options: 42 | members: 43 | - browse_books 44 | - get_favorited_books 45 | - get_recommended_books 46 | - get_recently_read_books 47 | - get_related_books 48 | - get_book 49 | 50 | --- 51 | 52 | :::sankaku.clients.clients.UserClient 53 | options: 54 | members: 55 | - browse_users 56 | - get_user -------------------------------------------------------------------------------- /docs/api/clients/http_client.md: -------------------------------------------------------------------------------- 1 | # Documentation for `http_client.py` 2 | 3 | ::: sankaku.clients.http_client.HttpClient 4 | options: 5 | members: 6 | - close 7 | - request 8 | - get 9 | - post -------------------------------------------------------------------------------- /docs/api/errors.md: -------------------------------------------------------------------------------- 1 | # Documentation for sankaku's errors 2 | 3 | ::: sankaku.errors.SankakuError 4 | options: 5 | members: 6 | - __init__ 7 | 8 | --- 9 | 10 | ::: sankaku.errors.RateLimitError 11 | 12 | --- 13 | 14 | ::: sankaku.errors.LoginRequirementError 15 | 16 | --- 17 | 18 | ::: sankaku.errors.VideoDurationError 19 | 20 | --- 21 | 22 | ::: sankaku.errors.PaginatorLastPage 23 | 24 | --- 25 | 26 | ::: sankaku.errors.SankakuServerError 27 | options: 28 | members: 29 | - __init__ 30 | 31 | --- 32 | 33 | ::: sankaku.errors.PageNotFoundError 34 | 35 | --- 36 | 37 | ::: sankaku.errors.AuthorizationError 38 | -------------------------------------------------------------------------------- /docs/api/index.md: -------------------------------------------------------------------------------- 1 | # Introduction to sankaku API 2 | 3 | This section describes main objects used by sankaku. Typedefs and constants are 4 | excluded from this section. 5 | -------------------------------------------------------------------------------- /docs/api/models/base.md: -------------------------------------------------------------------------------- 1 | # Documentation for `base.py` 2 | 3 | ::: sankaku.models.base.SankakuResponseModel 4 | -------------------------------------------------------------------------------- /docs/api/models/books.md: -------------------------------------------------------------------------------- 1 | # Documentation for `books.py` 2 | 3 | ::: sankaku.models.books.BookState 4 | options: 5 | show_source: false 6 | members: 7 | - current_page 8 | - sequence 9 | - post_id 10 | - series_id 11 | - created_at 12 | - updated_at 13 | - percent 14 | 15 | --- 16 | 17 | ::: sankaku.models.books.PageBook 18 | options: 19 | show_source: false 20 | members: 21 | - id 22 | - name_en 23 | - name_ja 24 | - description 25 | - description_en 26 | - description_ja 27 | - created_at 28 | - updated_at 29 | - author 30 | - is_public 31 | - is_active 32 | - is_flagged 33 | - post_count 34 | - pages_count 35 | - visible_post_count 36 | - is_intact 37 | - rating 38 | - reactions 39 | - parent_id 40 | - has_children 41 | - is_rating_locked 42 | - fav_count 43 | - vote_count 44 | - total_score 45 | - comment_count 46 | - tags 47 | - post_tags 48 | - artist_tags 49 | - genre_tags 50 | - is_favorited 51 | - user_vote 52 | - posts 53 | - file_url 54 | - sample_url 55 | - preview_url 56 | - cover_post 57 | - reading 58 | - is_premium 59 | - is_pending 60 | - is_raw 61 | - is_trial 62 | - redirect_to_signup 63 | - locale 64 | - is_deleted 65 | - cover_post_id 66 | - name 67 | - parent_pool 68 | 69 | --- 70 | 71 | ::: sankaku.models.books.Book 72 | options: 73 | show_source: false 74 | members: 75 | - child_pools 76 | - flagged_by_user 77 | - prem_post_count 78 | -------------------------------------------------------------------------------- /docs/api/models/http.md: -------------------------------------------------------------------------------- 1 | # Documentation for `http.py` 2 | 3 | ::: sankaku.models.http.ClientResponse 4 | options: 5 | show_source: false 6 | members: 7 | - status 8 | - json 9 | - ok -------------------------------------------------------------------------------- /docs/api/models/pages.md: -------------------------------------------------------------------------------- 1 | # Documentation for `pages.py` 2 | 3 | ::: sankaku.models.pages.Page 4 | options: 5 | show_source: false 6 | members: 7 | - number 8 | - items -------------------------------------------------------------------------------- /docs/api/models/posts.md: -------------------------------------------------------------------------------- 1 | # Documentation for `posts.py` 2 | 3 | ::: sankaku.models.posts.GenerationDirectivesAspectRatio 4 | options: 5 | show_source: false 6 | members: 7 | - type 8 | - width 9 | - height 10 | 11 | --- 12 | 13 | ::: sankaku.models.posts.GenerationDirectivesRating 14 | options: 15 | show_source: false 16 | members: 17 | - value 18 | - default 19 | 20 | --- 21 | 22 | ::: sankaku.models.posts.GenerationDirectives 23 | options: 24 | show_source: false 25 | members: 26 | - tags 27 | - aspect_ratio 28 | - rating 29 | - negative_prompt 30 | - natural_input 31 | - denoising_strength 32 | 33 | --- 34 | 35 | ::: sankaku.models.posts.AIGenerationDirectives 36 | options: 37 | show_source: false 38 | members: 39 | - width 40 | - height 41 | - prompt 42 | - batch_size 43 | - batch_count 44 | - sampling_steps 45 | - negative_prompt 46 | - version 47 | 48 | --- 49 | 50 | ::: sankaku.models.posts.BasePost 51 | options: 52 | show_source: false 53 | members: 54 | - id 55 | - created_at 56 | - rating 57 | - status 58 | - author 59 | - file_url 60 | - preview_url 61 | - width 62 | - height 63 | - file_size 64 | - file_type 65 | - extension 66 | - md5 67 | - tags 68 | 69 | --- 70 | 71 | ::: sankaku.models.posts.Comment 72 | options: 73 | show_source: false 74 | members: 75 | - id 76 | - created_at 77 | - post_id 78 | - author 79 | - body 80 | - score 81 | - parent_id 82 | - children 83 | - deleted 84 | - deleted_by 85 | - updated_at 86 | - can_reply 87 | - reason 88 | 89 | --- 90 | 91 | ::: sankaku.models.posts.Post 92 | options: 93 | show_source: false 94 | members: 95 | - sample_url 96 | - sample_width 97 | - sample_height 98 | - preview_width 99 | - preview_height 100 | - has_children 101 | - has_comments 102 | - has_notes 103 | - is_favorited 104 | - user_vote 105 | - parent_id 106 | - change 107 | - fav_count 108 | - recommended_posts 109 | - recommended_score 110 | - vote_count 111 | - total_score 112 | - comment_count 113 | - source 114 | - in_visible_pool 115 | - is_premium 116 | - is_rating_locked 117 | - is_note_locked 118 | - is_status_locked 119 | - redirect_to_signup 120 | - reactions 121 | - sequence 122 | - video_duration 123 | - generation_directives 124 | 125 | --- 126 | 127 | ::: sankaku.models.posts.AIPost 128 | options: 129 | show_source: false 130 | members: 131 | - updated_at 132 | - post_associated_id 133 | - generation_directives 134 | -------------------------------------------------------------------------------- /docs/api/models/tags.md: -------------------------------------------------------------------------------- 1 | # Documentation for `tags.py` 2 | 3 | ::: sankaku.models.tags.BaseTag 4 | options: 5 | show_source: false 6 | members: 7 | - id 8 | - name 9 | - name_en 10 | - name_ja 11 | - type 12 | - post_count 13 | - pool_count 14 | - series_count 15 | - rating 16 | 17 | --- 18 | 19 | ::: sankaku.models.tags.GenerationDirectivesTag 20 | options: 21 | show_source: false 22 | members: 23 | - count 24 | - tag_name 25 | - translations 26 | 27 | --- 28 | 29 | ::: sankaku.models.tags.TagMixin 30 | options: 31 | show_source: false 32 | members: 33 | - count 34 | - tag_name 35 | - total_post_count 36 | - total_pool_count 37 | 38 | --- 39 | 40 | ::: sankaku.models.tags.PostTag 41 | options: 42 | show_source: false 43 | members: 44 | - locale 45 | - version 46 | 47 | --- 48 | 49 | ::: sankaku.models.tags.NestedTag 50 | options: 51 | show_source: false 52 | members: 53 | - post_count 54 | - cached_related 55 | - cached_related_expires_on 56 | - type 57 | - name_en 58 | - name_ja 59 | - popularity_all 60 | - quality_all 61 | - popularity_ero 62 | - popularity_safe 63 | - quality_ero 64 | - quality_safe 65 | - parent_tags 66 | - child_tags 67 | - pool_count 68 | - premium_post_count 69 | - non_premium_post_count 70 | - premium_pool_count 71 | - non_premium_pool_count 72 | - series_count 73 | - premium_series_count 74 | - non_premium_series_count 75 | - is_trained 76 | - child 77 | - parent 78 | - version 79 | 80 | --- 81 | 82 | ::: sankaku.models.tags.BaseTranslations 83 | options: 84 | show_source: false 85 | members: 86 | - lang 87 | - translation 88 | 89 | --- 90 | 91 | ::: sankaku.models.tags.PageTagTranslations 92 | options: 93 | show_source: false 94 | members: 95 | - root_id 96 | 97 | --- 98 | 99 | ::: sankaku.models.tags.WikiTagTranslations 100 | options: 101 | show_source: false 102 | members: 103 | - status 104 | - opacity 105 | - id 106 | 107 | --- 108 | 109 | ::: sankaku.models.tags.PageTag 110 | options: 111 | show_source: false 112 | members: 113 | - translations 114 | - related_tags 115 | - child_tags 116 | - parent_tags 117 | 118 | --- 119 | 120 | ::: sankaku.models.tags.Wiki 121 | options: 122 | show_source: false 123 | members: 124 | - id 125 | - title 126 | - body 127 | - created_at 128 | - updated_at 129 | - author 130 | - is_locked 131 | - version 132 | 133 | --- 134 | 135 | ::: sankaku.models.tags.WikiTag 136 | options: 137 | show_source: false 138 | members: 139 | - related_tags 140 | - child_tags 141 | - parent_tags 142 | - alias_tags 143 | - implied_tags 144 | - translations 145 | - wiki 146 | -------------------------------------------------------------------------------- /docs/api/models/users.md: -------------------------------------------------------------------------------- 1 | # Documentation for `users.py` 2 | 3 | ::: sankaku.models.users.BaseUser 4 | options: 5 | show_source: false 6 | members: 7 | - id 8 | - name 9 | - avatar 10 | - avatar_rating 11 | 12 | --- 13 | 14 | ::: sankaku.models.users.Author 15 | options: 16 | show_source: false 17 | 18 | --- 19 | 20 | ::: sankaku.models.users.User 21 | options: 22 | show_source: false 23 | members: 24 | - level 25 | - upload_limit 26 | - created_at 27 | - favs_are_private 28 | - avatar 29 | - post_upload_count 30 | - pool_upload_count 31 | - comment_count 32 | - post_update_count 33 | - note_update_count 34 | - wiki_update_count 35 | - forum_post_count 36 | - pool_update_count 37 | - series_update_count 38 | - tag_update_count 39 | - artist_update_count 40 | - last_logged_in_at 41 | - favorite_count 42 | - post_favorite_count 43 | - pool_favorite_count 44 | - vote_count 45 | - post_vote_count 46 | - pool_vote_count 47 | - recommended_posts_for_user 48 | - subscriptions 49 | 50 | --- 51 | 52 | ::: sankaku.models.users.ExtendedUser 53 | options: 54 | show_source: false 55 | members: 56 | - email 57 | - hide_ads 58 | - subscription_level 59 | - filter_content 60 | - receive_dmails 61 | - email_verification_status 62 | - is_verified 63 | - verifications_count 64 | - blacklist_is_hidden 65 | - blacklisted_tags 66 | - blacklisted 67 | - mfa_method 68 | - show_popup_version 69 | - credits 70 | - credits_subs 71 | -------------------------------------------------------------------------------- /docs/api/paginators/abc.md: -------------------------------------------------------------------------------- 1 | # Documentation for `abc.py` 2 | 3 | ::: sankaku.paginators.abc.ABCPaginator 4 | options: 5 | members: 6 | - next_page 7 | - complete_params 8 | -------------------------------------------------------------------------------- /docs/api/paginators/paginators.md: -------------------------------------------------------------------------------- 1 | # Documentation for `paginators.py` 2 | 3 | ::: sankaku.paginators.paginators.Paginator 4 | options: 5 | members: 6 | - __init__ 7 | - next_page 8 | - complete_params 9 | 10 | --- 11 | 12 | ::: sankaku.paginators.paginators.PostPaginator 13 | options: 14 | members: 15 | - __init__ 16 | - complete_params 17 | 18 | --- 19 | 20 | ::: sankaku.paginators.paginators.TagPaginator 21 | options: 22 | members: 23 | - __init__ 24 | - complete_params 25 | 26 | --- 27 | 28 | ::: sankaku.paginators.paginators.BookPaginator 29 | options: 30 | members: 31 | - __init__ 32 | - complete_params 33 | 34 | --- 35 | 36 | ::: sankaku.paginators.paginators.UserPaginator 37 | options: 38 | members: 39 | - __init__ 40 | - complete_params -------------------------------------------------------------------------------- /docs/api/types.md: -------------------------------------------------------------------------------- 1 | # Documentation for sankaku's `enum` types 2 | 3 | ::: sankaku.types.Rating 4 | options: 5 | show_source: false 6 | members: 7 | - SAFE 8 | - QUESTIONABLE 9 | - EXPLICIT 10 | 11 | --- 12 | 13 | ::: sankaku.types.PostOrder 14 | options: 15 | show_source: false 16 | members: 17 | - POPULARITY 18 | - DATE 19 | - QUALITY 20 | - RANDOM 21 | - RECENTLY_FAVORITED 22 | - RECENTLY_VOTED 23 | 24 | --- 25 | 26 | ::: sankaku.types.SortParameter 27 | options: 28 | show_source: false 29 | members: 30 | - NAME 31 | - TRANSLATIONS 32 | - TYPE 33 | - RATING 34 | - BOOK_COUNT 35 | - POST_COUNT 36 | 37 | --- 38 | 39 | ::: sankaku.types.SortDirection 40 | options: 41 | show_source: false 42 | members: 43 | - ASC 44 | - DESC 45 | 46 | --- 47 | 48 | ::: sankaku.types.TagOrder 49 | options: 50 | show_source: false 51 | members: 52 | - POPULARITY 53 | - QUALITY 54 | 55 | --- 56 | 57 | ::: sankaku.types.TagType 58 | options: 59 | show_source: false 60 | members: 61 | - ARTIST 62 | - COPYRIGHT 63 | - CHARACTER 64 | - GENERAL 65 | - MEDIUM 66 | - META 67 | - GENRE 68 | - STUDIO 69 | 70 | --- 71 | 72 | ::: sankaku.types.FileType 73 | options: 74 | show_source: false 75 | members: 76 | - IMAGE 77 | - GIF 78 | - VIDEO 79 | 80 | --- 81 | 82 | ::: sankaku.types.FileSize 83 | options: 84 | show_source: false 85 | members: 86 | - LARGE 87 | - HUGE 88 | - LONG 89 | - WALLPAPER 90 | - A_RATIO_16_9 91 | - A_RATIO_4_3 92 | - A_RATIO_3_2 93 | - A_RATIO_1_1 94 | 95 | --- 96 | 97 | ::: sankaku.types.UserOrder 98 | options: 99 | show_source: false 100 | members: 101 | - POSTS 102 | - FAVORITES 103 | - NAME 104 | - NEWEST 105 | - OLDEST 106 | - LAST_SEEN 107 | 108 | --- 109 | 110 | ::: sankaku.types.UserLevel 111 | options: 112 | show_source: false 113 | members: 114 | - ADMIN 115 | - SYSTEM_USER 116 | - MODERATOR 117 | - JANITOR 118 | - CONTRIBUTOR 119 | - PRIVILEGED 120 | - MEMBER 121 | - BLOCKED 122 | - UNACTIVATED 123 | 124 | --- 125 | 126 | ::: sankaku.types.BookOrder 127 | options: 128 | show_source: false 129 | members: 130 | - POPULATIRY 131 | - DATE 132 | - QUALITY 133 | - RANDOM 134 | - RECENTLY_FAVORITED 135 | - RECENTLY_VOTED 136 | -------------------------------------------------------------------------------- /docs/api/utils.md: -------------------------------------------------------------------------------- 1 | # Documentation for miscellaneous support functions 2 | 3 | ::: sankaku.utils.ratelimit 4 | 5 | --- 6 | 7 | ::: sankaku.utils.convert_ts_to_datetime 8 | 9 | -------------------------------------------------------------------------------- /docs/authorization.md: -------------------------------------------------------------------------------- 1 | # The authorization process 2 | 3 | Authorization on [Sankaku Complex](https://beta.sankakucomplex.com) can be 4 | performed in two ways: 5 | 6 | - via access token 7 | - via credentials (login and password) 8 | 9 | ### Note 10 | 11 | > It is **not** necessary to login into Sankaku Complex at all. You are free 12 | > to send requests to server as unauthorized user, but in that case some methods 13 | > will be unavailable to you (e.g. `get_favorited_posts()`, `get_favorited_books()` etc.). 14 | 15 | ## Authorization via access token 16 | 17 | The following code block shows how to login into account using access token: 18 | 19 | ```python linenums="1" 20 | import asyncio 21 | import os 22 | from sankaku import SankakuClient 23 | 24 | async def main(): 25 | client = SankakuClient() 26 | await client.login(access_token=os.getenv("ACCESS_TOKEN")) 27 | # We're using virtual environment variables to prevent 28 | # private data from accidentally leaking. 29 | 30 | # ... Continue to work with API 31 | 32 | asyncio.run(main()) 33 | ``` 34 | 35 | ## Authorization via credentials 36 | 37 | Authorization method by credentials is the same as in previous example, 38 | but now user should pass two arguments to `login()` method: 39 | 40 | ```python linenums="1" 41 | import asyncio 42 | import os 43 | from sankaku import SankakuClient 44 | 45 | async def main(): 46 | client = SankakuClient() 47 | await client.login( 48 | login=os.getenv("LOGIN"), password=os.getenv("PASSWORD") 49 | ) 50 | 51 | # ... Continue to work with API 52 | 53 | asyncio.run(main()) 54 | ``` 55 | 56 | ## Results 57 | 58 | If authorization was successful, server will return response with serialized 59 | json data which will be processed by pydantic. After that user profile model 60 | will be passed to `client.profile` and all further requests to Sankaku servers 61 | will be performed on behalf of logged-in user. 62 | -------------------------------------------------------------------------------- /docs/clients/ai-client.md: -------------------------------------------------------------------------------- 1 | # About AIClient 2 | 3 | Recently Sankaku Complex developers released feature to generate posts by 4 | usage of neural networks. So AIClient is responsible for managing API requests 5 | to AI-related content. 6 | 7 | ### Note 8 | 9 | > Because AI is feature for premium users, AI client look a bit poor. 10 | 11 | ## Browsing posts with AIClient 12 | 13 | For non-premium users there is restriction to directly view posts created by 14 | AI: 15 | 16 | ![access to AI posts restricted for regular users](assets/restriction.png) 17 | 18 | But this restriction can be circumvented by sending requests directly via API. 19 | 20 | Here is example of post browsing, using AIClient: 21 | 22 | ```python linenums="1" 23 | import asyncio 24 | from sankaku.clients import AIClient 25 | from sankaku import types 26 | 27 | async def main(): 28 | client = AIClient() 29 | ai_posts = [] 30 | 31 | async for post in client.browse_ai_posts(90, 200): # Specify range 32 | if post.rating is types.Rating.SAFE: # Filter nsfw content 33 | ai_posts.append(post) 34 | 35 | print("\n".join(post.file_url for post in ai_posts if post.file_url)) 36 | 37 | asyncio.run(main()) 38 | ``` 39 | 40 | ## Getting specific AI post 41 | 42 | If there is situation when you know ID of the post and want to fetch its data 43 | from server, you can do it like this: 44 | 45 | ```python linenums="1" 46 | import asyncio 47 | from sankaku.clients import AIClient 48 | 49 | async def main(): 50 | post_id: int = 23432 # Here the ID of the post you interested in 51 | client = AIClient() 52 | post = await client.get_ai_post(post_id) 53 | print(post.file_url) 54 | 55 | asyncio.run(main()) 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/clients/assets/restriction.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerex290/sankaku/bf9e5f960d109326db5a5d38d04c96446a49ca75/docs/clients/assets/restriction.png -------------------------------------------------------------------------------- /docs/clients/book-client.md: -------------------------------------------------------------------------------- 1 | # About BookClient 2 | 3 | BookClient resembles PostClient in terms of functionality. It's because posts 4 | and books are strongly related. 5 | 6 | ## Browsing books with BookClient 7 | 8 | The following code shows how to browse pages with books: 9 | 10 | ```python linenums="1" 11 | import asyncio 12 | from sankaku.clients import BookClient 13 | from sankaku import types 14 | from sankaku.constants import LAST_RANGE_ITEM 15 | 16 | async def main(): 17 | client = BookClient() 18 | async for book in client.browse_books( 19 | LAST_RANGE_ITEM, 20 | favorited_by="Nigredo", 21 | order=types.BookOrder.POPULARITY 22 | ): 23 | print(book.name, book.description) 24 | 25 | asyncio.run(main()) 26 | ``` 27 | 28 | In the example above we used method `browse_books()` to get all books favorited 29 | by one specific user ('Nigredo' in our case). Predefined constant `LAST_RANGE_ITEM` 30 | is just an integer number high enough to be ensured that we will reach end of 31 | iteration. 32 | 33 | ## Getting books related to specific post 34 | 35 | If specific post id has some books as its parents, you can use 36 | `get_related_books()` method to get such books: 37 | 38 | ```python linenums="1" 39 | import asyncio 40 | from sankaku.clients import BookClient 41 | from sankaku.constants import LAST_RANGE_ITEM 42 | 43 | async def main(): 44 | client = BookClient() 45 | post_id: int = ... 46 | related_books = [] 47 | async for book in client.get_related_books(LAST_RANGE_ITEM, post_id=post_id): 48 | related_books.append(book) 49 | 50 | asyncio.run(main()) 51 | ``` 52 | 53 | ## Getting specific book by its ID 54 | 55 | If you know specific book ID then you can get remaining parameters. Peculiarity of 56 | that method is that it returns the whole book information (including another 57 | posts that are part of book): 58 | 59 | ```python 60 | import asyncio 61 | from sankaku.clients import BookClient 62 | 63 | async def main(): 64 | client = BookClient() 65 | book_id: int = 14562 66 | book = await client.get_book(book_id) 67 | print("\n".join(post.file_url for post in book.posts)) 68 | 69 | asyncio.run(main()) 70 | ``` 71 | 72 | ## About the remaining methods 73 | 74 | All the remaining methods inside their definitions invoke method `browse_books()` 75 | with certain arguments so there is no need to thoroughly consider them. Also, 76 | all the remaining mehtods require authentication. 77 | -------------------------------------------------------------------------------- /docs/clients/index.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | There are several clients present and each of them has different responsibilities, 4 | but for simplicity they all are merged into one client with multiple inheritance - 5 | `SankakuClient()`. If you want to use any specific client, then you should 6 | import it explicitly: `from sankaku.clients import `. 7 | 8 | ## Specifying the range 9 | 10 | It's worth mentioning that any client methods with return type `AsyncIterator` 11 | supports specyfing of range (except `PostClient.get_post_comments()`) in the 12 | same way as with using python built-in `range(_start, _stop, _step)` function. 13 | 14 | The only restrictions when setting range: 15 | 16 | - Initial value can't be 0; 17 | - Can't use negative `_step`; 18 | - `_start` value should always be less than `_end`. 19 | -------------------------------------------------------------------------------- /docs/clients/post-client.md: -------------------------------------------------------------------------------- 1 | # About PostClient 2 | 3 | Client for post browsing has several times more methods than other clients. 4 | That applies to `browse_posts()` method too. 5 | 6 | ## Browsing posts with post client 7 | 8 | Here simple code snippet with post browsing: 9 | 10 | ```python linenums="1" 11 | import asyncio 12 | from datetime import datetime 13 | from sankaku.clients import PostClient 14 | from sankaku import types 15 | 16 | async def main(): 17 | client = PostClient() 18 | 19 | async for post in client.browse_posts( 20 | 100, 21 | order=types.PostOrder.QUALITY, 22 | date=[datetime(2020, 1, 12), datetime(2022, 1, 12)], 23 | tags=["animated"], 24 | file_type=types.FileType.VIDEO, 25 | rating=types.Rating.SAFE 26 | ): 27 | print(post.file_url) 28 | 29 | asyncio.run(main()) 30 | ``` 31 | 32 | In the example above we specified: 33 | 34 | - amount of posts that we want to fetch; 35 | - rule which will be used to sort posts before fetching; 36 | - date range of posts; 37 | - tags by which posts will be filtered; 38 | - type of posts (e.g. gif, images or video); 39 | - content rating of posts (safe, questionable or explicit (nsfw)). 40 | 41 | ## Getting specific post by its ID 42 | 43 | You can get specific post by its ID like that: 44 | 45 | ```python linenums="1" 46 | import asyncio 47 | from sankaku.clients import PostClient 48 | 49 | async def main(): 50 | post_id: int = 25742064 # Here the ID of the post you interested in 51 | client = PostClient() 52 | post = await client.get_post(post_id) 53 | print(post.file_url) 54 | 55 | asyncio.run(main()) 56 | ``` 57 | 58 | ## About the remaining methods 59 | 60 | Almost all the remaining methods inside their definitions invoke method `browse_posts()` 61 | with certain arguments so there is no need to thoroughly consider them. 62 | But it's worth mentioning that methods `get_recommended_posts()` and 63 | `get_favorited_posts()` require authorization. 64 | -------------------------------------------------------------------------------- /docs/clients/tag-client.md: -------------------------------------------------------------------------------- 1 | # About TagClient 2 | 3 | Tag client has methods for browsing pages with tags and for fetching specific tag. 4 | 5 | ## Browsing tags with TagClient 6 | 7 | Unlike AI-generated posts, whose browsing is restricted and can't be parametrized, 8 | method `browse_tags()` can be parametrized in same way as on website: 9 | 10 | ```python linenums="1" 11 | import asyncio 12 | from sankaku.clients import TagClient 13 | from sankaku import types 14 | 15 | async def main(): 16 | client = TagClient() 17 | async for tag in client.browse_tags( 18 | 30, # Specify amount of tags to fetch from server 19 | order=types.TagOrder.QUALITY, 20 | sort_parameter=types.SortParameter.POST_COUNT, 21 | sort_direction=types.SortDirection.DESC 22 | ): 23 | print(tag.name, tag.rating, tag.type) 24 | 25 | asyncio.run(main()) 26 | ``` 27 | 28 | ## Getting specific tag 29 | 30 | Unlike posts, AI-generated posts or books, specific tag can be returned by its 31 | name or id: 32 | 33 | ```python linenums="1" 34 | import asyncio 35 | from sankaku.clients import TagClient 36 | 37 | async def main(): 38 | client = TagClient() 39 | tag_id: int = 100 40 | tag_name: str = "mirco_cabbia" 41 | tag_by_id = await client.get_tag(tag_id) 42 | tag_by_name = await client.get_tag(tag_name) 43 | 44 | print(tag_by_id) 45 | print(tag_by_name) 46 | 47 | asyncio.run(main()) 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/clients/user-client.md: -------------------------------------------------------------------------------- 1 | # About UserClient 2 | 3 | Methods of UserClient enables you browse pages with users or get specific user. 4 | 5 | ## Browsing users with UserClient 6 | 7 | User browsing can be parametrized by specifying Order rule or level of users: 8 | 9 | ```python linenums="1" 10 | import asyncio 11 | from datetime import datetime 12 | from sankaku.clients import UserClient 13 | from sankaku import types 14 | 15 | async def main(): 16 | client = UserClient() 17 | async for user in client.browse_users( 18 | 1000, 19 | order=types.UserOrder.OLDEST, 20 | level=types.UserLevel.CONTRIBUTOR 21 | ): 22 | print(user.created_at < datetime(2020, 12, 18).astimezone()) 23 | 24 | asyncio.run(main()) 25 | ``` 26 | 27 | ## Getting specific user 28 | 29 | By analogy with `get_tag()` method you can get information about specific user 30 | by its nickname or id: 31 | 32 | ```python linenums="1" 33 | import asyncio 34 | from sankaku.clients import UserClient 35 | 36 | async def main(): 37 | client = UserClient() 38 | user_id: int = 3242 39 | user_name: str = "reichan" 40 | user_by_id = await client.get_user(user_id) 41 | user_by_name = await client.get_user(user_name) 42 | 43 | print(user_by_id) 44 | print(user_by_name) 45 | 46 | asyncio.run(main()) 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerex290/sankaku/bf9e5f960d109326db5a5d38d04c96446a49ca75/docs/icon.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Welcome to sankaku documentation 2 | 3 | It is an unofficial API wrapper for [Sankaku Complex](https://beta.sankakucomplex.com) 4 | with *type-hinting*, pydantic *data validation* and an optional *logging support* 5 | with loguru. 6 | 7 | 8 | ### Features 9 | 10 | - Type-hints 11 | - Deserialization of raw json data thanks to pydantic models 12 | - Enumerations for API request parameters to provide better user experience 13 | 14 | ## Requirements 15 | 16 | - Python 3.8+ 17 | - aiohttp 18 | - pydantic 19 | - loguru 20 | - aiohttp-retry 21 | - typing_extensions; python_version < '3.10' 22 | 23 | ## Installation 24 | 25 | ### Installation with pip 26 | 27 | To install sankaku via pip write following line of code in your terminal: 28 | 29 | ```commandline 30 | pip install sankaku 31 | ``` 32 | 33 | ### Installation with Docker 34 | 35 | To install the sankaku via Docker, you can follow these steps: 36 | 37 | #### Step 1: Install Docker 38 | 39 | Ensure that Docker is installed on your machine. If Docker is not already 40 | installed, you can download and install it from the official 41 | [Docker website](https://www.docker.com/get-started). 42 | 43 | #### Step 2: Use docker to install sankaku 44 | 45 | Open a command prompt. Navigate to the directory where you want 46 | to install sankaku. Type the following command: 47 | 48 | ```commandline 49 | git clone https://github.com/zerex290/sankaku.git 50 | cd sankaku 51 | docker run -it --name sankaku -w /opt -v$(pwd):/opt python:3 bash 52 | ``` 53 | 54 | ## Usage example 55 | 56 | ```py linenums="1" 57 | import asyncio 58 | from sankaku import SankakuClient 59 | 60 | async def main(): 61 | client = SankakuClient() 62 | 63 | post = await client.get_post(25742064) 64 | print(f"Rating: {post.rating} | Created: {post.created_at}") 65 | # "Rating: Rating.QUESTIONABLE | Created: 2021-08-01 23:18:52+03:00" 66 | 67 | await client.login(access_token="token") 68 | # Or you can authorize by credentials: 69 | # await client.login(login="nickname or email", password="password") 70 | 71 | # Get the first 100 posts which have been added to favorites of the 72 | # currently logged-in user: 73 | async for post in client.get_favorited_posts(100): 74 | print(post) 75 | 76 | # Get every 3rd book from book pages, starting with 100th and ending with 77 | # 400th book: 78 | async for book in client.browse_books(100, 401, 3): # range specified in 79 | print(book) # same way as with 'range()' 80 | 81 | asyncio.run(main()) 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerex290/sankaku/bf9e5f960d109326db5a5d38d04c96446a49ca75/docs/logo.png -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: sankaku 2 | site_description: Asynchronous API wrapper for Sankaku Complex 3 | site_url: https://zerex290.github.io/sankaku 4 | repo_name: zerex290/sankaku 5 | repo_url: https://github.com/zerex290/sankaku 6 | 7 | nav: 8 | - Home: index.md 9 | - Authorization: authorization.md 10 | - Usage: 11 | - Introduction: clients/index.md 12 | - Working with posts: clients/post-client.md 13 | - Working with AI: clients/ai-client.md 14 | - Working with tags: clients/tag-client.md 15 | - Working with books: clients/book-client.md 16 | - Working with users: clients/user-client.md 17 | - API Reference: 18 | - Introduction: api/index.md 19 | - sankaku.clients: 20 | - abc: api/clients/abc.md 21 | - clients: api/clients/clients.md 22 | - http_client: api/clients/http_client.md 23 | - sankaku.models: 24 | - base: api/models/base.md 25 | - books: api/models/books.md 26 | - http: api/models/http.md 27 | - pages: api/models/pages.md 28 | - posts: api/models/posts.md 29 | - tags: api/models/tags.md 30 | - users: api/models/users.md 31 | - sankaku.paginators: 32 | - abc: api/paginators/abc.md 33 | - paginators: api/paginators/paginators.md 34 | - sankaku.errors: api/errors.md 35 | - sankaku.types: api/types.md 36 | - sankaku.utils: api/utils.md 37 | 38 | plugins: 39 | - search 40 | - mkdocstrings: 41 | default_handler: python 42 | handlers: 43 | python: 44 | options: 45 | show_root_heading: true 46 | 47 | theme: 48 | name: material 49 | logo: logo.png 50 | icon: 51 | repo: fontawesome/brands/github-alt 52 | 53 | features: 54 | - navigation.tabs 55 | - navigation.tabs.sticky 56 | - content.code.copy 57 | - search.suggest 58 | - search.highlight 59 | 60 | palette: 61 | - scheme: slate 62 | primary: deep purple 63 | accent: purple 64 | toggle: 65 | icon: material/brightness-4 66 | name: Switch to light mode 67 | 68 | - scheme: default 69 | primary: deep purple 70 | accent: purple 71 | toggle: 72 | icon: material/brightness-7 73 | name: Switch to dark mode 74 | 75 | font: 76 | text: Crete Round 77 | code: JetBrains Mono 78 | 79 | markdown_extensions: 80 | - pymdownx.highlight: 81 | anchor_linenums: true 82 | line_spans: __span 83 | pygments_lang_class: true 84 | - pymdownx.inlinehilite 85 | - pymdownx.snippets 86 | - pymdownx.superfences 87 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.pyright] 6 | include = ["."] 7 | exclude = [ 8 | ".ruff_cache", 9 | ".mypy_cache", 10 | "**/__pycache__/", 11 | ".eggs", 12 | ".git", 13 | "build", 14 | "dist", 15 | "venv", 16 | "virtualenv", 17 | ".env", 18 | ".venv", 19 | ] 20 | typeCheckingMode = "basic" 21 | reportMissingTypeStubs = false 22 | reportInvalidStringEscapeSequence = "error" 23 | reportDuplicateImport = "warning" 24 | reportUnnecessaryTypeIgnoreComment = "warning" 25 | reportShadowedImports = "warning" 26 | 27 | [tool.ruff] 28 | # Paths to directories to consider as first-party imports 29 | src = ["."] 30 | # Exclude directories from linting 31 | exclude = [ 32 | ".ruff_cache", 33 | ".mypy_cache", 34 | "**/__pycache__/", 35 | ".eggs", 36 | ".git", 37 | "build", 38 | "dist", 39 | "venv", 40 | "virtualenv", 41 | ".env", 42 | ".venv", 43 | ] 44 | # Rule configuration: https://beta.ruff.rs/docs/rules/ 45 | select = [ 46 | "F", # Pyflakes 47 | "N", # pep8-naming 48 | "A", # flake8-builtins 49 | "Q", # flake8-quotes 50 | "PL", # Pylint 51 | "T20", # flake8-print 52 | "ARG", # flake8-unused-arguments 53 | "D102", # pydocstyle[undocumented-public-method] 54 | "D103", # pydocstyle[undocumented-public-function] 55 | "D200", # pydocstyle[fits-on-one-line] 56 | "RUF013", # Ruff[implicit-optional] 57 | "RUF100", # Ruff[unused-noqa] 58 | "E", "W", # pycodestyle 59 | ] 60 | ignore = [ 61 | "PLR0913", # pylint[max-args] 62 | "PLR0915", # pylint[max-statements] 63 | ] 64 | 65 | [tool.ruff.pydocstyle] 66 | convention = "google" 67 | 68 | [tool.ruff.pep8-naming] 69 | classmethod-decorators = ["pydantic.validator"] 70 | 71 | [tool.pytest.ini_options] 72 | asyncio_mode = "auto" # Auto mode for usage with pytest-asyncio 73 | 74 | [tool.yapf] 75 | based_on_style = "pep8" 76 | arithmetic_precedence_indication = true 77 | blank_lines_between_top_level_imports_and_variables = 2 78 | blank_line_before_nested_class_or_def = true 79 | dedent_closing_brackets = true 80 | space_between_ending_comma_and_closing_bracket = false 81 | split_all_comma_separated_values = true 82 | split_all_top_level_comma_separated_values = true 83 | split_before_arithmetic_operator = true 84 | split_before_dot = true 85 | split_before_first_argument = true 86 | split_complex_comprehension = true 87 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pytest~=7.3.1 2 | pytest-asyncio~=0.21.0 3 | pytest-cov~=4.0.0 4 | yapf~=0.40.1 5 | ruff~=0.0.282 6 | pyright~=1.1.320 7 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | mkdocstrings 4 | mkdocstrings[python] 5 | -------------------------------------------------------------------------------- /requirements-socks.txt: -------------------------------------------------------------------------------- 1 | aiohttp-socks -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | pydantic 3 | loguru 4 | aiohttp-retry 5 | typing_extensions; python_version < '3.10' -------------------------------------------------------------------------------- /sankaku/__init__.py: -------------------------------------------------------------------------------- 1 | from loguru import logger 2 | 3 | from .clients import SankakuClient 4 | 5 | 6 | __all__ = ["SankakuClient"] 7 | 8 | 9 | logger.disable("sankaku") 10 | -------------------------------------------------------------------------------- /sankaku/clients/__init__.py: -------------------------------------------------------------------------------- 1 | from .http_client import HttpClient 2 | from .clients import * # noqa: F403 3 | 4 | 5 | __all__ = [ # noqa: F405 6 | "HttpClient", 7 | "PostClient", 8 | "AIClient", 9 | "TagClient", 10 | "BookClient", 11 | "UserClient", 12 | "SankakuClient" 13 | ] 14 | 15 | 16 | class SankakuClient( 17 | PostClient, # noqa: F405 18 | AIClient, # noqa: F405 19 | TagClient, # noqa: F405 20 | BookClient, # noqa: F405 21 | UserClient # noqa: F405 22 | ): 23 | """Simple client for Sankaku API.""" 24 | -------------------------------------------------------------------------------- /sankaku/clients/abc.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Optional 3 | 4 | from sankaku.models.http import ClientResponse 5 | 6 | 7 | __all__ = ["ABCHttpClient", "ABCClient"] 8 | 9 | 10 | class ABCHttpClient(ABC): 11 | @abstractmethod 12 | def __init__(self, *args, **kwargs) -> None: 13 | """Abstract client for handling http requests.""" 14 | pass 15 | 16 | async def __aenter__(self): 17 | return self 18 | 19 | async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 20 | await self.close() 21 | 22 | @abstractmethod 23 | def __del__(self) -> None: 24 | pass 25 | 26 | @abstractmethod 27 | async def close(self) -> None: 28 | """Close previously created client session.""" 29 | 30 | @abstractmethod 31 | async def request(self, method: str, url: str, **kwargs) -> ClientResponse: 32 | """Make request to specified url.""" 33 | 34 | 35 | class ABCClient(ABC): 36 | @abstractmethod 37 | def __init__(self, *args, **kwargs) -> None: 38 | """Abstract Sankaku client.""" 39 | pass 40 | 41 | @abstractmethod 42 | async def login( 43 | self, 44 | *, 45 | access_token: Optional[str] = None, 46 | login: Optional[str] = None, 47 | password: Optional[str] = None 48 | ) -> None: 49 | """Login into sankakucomplex.com via access token or credentials.""" 50 | -------------------------------------------------------------------------------- /sankaku/clients/clients.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from typing import Optional, Union, List, Tuple, AsyncIterator 4 | 5 | from typing_extensions import Literal, Annotated 6 | 7 | from loguru import logger 8 | 9 | from sankaku import models as mdl, constants as const, types, errors 10 | from sankaku.paginators import * # noqa: F403 11 | from sankaku.typedefs import ValueRange 12 | from .abc import ABCClient 13 | from .http_client import HttpClient 14 | 15 | 16 | __all__ = [ 17 | "PostClient", 18 | "AIClient", 19 | "TagClient", 20 | "BookClient", 21 | "UserClient", 22 | ] 23 | 24 | 25 | class BaseClient(ABCClient): 26 | def __init__(self) -> None: 27 | """Base client used for login.""" 28 | self._profile: Optional[mdl.ExtendedUser] = None 29 | self._http_client: HttpClient = HttpClient() 30 | self._access_token: Optional[str] = None # TODO: ability to update access token 31 | self._token_type: Optional[str] = None 32 | 33 | async def _login_via_credentials(self, login: str, password: str) -> None: 34 | response = await self._http_client.post( 35 | const.LOGIN_URL, 36 | data=json.dumps({"login": login, "password": password}) 37 | ) 38 | 39 | if not response.ok: 40 | raise errors.AuthorizationError(response.status, **response.json) 41 | 42 | self._access_token = response.json["access_token"] 43 | self._token_type = response.json["token_type"] 44 | self._profile = mdl.ExtendedUser(**response.json["current_user"]) 45 | 46 | async def _login_via_access_token(self, access_token: str) -> None: 47 | try: 48 | self._profile = await self._get_profile(access_token) 49 | # Update access token and token type after successful profile fetch 50 | self._access_token = access_token 51 | self._token_type = const.DEFAULT_TOKEN_TYPE 52 | except errors.SankakuServerError as e: 53 | raise errors.AuthorizationError(e.status, **e.kwargs) 54 | 55 | async def _get_profile(self, access_token: str) -> mdl.ExtendedUser: 56 | """Get user profile information from Sankaku server by access token.""" 57 | if self._profile is not None: 58 | return self._profile 59 | 60 | headers = {"authorization": f"{const.DEFAULT_TOKEN_TYPE} {access_token}"} 61 | headers.update(self._http_client.headers) 62 | response = await self._http_client.get(const.PROFILE_URL, headers=headers) 63 | 64 | if not response.ok: 65 | raise errors.SankakuServerError( 66 | response.status, "Failed to get user profile", **response.json 67 | ) 68 | 69 | return mdl.ExtendedUser(**response.json["user"]) 70 | 71 | async def login( 72 | self, 73 | *, 74 | access_token: Optional[str] = None, 75 | login: Optional[str] = None, 76 | password: Optional[str] = None 77 | ) -> None: 78 | """Login into sankakucomplex.com via access token or credentials. 79 | In case when all arguments are specified, preference will be given 80 | to authorization by credentials. 81 | 82 | Args: 83 | access_token: User access token 84 | login: User email or nickname 85 | password: User password 86 | """ 87 | if login and password: 88 | await self._login_via_credentials(login, password) 89 | elif access_token and not login and not password: 90 | await self._login_via_access_token(access_token) 91 | else: 92 | raise errors.SankakuError( 93 | "The given data is not enough " 94 | "or invalid (perhaps of the wrong type)." 95 | ) 96 | 97 | self._http_client.headers.update( 98 | authorization=f"{self._token_type} {self._access_token}" 99 | ) 100 | logger.info(f"Successfully logged in as {self._profile.name}.") # type: ignore 101 | 102 | @property 103 | def profile(self) -> Optional[mdl.ExtendedUser]: 104 | return self._profile 105 | 106 | 107 | class PostClient(BaseClient): 108 | """Client for post browsing.""" 109 | async def browse_posts( 110 | self, 111 | _start: int, 112 | _stop: Optional[int] = None, 113 | _step: Optional[int] = None, 114 | /, 115 | *, 116 | order: Optional[types.PostOrder] = None, 117 | date: Optional[List[datetime]] = None, 118 | rating: Optional[types.Rating] = None, 119 | threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None, 120 | hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None, 121 | file_size: Optional[types.FileSize] = None, 122 | file_type: Optional[types.FileType] = None, 123 | video_duration: Optional[List[int]] = None, 124 | recommended_for: Optional[str] = None, 125 | favorited_by: Optional[str] = None, 126 | tags: Optional[List[str]] = None, 127 | added_by: Optional[List[str]] = None, 128 | voted: Optional[str] = None 129 | ) -> AsyncIterator[mdl.Post]: 130 | """Get get a certain range of posts with specific characteristics. 131 | Range of posts can be specified in the same way as when using built-in 132 | `range()`. 133 | 134 | Args: 135 | _start: Start of the sequence 136 | _stop: End of the sequence (except this value itself) 137 | _step: Step of the sequence 138 | order: Post order rule 139 | date: Date or range of dates 140 | rating: Post rating 141 | threshold: Vote (quality) filter of posts 142 | hide_posts_in_books: Whether show post from books or not 143 | file_size: Size (aspect ratio) of mediafile 144 | file_type: Type of mediafile in post 145 | video_duration: Video duration in seconds or in range of seconds 146 | recommended_for: Posts recommended for specified user 147 | favorited_by: Posts favorited by specified user 148 | tags: Tags available for search 149 | added_by: Posts uploaded by specified users 150 | voted: Posts voted by specified user 151 | """ 152 | item_range = _process_item_range(_start, _stop, _step) 153 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 154 | slices = _compute_slices(item_range, page_range) 155 | 156 | async for page in PostPaginator( # noqa: F405 157 | *page_range, 158 | http_client=self._http_client, 159 | order=order, 160 | date=date, 161 | rating=rating, 162 | threshold=threshold, 163 | hide_posts_in_books=hide_posts_in_books, 164 | file_size=file_size, 165 | file_type=file_type, 166 | video_duration=video_duration, 167 | recommended_for=recommended_for, 168 | favorited_by=favorited_by, 169 | tags=tags, 170 | added_by=added_by, 171 | voted=voted 172 | ): 173 | for post in page.items[slices.pop()]: 174 | yield post 175 | 176 | async def get_favorited_posts( 177 | self, 178 | _start: int, 179 | _stop: Optional[int] = None, 180 | _step: Optional[int] = None, 181 | / 182 | ) -> AsyncIterator[mdl.Post]: 183 | """Shorthand way to get a certain range of favorited posts of 184 | currently logged-in user. 185 | Range of posts can be specified in the same way as when using built-in 186 | `range()`. 187 | 188 | Args: 189 | _start: Start of the sequence 190 | _stop: End of the sequence (except this value itself) 191 | _step: Step of the sequence 192 | """ 193 | if self._profile is None: 194 | raise errors.LoginRequirementError 195 | 196 | async for post in self.browse_posts( 197 | _start, _stop, _step, 198 | favorited_by=self._profile.name 199 | ): 200 | yield post 201 | 202 | async def get_top_posts( 203 | self, 204 | _start: int, 205 | _stop: Optional[int] = None, 206 | _step: Optional[int] = None, 207 | / 208 | ) -> AsyncIterator[mdl.Post]: 209 | """Shorthand way to get a certain range of top posts. 210 | Range of posts can be specified in the same way as when using built-in 211 | `range()`. 212 | 213 | Args: 214 | _start: Start of the sequence 215 | _stop: End of the sequence (except this value itself) 216 | _step: Step of the sequence 217 | """ 218 | async for post in self.browse_posts( 219 | _start, _stop, _step, 220 | order=types.PostOrder.QUALITY 221 | ): 222 | yield post 223 | 224 | async def get_popular_posts( 225 | self, 226 | _start: int, 227 | _stop: Optional[int] = None, 228 | _step: Optional[int] = None, 229 | / 230 | ) -> AsyncIterator[mdl.Post]: 231 | """Shorthand way to get a certain range of popular posts. 232 | Range of posts can be specified in the same way as when using built-in 233 | `range()`. 234 | 235 | Args: 236 | _start: Start of the sequence 237 | _stop: End of the sequence (except this value itself) 238 | _step: Step of the sequence 239 | """ 240 | async for post in self.browse_posts( 241 | _start, _stop, _step, 242 | order=types.PostOrder.POPULARITY 243 | ): 244 | yield post 245 | 246 | async def get_recommended_posts( 247 | self, 248 | _start: int, 249 | _stop: Optional[int] = None, 250 | _step: Optional[int] = None, 251 | / 252 | ) -> AsyncIterator[mdl.Post]: 253 | """Shorthand way to get a certain range of recommended posts for 254 | currently logged-in user. 255 | Range of posts can be specified in the same way as when using built-in 256 | `range()`. 257 | 258 | Args: 259 | _start: Start of the sequence 260 | _stop: End of the sequence (except this value itself) 261 | _step: Step of the sequence 262 | """ 263 | if self._profile is None: 264 | raise errors.LoginRequirementError 265 | 266 | async for post in self.browse_posts( 267 | _start, _stop, _step, 268 | recommended_for=self._profile.name 269 | ): 270 | yield post 271 | 272 | async def get_similar_posts( 273 | self, 274 | _start: int, 275 | _stop: Optional[int] = None, 276 | _step: Optional[int] = None, 277 | /, 278 | *, 279 | post_id: int 280 | ) -> AsyncIterator[mdl.Post]: 281 | """Get a certain range of posts similar (recommended) for specific post. 282 | Range of posts can be specified in the same way as when using built-in 283 | `range()`. 284 | 285 | Args: 286 | _start: Start of the sequence 287 | _stop: End of the sequence (except this value itself) 288 | _step: Step of the sequence 289 | post_id: ID of the post of interest 290 | """ 291 | async for post in self.browse_posts( 292 | _start, _stop, _step, 293 | tags=[f"recommended_for_post:{post_id}"] 294 | ): 295 | yield post 296 | 297 | async def get_post_comments(self, post_id: int) -> AsyncIterator[mdl.Comment]: 298 | """Get all comments of the specific post by its ID.""" 299 | async for page in Paginator( # noqa: F405 300 | const.LAST_RANGE_ITEM, 301 | http_client=self._http_client, 302 | url=const.COMMENTS_URL.format(post_id=post_id), 303 | model=mdl.Comment 304 | ): 305 | for comment in page.items: 306 | yield comment 307 | 308 | async def get_post(self, post_id: int) -> mdl.Post: 309 | """Get specific post by its ID.""" 310 | response = await self._http_client.get(const.POST_URL.format(post_id=post_id)) 311 | 312 | if not response.ok: 313 | raise errors.PageNotFoundError(response.status, post_id=post_id) 314 | 315 | return mdl.Post(**response.json) 316 | 317 | async def create_post(self): # TODO: TBA # noqa: D102 318 | raise NotImplementedError 319 | 320 | 321 | class AIClient(BaseClient): 322 | """Client for working with Sankaku built-in AI.""" 323 | async def browse_ai_posts( 324 | self, 325 | _start: int, 326 | _stop: Optional[int] = None, 327 | _step: Optional[int] = None, 328 | / 329 | ) -> AsyncIterator[mdl.AIPost]: 330 | """Get a certain range of AI created posts from AI dedicated post pages. 331 | Range of posts can be specified in the same way as when using built-in 332 | `range()`. 333 | 334 | Args: 335 | _start: Start of the sequence 336 | _stop: End of the sequence (except this value itself) 337 | _step: Step of the sequence 338 | """ 339 | item_range = _process_item_range(_start, _stop, _step) 340 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 341 | slices = _compute_slices(item_range, page_range) 342 | 343 | async for page in Paginator( # noqa: F405 344 | *page_range, 345 | http_client=self._http_client, 346 | url=const.AI_POSTS_URL, 347 | model=mdl.AIPost 348 | ): 349 | for post in page.items[slices.pop()]: 350 | yield post 351 | 352 | async def get_ai_post(self, post_id: int) -> mdl.AIPost: 353 | """Get specific AI post by its ID.""" 354 | response = await self._http_client.get( 355 | const.AI_POST_URL.format(post_id=post_id) 356 | ) 357 | 358 | if not response.ok: 359 | raise errors.PageNotFoundError(response.status, post_id=post_id) 360 | 361 | return mdl.AIPost(**response.json) 362 | 363 | async def create_ai_post(self): # TODO: TBA # noqa: D102 364 | raise NotImplementedError 365 | 366 | 367 | class TagClient(BaseClient): 368 | """Client for tag browsing.""" 369 | async def browse_tags( 370 | self, 371 | _start: int, 372 | _stop: Optional[int] = None, 373 | _step: Optional[int] = None, 374 | /, 375 | *, 376 | tag_type: Optional[types.TagType] = None, 377 | order: Optional[types.TagOrder] = None, 378 | rating: Optional[types.Rating] = None, 379 | max_post_count: Optional[int] = None, 380 | sort_parameter: Optional[types.SortParameter] = None, 381 | sort_direction: Optional[types.SortDirection] = None 382 | ) -> AsyncIterator[mdl.PageTag]: 383 | """Get a certain range of tags from tag pages. 384 | Range of tags can be specified in the same way as when using built-in 385 | `range()`. 386 | 387 | Args: 388 | _start: Start of the sequence 389 | _stop: End of the sequence (except this value itself) 390 | _step: Step of the sequence 391 | tag_type: Tag type filter 392 | order: Tag order rule 393 | rating: Tag rating 394 | max_post_count: Upper threshold for number of posts with tags found 395 | sort_parameter: Tag sorting parameter 396 | sort_direction: Tag sorting direction 397 | """ 398 | item_range = _process_item_range(_start, _stop, _step) 399 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 400 | slices = _compute_slices(item_range, page_range) 401 | 402 | async for page in TagPaginator( # noqa: F405 403 | *page_range, 404 | http_client=self._http_client, 405 | tag_type=tag_type, 406 | order=order, 407 | rating=rating, 408 | max_post_count=max_post_count, 409 | sort_parameter=sort_parameter, 410 | sort_direction=sort_direction 411 | ): 412 | for tag in page.items[slices.pop()]: 413 | yield tag 414 | 415 | async def get_tag(self, name_or_id: Union[str, int]) -> mdl.WikiTag: 416 | """Get specific tag by its name or ID.""" 417 | response = await self._http_client.get( 418 | const.TAG_WIKI_URL.format( 419 | ref="/name" if isinstance(name_or_id, str) else "/id", 420 | name_or_id=name_or_id 421 | ) 422 | ) 423 | 424 | if not response.ok: 425 | raise errors.PageNotFoundError(response.status, name_or_id=name_or_id) 426 | 427 | return mdl.WikiTag(wiki=response.json["wiki"], **response.json["tag"]) 428 | 429 | 430 | class BookClient(BaseClient): 431 | """Client for book (pool) browsing.""" 432 | async def browse_books( 433 | self, 434 | _start: int, 435 | _stop: Optional[int] = None, 436 | _step: Optional[int] = None, 437 | /, 438 | *, 439 | order: Optional[types.BookOrder] = None, 440 | rating: Optional[types.Rating] = None, 441 | recommended_for: Optional[str] = None, 442 | favorited_by: Optional[str] = None, 443 | tags: Optional[List[str]] = None, 444 | added_by: Optional[List[str]] = None, 445 | voted: Optional[str] = None, 446 | ) -> AsyncIterator[mdl.PageBook]: 447 | """Get a certain range of books (pools) from book (pool) pages. 448 | Range of books can be specified in the same way as when using built-in 449 | `range()`. 450 | 451 | Args: 452 | _start: Start of the sequence 453 | _stop: End of the sequence (except this value itself) 454 | _step: Step of the sequence 455 | order: Book order rule 456 | rating: Books rating 457 | recommended_for: Books recommended for specified user 458 | favorited_by: Books favorited by specified user 459 | tags: Tags available for search 460 | added_by: Books uploaded by specified users 461 | voted: Books voted by specified user 462 | """ 463 | item_range = _process_item_range(_start, _stop, _step) 464 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 465 | slices = _compute_slices(item_range, page_range) 466 | 467 | async for page in BookPaginator( # noqa: F405 468 | *page_range, 469 | http_client=self._http_client, 470 | order=order, 471 | rating=rating, 472 | recommended_for=recommended_for, 473 | favorited_by=favorited_by, 474 | tags=tags, 475 | added_by=added_by, 476 | voted=voted 477 | ): 478 | for book in page.items[slices.pop()]: 479 | yield book 480 | 481 | async def get_favorited_books( 482 | self, 483 | _start: int, 484 | _stop: Optional[int] = None, 485 | _step: Optional[int] = None, 486 | / 487 | ) -> AsyncIterator[mdl.PageBook]: 488 | """Shorthand way to get a certain range of favorited books for 489 | currently logged-in user. 490 | Range of books can be specified in the same way as when using built-in 491 | `range()`. 492 | 493 | Args: 494 | _start: Start of the sequence 495 | _stop: End of the sequence (except this value itself) 496 | _step: Step of the sequence 497 | """ 498 | if self._profile is None: 499 | raise errors.LoginRequirementError 500 | 501 | async for book in self.browse_books( 502 | _start, _stop, _step, 503 | favorited_by=self._profile.name 504 | ): 505 | yield book 506 | 507 | async def get_recommended_books( 508 | self, 509 | _start: int, 510 | _stop: Optional[int] = None, 511 | _step: Optional[int] = None, 512 | / 513 | ) -> AsyncIterator[mdl.PageBook]: 514 | """Shorthand way to get a certain range of recommended books for 515 | currently logged-in user. 516 | Range of books can be specified in the same way as when using built-in 517 | `range()`. 518 | 519 | Args: 520 | _start: Start of the sequence 521 | _stop: End of the sequence (except this value itself) 522 | _step: Step of the sequence 523 | """ 524 | if self._profile is None: 525 | raise errors.LoginRequirementError 526 | 527 | async for book in self.browse_books( 528 | _start, _stop, _step, 529 | recommended_for=self._profile.name 530 | ): 531 | yield book 532 | 533 | async def get_recently_read_books( 534 | self, 535 | _start: int, 536 | _stop: Optional[int] = None, 537 | _step: Optional[int] = None, 538 | / 539 | ) -> AsyncIterator[mdl.PageBook]: 540 | """Get a certain range of recently read/opened books of currently 541 | logged-in user. 542 | Range of books can be specified in the same way as when using built-in 543 | `range()`. 544 | 545 | Args: 546 | _start: Start of the sequence 547 | _stop: End of the sequence (except this value itself) 548 | _step: Step of the sequence 549 | """ 550 | if self._profile is None: 551 | raise errors.LoginRequirementError 552 | 553 | async for book in self.browse_books( 554 | _start, _stop, _step, 555 | tags=[f"read:@{self._profile.id}@"] 556 | ): 557 | yield book 558 | 559 | async def get_related_books( 560 | self, 561 | _start: int, 562 | _stop: Optional[int] = None, 563 | _step: Optional[int] = None, 564 | /, 565 | *, 566 | post_id: int 567 | ) -> AsyncIterator[mdl.PageBook]: 568 | """Get a certain range of books related to specific post. 569 | Range of books can be specified in the same way as when using built-in 570 | `range()`. 571 | 572 | Args: 573 | _start: Start of the sequence 574 | _stop: End of the sequence (except this value itself) 575 | _step: Step of the sequence 576 | post_id: ID of the post of interest 577 | """ 578 | item_range = _process_item_range(_start, _stop, _step) 579 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 580 | slices = _compute_slices(item_range, page_range) 581 | 582 | async for page in BookPaginator( # noqa: F405 583 | *page_range, 584 | http_client=self._http_client, 585 | url=const.RELATED_BOOKS_URL.format(post_id=post_id) 586 | ): 587 | for book in page.items[slices.pop()]: 588 | yield book 589 | 590 | async def get_book(self, book_id: int) -> mdl.Book: 591 | """Get specific book by its ID.""" 592 | response = await self._http_client.get(const.BOOK_URL.format(book_id=book_id)) 593 | 594 | if not response.ok: 595 | raise errors.PageNotFoundError(response.status, book_id=book_id) 596 | 597 | return mdl.Book(**response.json) 598 | 599 | 600 | class UserClient(BaseClient): 601 | """Client for browsing users.""" 602 | async def browse_users( 603 | self, 604 | _start: int, 605 | _stop: Optional[int] = None, 606 | _step: Optional[int] = None, 607 | /, 608 | *, 609 | order: Optional[types.UserOrder] = None, 610 | level: Optional[types.UserLevel] = None, 611 | ) -> AsyncIterator[mdl.User]: 612 | """Get a certain range of user profiles from user pages. 613 | Range of user profiles can be specified in the same way as when using 614 | built-in `range()`. 615 | 616 | Args: 617 | _start: Start of the sequence 618 | _stop: End of the sequence (except this value itself) 619 | _step: Step of the sequence 620 | order: User order rule 621 | level: User level type 622 | """ 623 | item_range = _process_item_range(_start, _stop, _step) 624 | page_range = _process_page_range(*item_range[:2], limit=const.BASE_LIMIT) 625 | slices = _compute_slices(item_range, page_range) 626 | 627 | async for page in UserPaginator( # noqa: F405 628 | *page_range, 629 | http_client=self._http_client, 630 | order=order, 631 | level=level 632 | ): 633 | for user in page.items[slices.pop()]: 634 | yield user 635 | 636 | async def get_user(self, name_or_id: Union[str, int]) -> mdl.User: 637 | """Get specific user by its name or ID.""" 638 | response = await self._http_client.get( 639 | const.USER_URL.format( 640 | ref="/name" if isinstance(name_or_id, str) else "", 641 | name_or_id=name_or_id 642 | ) 643 | ) 644 | 645 | if not response.ok: 646 | raise errors.PageNotFoundError(response.status, name_or_id=name_or_id) 647 | 648 | return mdl.User(**response.json) 649 | 650 | 651 | def _process_item_range( 652 | _start: int, 653 | _stop: Optional[int] = None, 654 | _step: Optional[int] = None 655 | ) -> Tuple[int, int, int]: 656 | if _stop is None and _step is None: 657 | _item_start = const.BASE_RANGE_START 658 | _item_stop = _start 659 | _item_step = const.BASE_RANGE_STEP 660 | elif _stop is not None and _step is None: 661 | _item_start = _start 662 | _item_stop = _stop 663 | _item_step = const.BASE_RANGE_STEP 664 | else: 665 | _item_start = _start 666 | _item_stop = _stop 667 | _item_step = _step 668 | return _item_start, _item_stop, _item_step # type: ignore 669 | 670 | 671 | def _process_page_range( 672 | _item_start: int, 673 | _item_stop: int, 674 | *, 675 | limit: Annotated[int, ValueRange(1, 100)] 676 | ) -> Tuple[int, int, int]: 677 | _page_start = _item_start // limit 678 | _page_stop = _item_stop // limit + (1 if _item_stop % limit else 0) 679 | _page_step = const.BASE_RANGE_STEP 680 | return _page_start, _page_stop, _page_step 681 | 682 | 683 | def _compute_slices( 684 | _item_range: Tuple[int, int, int], 685 | _page_range: Tuple[int, int, int] 686 | ) -> List[slice]: 687 | """Compute slices for further subscription of page items. 688 | 689 | Usage: 690 | ``` 691 | slices = _compute_slices(_item_range, _page_range) 692 | for page in Paginator(...): 693 | for item in page.items(slices.pop()): 694 | ... 695 | ``` 696 | """ 697 | # Flat view of item indexes 698 | items = range(*_item_range) 699 | pages = range(*_page_range) 700 | # Reshaped view with grouping item indexes inside relevant page lists. 701 | reshaped = [[] for _ in pages] 702 | for i in items: 703 | page_number = i // const.BASE_LIMIT 704 | reshaped[pages.index(page_number)].append(i - page_number*const.BASE_LIMIT) 705 | 706 | slices: List[slice] = [] 707 | template = range(const.BASE_LIMIT) 708 | for page in reshaped: 709 | slices.append( 710 | slice( 711 | template.index(page[0]), 712 | template.index(page[-1]) + 1, 713 | _item_range[-1] 714 | ) 715 | ) 716 | 717 | # Reverse slices objects for for further usage of slices.pop() with 718 | # default behaviour. Without reversing slices list it must be popped from 719 | # head (slices.pop(0)), which will cause internal array shifting and memory 720 | # reallocations at every iteration. 721 | return list(reversed(slices)) 722 | -------------------------------------------------------------------------------- /sankaku/clients/http_client.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Dict, Optional 3 | 4 | from aiohttp import ClientSession 5 | from aiohttp_retry import ExponentialRetry, RetryClient 6 | from loguru import logger 7 | 8 | from sankaku import errors, constants as const 9 | from sankaku.constants import BASE_RETRIES 10 | from sankaku.models.http import ClientResponse 11 | from .abc import ABCHttpClient 12 | 13 | 14 | try: 15 | from aiohttp_socks import ProxyConnector as SocksProxyConnector # type: ignore 16 | except (ImportError, ModuleNotFoundError): 17 | SocksProxyConnector = None 18 | 19 | 20 | __all__ = ["HttpClient"] 21 | 22 | 23 | def _get_socks_connector() -> Optional[SocksProxyConnector]: # type: ignore 24 | if SocksProxyConnector is None: 25 | return None 26 | 27 | proxy = os.getenv("ALL_PROXY") or os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") # noqa: E501 28 | if proxy is None or not proxy.startswith("socks"): 29 | return None 30 | 31 | return SocksProxyConnector.from_url(proxy) 32 | 33 | 34 | class HttpClient(ABCHttpClient): 35 | def __init__(self) -> None: 36 | """HTTP client for API requests that instances use a single session.""" 37 | self.headers: Dict[str, str] = const.HEADERS.copy() 38 | 39 | socks_connector = _get_socks_connector() 40 | if socks_connector is not None: 41 | # use socks connector 42 | kwargs = {"connector": socks_connector} 43 | else: 44 | # aiohttp will read HTTP_PROXY and HTTPS_PROXY from env 45 | kwargs = {"trust_env": True} 46 | self._client_session: ClientSession = ClientSession(**kwargs) # type: ignore 47 | 48 | retry_options = ExponentialRetry(attempts=BASE_RETRIES) 49 | self.session: RetryClient = RetryClient( 50 | raise_for_status=False, 51 | retry_options=retry_options, 52 | client_session=self._client_session 53 | ) 54 | 55 | def __del__(self) -> None: 56 | if not self._client_session.closed and self._client_session.connector is not None: # noqa: E501 57 | self._client_session.connector.close() 58 | 59 | async def close(self) -> None: 60 | """There is no need to close client with single session.""" 61 | 62 | async def request(self, method: str, url: str, **kwargs) -> ClientResponse: 63 | """Make request to specified url.""" 64 | if kwargs.get("headers") is None: 65 | kwargs["headers"] = self.headers 66 | 67 | response = await self.session.request(method, url, **kwargs) 68 | logger.debug(f"Sent {method} request to {response.url}") 69 | 70 | if response.content_type != "application/json": 71 | raise errors.SankakuServerError( 72 | response.status, "Invalid response content type", 73 | content_type=response.content_type 74 | ) 75 | 76 | client_response = ClientResponse( 77 | response.status, 78 | response.ok, 79 | await response.json(encoding="utf-8"), 80 | ) 81 | response.close() 82 | logger.debug( 83 | f"Request {method} returned response with status " 84 | f"[{client_response.status}]: {client_response.json}", 85 | ) 86 | 87 | return client_response 88 | 89 | async def get(self, url: str, **kwargs) -> ClientResponse: 90 | """Send GET request to specified url.""" 91 | return await self.request("GET", url, **kwargs) 92 | 93 | async def post(self, url: str, **kwargs) -> ClientResponse: 94 | """Send POST request to specified url.""" 95 | return await self.request("POST", url, **kwargs) 96 | -------------------------------------------------------------------------------- /sankaku/constants.py: -------------------------------------------------------------------------------- 1 | """Necessary constants such as hardcoded headers, API urls and endpoints, 2 | default values of parameters etc. 3 | """ 4 | from typing import Dict 5 | 6 | HEADERS: Dict[str, str] = { 7 | "user-agent": ( 8 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " 9 | "(KHTML, like Gecko) Chrome/94.0.4606.85 YaBrowser/21.11.0.1996 " 10 | "Yowser/2.5 Safari/537.36" 11 | ), 12 | "content-type": "application/json; charset=utf-8", 13 | "x-requested-with": "com.android.browser", 14 | "accept-encoding": "gzip, deflate, br", 15 | "host": "capi-v2.sankakucomplex.com" 16 | } 17 | 18 | BASE_URL = "https://login.sankakucomplex.com" 19 | API_URL = "https://capi-v2.sankakucomplex.com" 20 | 21 | LOGIN_URL = f"{BASE_URL}/auth/token" 22 | POSTS_URL = f"{API_URL}/posts" 23 | AI_POSTS_URL = f"{API_URL}/ai_posts" 24 | TAGS_URL = f"{API_URL}/tags" 25 | BOOKS_URL = f"{API_URL}/pools" 26 | USERS_URL = f"{API_URL}/users" 27 | PROFILE_URL = f"{USERS_URL}/me" 28 | 29 | # Not fully completed urls for usage with str.format() 30 | COMMENTS_URL = f"{POSTS_URL}/{{post_id}}/comments" 31 | RELATED_BOOKS_URL = f"{API_URL}/post/{{post_id}}/pools" 32 | POST_URL = f"{POSTS_URL}/{{post_id}}" 33 | AI_POST_URL = f"{AI_POSTS_URL}/{{post_id}}" 34 | TAG_WIKI_URL = f"{API_URL}/tag-and-wiki{{ref}}/{{name_or_id}}" 35 | BOOK_URL = f"{BOOKS_URL}/{{book_id}}" 36 | USER_URL = f"{USERS_URL}{{ref}}/{{name_or_id}}" 37 | 38 | BASE_RPS = 3 39 | BASE_RPM = 180 40 | 41 | BASE_RANGE_START = 0 42 | BASE_RANGE_STEP = 1 43 | 44 | # Number that big enough to be ensured that iteration will continue 45 | # to the last item in sequence. 46 | LAST_RANGE_ITEM = 100_000 47 | 48 | BASE_LIMIT = 40 # Limit of items per page 49 | 50 | BASE_RETRIES = 3 51 | 52 | PAGE_ALLOWED_ERRORS = [ 53 | "snackbar__anonymous-recommendations-limit-reached", 54 | "snackbar__account_offset-forbidden" 55 | ] 56 | 57 | DEFAULT_TOKEN_TYPE = "Bearer" 58 | -------------------------------------------------------------------------------- /sankaku/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | __all__ = [ 5 | "SankakuError", 6 | "RateLimitError", 7 | "LoginRequirementError", 8 | "VideoDurationError", 9 | "SankakuServerError", 10 | "PaginatorLastPage", 11 | "PageNotFoundError", 12 | "AuthorizationError" 13 | ] 14 | 15 | 16 | class SankakuError(Exception): 17 | msg: str = "" 18 | 19 | def __init__(self, msg: Optional[str] = None) -> None: 20 | """Base error class for raising exceptions without any special params.""" 21 | self.msg = msg or self.msg 22 | 23 | def __repr__(self) -> str: 24 | return repr(self.msg) 25 | 26 | def __str__(self) -> str: 27 | return str(self.msg) 28 | 29 | 30 | class RateLimitError(SankakuError): 31 | msg = "Can't set both rps and rpm at once." 32 | 33 | 34 | class LoginRequirementError(SankakuError): 35 | msg = "You must be logged-in." 36 | 37 | 38 | class VideoDurationError(SankakuError): 39 | msg = "Argument is available only with video files." 40 | 41 | 42 | class PaginatorLastPage(SankakuError): # noqa: N818 43 | msg = "Last available page reached." 44 | 45 | 46 | class SankakuServerError(SankakuError): 47 | def __init__( 48 | self, 49 | status: Optional[int], 50 | msg: Optional[str] = None, 51 | **kwargs 52 | ) -> None: 53 | """Error class for parametrized exceptions.""" 54 | self.status = status 55 | self.kwargs = kwargs 56 | 57 | str_kwargs = ", ".join(f"{k}={v}" for k, v in self.kwargs.items()) 58 | delimiter = ": " if self.kwargs else "" 59 | self.msg = f"[{self.status}] {msg or self.msg}{delimiter}{str_kwargs}." 60 | 61 | def __repr__(self) -> str: 62 | return repr(self.msg) 63 | 64 | def __str__(self) -> str: 65 | return str(self.msg) 66 | 67 | 68 | class PageNotFoundError(SankakuServerError): 69 | msg = "Failed to fetch page with requested params" 70 | 71 | 72 | class AuthorizationError(SankakuServerError): 73 | msg = "Authorization failed" 74 | -------------------------------------------------------------------------------- /sankaku/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .pages import * # noqa: F403 2 | from .posts import * # noqa: F403 3 | from .tags import * # noqa: F403 4 | from .books import * # noqa: F403 5 | from .users import * # noqa: F403 6 | -------------------------------------------------------------------------------- /sankaku/models/base.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | __all__ = ["SankakuResponseModel"] 5 | 6 | 7 | class SankakuResponseModel(BaseModel, extra="forbid"): 8 | """Base model for sankaku JSON responses.""" 9 | -------------------------------------------------------------------------------- /sankaku/models/books.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Optional, List 5 | 6 | from sankaku import types 7 | from .base import SankakuResponseModel 8 | from .posts import Post 9 | from .tags import PostTag 10 | from .users import Author 11 | 12 | 13 | __all__ = ["PageBook", "Book"] 14 | 15 | 16 | class BookState(SankakuResponseModel): 17 | current_page: int 18 | sequence: int 19 | post_id: int 20 | series_id: Optional[int] 21 | created_at: datetime 22 | updated_at: datetime 23 | percent: int 24 | 25 | 26 | class PageBook(SankakuResponseModel): 27 | """Model that describes books on book pages.""" 28 | id: int # noqa: A003 29 | name_en: Optional[str] 30 | name_ja: Optional[str] 31 | description: str 32 | description_en: Optional[str] 33 | description_ja: Optional[str] 34 | created_at: datetime 35 | updated_at: datetime 36 | author: Optional[Author] 37 | is_public: bool 38 | is_active: bool 39 | is_flagged: bool 40 | post_count: int 41 | pages_count: int 42 | visible_post_count: int 43 | is_intact: bool 44 | rating: Optional[types.Rating] 45 | reactions: List # TODO: Search for books with non-empty reactions 46 | parent_id: Optional[int] 47 | has_children: Optional[bool] 48 | is_rating_locked: bool 49 | fav_count: int 50 | vote_count: int 51 | total_score: int 52 | comment_count: Optional[int] 53 | tags: List[PostTag] 54 | post_tags: List[PostTag] 55 | artist_tags: List[PostTag] 56 | genre_tags: List[PostTag] 57 | is_favorited: bool 58 | user_vote: Optional[int] 59 | posts: List[Optional[Post]] 60 | file_url: Optional[str] 61 | sample_url: Optional[str] 62 | preview_url: Optional[str] 63 | cover_post: Optional[Post] 64 | reading: Optional[BookState] 65 | is_premium: bool 66 | is_pending: bool 67 | is_raw: bool 68 | is_trial: bool 69 | redirect_to_signup: bool 70 | locale: str 71 | is_deleted: bool 72 | cover_post_id: Optional[int] 73 | name: Optional[str] 74 | parent_pool: Optional[PageBook] 75 | 76 | 77 | class Book(PageBook): 78 | """Model that describes specific book.""" 79 | child_pools: Optional[List[PageBook]] 80 | flagged_by_user: bool 81 | prem_post_count: int 82 | -------------------------------------------------------------------------------- /sankaku/models/http.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | 5 | __all__ = ["ClientResponse"] 6 | 7 | 8 | @dataclass() 9 | class ClientResponse: 10 | """Dataclass that preserves information from aiohttp ClientResponse.""" 11 | status: int 12 | ok: bool 13 | json: Any 14 | -------------------------------------------------------------------------------- /sankaku/models/pages.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Generic, TypeVar, List 3 | 4 | 5 | __all__ = ["Page"] 6 | 7 | _T = TypeVar("_T") 8 | 9 | 10 | @dataclass() 11 | class Page(Generic[_T]): 12 | """Model that describes page containing content with specific type.""" 13 | number: int 14 | items: List[_T] 15 | -------------------------------------------------------------------------------- /sankaku/models/posts.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from datetime import datetime 4 | from typing import Optional, List 5 | 6 | from pydantic import Field, field_validator 7 | 8 | from sankaku import types 9 | from sankaku.utils import convert_ts_to_datetime 10 | from .base import SankakuResponseModel 11 | from .tags import PostTag, GenerationDirectivesTag 12 | from .users import Author 13 | 14 | 15 | __all__ = ["Comment", "Post", "AIPost"] 16 | 17 | 18 | class GenerationDirectivesAspectRatio(SankakuResponseModel): 19 | type: str # noqa: A003 20 | width: int 21 | height: int 22 | 23 | 24 | class GenerationDirectivesRating(SankakuResponseModel): 25 | value: str 26 | default: str 27 | 28 | 29 | class GenerationDirectives(SankakuResponseModel): 30 | tags: Optional[List[GenerationDirectivesTag]] = None 31 | aspect_ratio: Optional[GenerationDirectivesAspectRatio] = None 32 | rating: Optional[GenerationDirectivesRating] = None 33 | negative_prompt: Optional[str] = None 34 | natural_input: Optional[str] = None 35 | denoising_strength: Optional[int] = None 36 | 37 | 38 | class AIGenerationDirectives(SankakuResponseModel): 39 | """Model that describes additional fields for AI-generated posts.""" 40 | width: int 41 | height: int 42 | prompt: str 43 | batch_size: int 44 | batch_count: int 45 | sampling_steps: int 46 | negative_prompt: str 47 | 48 | # The following fields can be missing in server JSON response 49 | version: Optional[str] = None 50 | 51 | 52 | class BasePost(SankakuResponseModel): 53 | """Model that contains minimum amount of information that all posts have.""" 54 | id: int # noqa: A003 55 | created_at: datetime 56 | rating: types.Rating 57 | status: str 58 | author: Author 59 | file_url: Optional[str] 60 | preview_url: Optional[str] 61 | width: int 62 | height: int 63 | file_size: int 64 | file_type: Optional[types.FileType] = None 65 | extension: Optional[str] = Field(alias="file_type", default=None) 66 | md5: str 67 | tags: List[PostTag] 68 | 69 | @field_validator("file_type", mode="before") 70 | @classmethod 71 | def get_file_type(cls, v) -> Optional[types.FileType]: 72 | return types.FileType(v.split("/")[0]) if v else None 73 | 74 | @field_validator("created_at", mode="before") 75 | @classmethod 76 | def normalize_datetime(cls, v) -> Optional[datetime]: 77 | return convert_ts_to_datetime(v) 78 | 79 | @field_validator("extension", mode="before") 80 | @classmethod 81 | def get_extension(cls, v) -> Optional[str]: 82 | return v.split("/")[-1] if v else None 83 | 84 | 85 | class Comment(SankakuResponseModel): 86 | """Model that describes comments related to posts if they are exist.""" 87 | id: int # noqa: A003 88 | created_at: datetime 89 | post_id: int 90 | author: Author 91 | body: str 92 | score: int 93 | parent_id: Optional[int] 94 | children: List[Comment] 95 | deleted: bool 96 | deleted_by: dict # Seen only empty dictionaries so IDK real type 97 | updated_at: Optional[datetime] 98 | can_reply: bool 99 | reason: None # Seen only None values so IDK real type 100 | 101 | 102 | class Post(BasePost): 103 | """Model that describes posts.""" 104 | sample_url: Optional[str] 105 | sample_width: int 106 | sample_height: int 107 | preview_width: Optional[int] 108 | preview_height: Optional[int] 109 | has_children: bool 110 | has_comments: bool 111 | has_notes: bool 112 | is_favorited: bool 113 | user_vote: Optional[int] 114 | parent_id: Optional[int] 115 | change: Optional[int] 116 | fav_count: int 117 | recommended_posts: int 118 | recommended_score: int 119 | vote_count: int 120 | total_score: int 121 | comment_count: Optional[int] 122 | source: Optional[str] 123 | in_visible_pool: bool 124 | is_premium: bool 125 | is_rating_locked: bool 126 | is_note_locked: bool 127 | is_status_locked: bool 128 | redirect_to_signup: bool 129 | reactions: List # TODO: Search for posts with non-empty reactions 130 | 131 | # Sequence can be missing when Post model used inside PageBook model 132 | sequence: Optional[int] = None 133 | 134 | video_duration: Optional[float] 135 | generation_directives: Optional[GenerationDirectives] 136 | 137 | 138 | class AIPost(BasePost): 139 | """Model that describes AI-generated posts. 140 | 141 | There is possibility that AI posts have the same fields as common posts, 142 | but premium account is needed to check it properly. So this model is 143 | actual for non-premium accounts. 144 | """ 145 | updated_at: Optional[datetime] 146 | post_associated_id: Optional[int] 147 | generation_directives: Optional[AIGenerationDirectives] 148 | 149 | @field_validator("created_at", "updated_at", mode="before") 150 | @classmethod 151 | def normalize_datetime(cls, v) -> Optional[datetime]: # noqa: D102 152 | return convert_ts_to_datetime(v) 153 | -------------------------------------------------------------------------------- /sankaku/models/tags.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, List 3 | 4 | from pydantic import Field, field_validator 5 | 6 | from sankaku import types 7 | from sankaku.utils import convert_ts_to_datetime 8 | from .base import SankakuResponseModel 9 | from .users import Author 10 | 11 | 12 | __all__ = ["PostTag", "PageTag", "Wiki", "WikiTag"] 13 | 14 | 15 | class BaseTag(SankakuResponseModel): 16 | """Model that contains minimum amount of information that all tags have.""" 17 | id: int # noqa: A003 18 | name: str 19 | name_en: str 20 | name_ja: Optional[str] 21 | type: types.TagType # noqa: A003 22 | post_count: int 23 | pool_count: int 24 | series_count: int 25 | rating: Optional[types.Rating] 26 | 27 | 28 | class GenerationDirectivesTag(BaseTag): 29 | count: int 30 | tag_name: str = Field(alias="tagName") 31 | translations: List[str] = Field(alias="tag_translations") 32 | 33 | 34 | class TagMixin(SankakuResponseModel): 35 | """Additional data that certain tags have.""" 36 | count: int 37 | tag_name: str = Field(alias="tagName") 38 | total_post_count: int 39 | total_pool_count: int 40 | 41 | 42 | class PostTag(BaseTag, TagMixin): 43 | """Model that describes tags related to posts.""" 44 | locale: Optional[str] 45 | version: Optional[int] 46 | 47 | 48 | class NestedTag(BaseTag): 49 | """Model that describes tags with specific relation to certain tag on tag page.""" 50 | post_count: int = Field(alias="postCount") 51 | cached_related: Optional[List[int]] = Field(alias="cachedRelated") 52 | cached_related_expires_on: datetime = Field(alias="cachedRelatedExpiresOn") 53 | type: types.TagType = Field(alias="tagType") # noqa: A003 54 | name_en: str = Field(alias="nameEn") 55 | name_ja: Optional[str] = Field(alias="nameJa") 56 | popularity_all: Optional[float] = Field(alias="scTagPopularityAll") 57 | quality_all: Optional[float] = Field(alias="scTagQualityAll") 58 | popularity_ero: Optional[float] = Field(alias="scTagPopularityEro") 59 | popularity_safe: Optional[float] = Field(alias="scTagPopularitySafe") 60 | quality_ero: Optional[float] = Field(alias="scTagQualityEro") 61 | quality_safe: Optional[float] = Field(alias="scTagQualitySafe") 62 | parent_tags: Optional[List[int]] = Field(alias="parentTags") 63 | child_tags: Optional[List[int]] = Field(alias="childTags") 64 | pool_count: int = Field(alias="poolCount") 65 | premium_post_count: int = Field(alias="premPostCount") 66 | non_premium_post_count: int = Field(alias="nonPremPostCount") 67 | premium_pool_count: int = Field(alias="premPoolCount") 68 | non_premium_pool_count: int = Field(alias="nonPremPoolCount") 69 | series_count: int = Field(alias="seriesCount") 70 | premium_series_count: int = Field(alias="premSeriesCount") 71 | non_premium_series_count: int = Field(alias="nonPremSeriesCount") 72 | is_trained: bool = Field(alias="isTrained") 73 | child: int 74 | parent: int 75 | version: Optional[int] 76 | 77 | @field_validator("cached_related", "parent_tags", "child_tags", mode="before") 78 | @classmethod 79 | def flatten(cls, v) -> Optional[List[int]]: 80 | """Flatten nested lists into one.""" 81 | if not v: 82 | return None 83 | tag_ids = v.split(",") if "," in v else v.split() 84 | try: 85 | return [int(tag_id) for tag_id in tag_ids] 86 | except ValueError: 87 | return None 88 | 89 | 90 | class BaseTranslations(SankakuResponseModel): 91 | """Model that contain minimum information about tag translations.""" 92 | lang: str 93 | translation: str 94 | 95 | 96 | class PageTagTranslations(BaseTranslations): 97 | """Model that describes page tag translations.""" 98 | root_id: int = Field(alias="rootId") 99 | 100 | 101 | class WikiTagTranslations(BaseTranslations): 102 | """Model that describes wiki tag translations.""" 103 | status: int 104 | opacity: float 105 | id: Optional[int] = None # noqa: A003 106 | 107 | 108 | class PageTag(PostTag): 109 | """Model that describes tags on tag page.""" 110 | translations: List[PageTagTranslations] 111 | related_tags: List[NestedTag] 112 | child_tags: List[NestedTag] 113 | parent_tags: List[NestedTag] 114 | 115 | 116 | class Wiki(SankakuResponseModel): 117 | """Model that describes wiki information for specific tag.""" 118 | id: int # noqa: A003 119 | title: str 120 | body: str 121 | created_at: datetime 122 | updated_at: Optional[datetime] 123 | author: Author = Field(alias="user") 124 | is_locked: bool 125 | version: int 126 | 127 | @field_validator("created_at", "updated_at", mode="before") 128 | @classmethod 129 | def normalize_datetime(cls, v) -> Optional[datetime]: # noqa: D102 130 | return convert_ts_to_datetime(v) 131 | 132 | 133 | class WikiTag(BaseTag, TagMixin): 134 | """Model that describes tag on wiki page.""" 135 | related_tags: List[PostTag] 136 | child_tags: List[PostTag] 137 | parent_tags: List[PostTag] 138 | alias_tags: List[PostTag] 139 | implied_tags: List[PostTag] 140 | translations: List[WikiTagTranslations] 141 | wiki: Wiki 142 | -------------------------------------------------------------------------------- /sankaku/models/users.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, List 3 | 4 | from pydantic import Field, field_validator 5 | 6 | from sankaku import types 7 | from .base import SankakuResponseModel 8 | 9 | 10 | __all__ = ["Author", "User", "ExtendedUser"] 11 | 12 | 13 | class BaseUser(SankakuResponseModel): 14 | """User profile with a minimum amount of information.""" 15 | id: int # noqa: A003 16 | name: str 17 | avatar: str 18 | avatar_rating: types.Rating 19 | 20 | 21 | class Author(BaseUser): 22 | """Model that describes users who are the authors of posts or wiki pages.""" 23 | 24 | 25 | class User(BaseUser): 26 | """User profile model for any user that has an account on website.""" 27 | level: int 28 | upload_limit: int 29 | created_at: datetime 30 | favs_are_private: bool 31 | avatar: str = Field(alias="avatar_url") 32 | post_upload_count: int 33 | pool_upload_count: int 34 | comment_count: int 35 | post_update_count: int 36 | note_update_count: int 37 | wiki_update_count: int 38 | forum_post_count: int 39 | pool_update_count: int 40 | series_update_count: int 41 | tag_update_count: int 42 | artist_update_count: int 43 | 44 | # The following fields can be missing in server JSON response 45 | last_logged_in_at: Optional[datetime] = None 46 | favorite_count: Optional[int] = None 47 | post_favorite_count: Optional[int] = None 48 | pool_favorite_count: Optional[int] = None 49 | vote_count: Optional[int] = None 50 | post_vote_count: Optional[int] = None 51 | pool_vote_count: Optional[int] = None 52 | recommended_posts_for_user: Optional[int] = None 53 | subscriptions: List[str] = [] 54 | 55 | 56 | class ExtendedUser(User): 57 | """Profile of the currently logged-in user.""" 58 | email: str 59 | hide_ads: bool 60 | subscription_level: int 61 | filter_content: bool 62 | has_mail: bool 63 | receive_dmails: bool 64 | email_verification_status: str 65 | is_verified: bool 66 | verifications_count: int 67 | blacklist_is_hidden: bool 68 | blacklisted_tags: List[str] 69 | blacklisted: List[str] 70 | mfa_method: int 71 | show_popup_version: Optional[int] 72 | credits: Optional[int] # noqa A003 73 | credits_subs: Optional[int] 74 | 75 | @field_validator("blacklisted_tags", mode="before") 76 | @classmethod 77 | def flatten_blacklisted_tags(cls, v) -> List[str]: 78 | """Flatten nested lists into one.""" 79 | return [tag[0] for tag in v] 80 | -------------------------------------------------------------------------------- /sankaku/paginators/__init__.py: -------------------------------------------------------------------------------- 1 | from .paginators import * # noqa: F403 2 | -------------------------------------------------------------------------------- /sankaku/paginators/abc.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Generic, TypeVar, AsyncIterator 3 | 4 | from sankaku import errors, models as mdl 5 | 6 | 7 | __all__ = ["ABCPaginator"] 8 | 9 | _T = TypeVar("_T") 10 | 11 | 12 | class ABCPaginator(ABC, Generic[_T]): 13 | @abstractmethod 14 | def __init__(self, *args, **kwargs) -> None: 15 | """Abstract paginator class.""" 16 | pass 17 | 18 | def __aiter__(self) -> AsyncIterator[mdl.Page[_T]]: 19 | return self 20 | 21 | async def __anext__(self) -> mdl.Page[_T]: 22 | try: 23 | return await self.next_page() 24 | except errors.PaginatorLastPage: 25 | raise StopAsyncIteration 26 | 27 | @abstractmethod 28 | async def next_page(self) -> mdl.Page[_T]: 29 | """Get paginator next page.""" 30 | 31 | @abstractmethod 32 | def complete_params(self) -> None: 33 | """Complete params passed to paginator for further use.""" 34 | -------------------------------------------------------------------------------- /sankaku/paginators/paginators.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional, TypeVar, List, Dict, Type 3 | 4 | from typing_extensions import Literal, Annotated 5 | 6 | from sankaku import models as mdl, constants as const, types, errors 7 | from sankaku.clients import HttpClient 8 | from sankaku.utils import ratelimit 9 | from sankaku.typedefs import ValueRange 10 | from .abc import ABCPaginator 11 | 12 | 13 | __all__ = [ 14 | "Paginator", 15 | "PostPaginator", 16 | "TagPaginator", 17 | "BookPaginator", 18 | "UserPaginator" 19 | ] 20 | 21 | _T = TypeVar("_T") 22 | 23 | 24 | class Paginator(ABCPaginator[_T]): 25 | def __init__( 26 | self, 27 | _start: int, 28 | _stop: Optional[int] = None, 29 | _step: Optional[int] = None, 30 | /, 31 | *, 32 | http_client: HttpClient, 33 | url: str, 34 | model: Type[_T], 35 | limit: Annotated[int, ValueRange(1, 100)] = const.BASE_LIMIT 36 | ) -> None: 37 | """Basic paginator for iteration in a certain range. 38 | Range of pages can be specified in the same way as when using built-in 39 | `range()`. 40 | 41 | Args: 42 | _start: Start of the sequence 43 | _stop: End of the sequence (except this value itself) 44 | _step: Step of the sequence 45 | http_client: Provider used for paginator to fetch pages from server 46 | url: Target API url 47 | model: Type of response model to be returned inside page items 48 | limit: Limit of items per each fetched page 49 | """ 50 | # TODO: Raise error if self._start less than or equal 0. 51 | if _stop is None and _step is None: 52 | self._start = const.BASE_RANGE_START 53 | self._stop = _start 54 | self._step = const.BASE_RANGE_STEP 55 | elif _stop is not None and _step is None: 56 | self._start = _start 57 | self._stop = _stop 58 | self._step = const.BASE_RANGE_STEP 59 | else: # Case when `_stop is not None and _step is not None`. 60 | self._start = _start 61 | self._stop = _stop 62 | self._step = _step 63 | self._current_page = self._start 64 | 65 | self.http_client = http_client 66 | self.url = url 67 | self.model = model 68 | self.limit = limit 69 | 70 | self.params: Dict[str, str] = {} 71 | self.complete_params() 72 | 73 | @ratelimit(rps=const.BASE_RPS) 74 | async def next_page(self) -> mdl.Page[_T]: 75 | """Get paginator next page.""" 76 | if self._current_page >= self._stop: # type: ignore 77 | raise errors.PaginatorLastPage 78 | 79 | response = await self.http_client.get(self.url, params=self.params) 80 | json_ = response.json 81 | if "code" in json_ and json_["code"] in const.PAGE_ALLOWED_ERRORS: 82 | raise errors.PaginatorLastPage 83 | elif "code" in json_: 84 | raise errors.SankakuServerError(response.status, **response.json) 85 | elif json_ == [] or (isinstance(json_, dict) and not json_["data"]): 86 | raise errors.PaginatorLastPage 87 | elif "data" in json_: 88 | response.json = json_["data"] 89 | 90 | self._current_page += self._step # type: ignore 91 | self.params["page"] = str(self._current_page + 1) 92 | return self._construct_page(response.json) 93 | 94 | def complete_params(self) -> None: 95 | """Complete params passed to paginator for further use.""" 96 | self.params["lang"] = "en" 97 | self.params["page"] = str(self._current_page + 1) 98 | self.params["limit"] = str(self.limit) 99 | 100 | def _construct_page(self, data: List[dict]) -> mdl.Page[_T]: 101 | """Construct and return page model.""" 102 | items = [self.model(**d) for d in data] 103 | return mdl.Page[_T]( 104 | number=self._current_page - self._step, # type: ignore 105 | items=items 106 | ) 107 | 108 | 109 | class PostPaginator(Paginator[mdl.Post]): 110 | def __init__( 111 | self, 112 | _start: int, 113 | _stop: Optional[int] = None, 114 | _step: Optional[int] = None, 115 | /, 116 | *, 117 | http_client: HttpClient, 118 | url: str = const.POSTS_URL, 119 | model: Type[mdl.Post] = mdl.Post, 120 | limit: Annotated[int, ValueRange(1, 100)] = const.BASE_LIMIT, 121 | order: Optional[types.PostOrder] = None, 122 | date: Optional[List[datetime]] = None, 123 | rating: Optional[types.Rating] = None, 124 | threshold: Optional[Annotated[int, ValueRange(1, 100)]] = None, 125 | hide_posts_in_books: Optional[Literal["in-larger-tags", "always"]] = None, 126 | file_size: Optional[types.FileSize] = None, 127 | file_type: Optional[types.FileType] = None, 128 | video_duration: Optional[List[int]] = None, 129 | recommended_for: Optional[str] = None, 130 | favorited_by: Optional[str] = None, 131 | tags: Optional[List[str]] = None, 132 | added_by: Optional[List[str]] = None, 133 | voted: Optional[str] = None 134 | ) -> None: 135 | """Paginator for iteration in a certain range of post pages. 136 | Range of pages can be specified in the same way as when using built-in 137 | `range()`. 138 | 139 | Args: 140 | _start: Start of the sequence 141 | _stop: End of the sequence (except this value itself) 142 | _step: Step of the sequence 143 | http_client: Provider used for paginator to fetch pages from server 144 | url: Target API url 145 | model: Type of response model to be returned inside page items 146 | limit: Limit of items per each fetched page 147 | order: Post order rule 148 | date: Date or range of dates 149 | rating: Post rating 150 | threshold: Vote (quality) filter of posts 151 | hide_posts_in_books: Whether show post from books or not 152 | file_size: Size (aspect ratio) of mediafile 153 | file_type: Type of mediafile in post 154 | video_duration: Video duration in seconds or in range of seconds 155 | recommended_for: Posts recommended for specified user 156 | favorited_by: Posts favorited by specified user 157 | tags: Tags available for search 158 | added_by: Posts uploaded by specified users 159 | voted: Posts voted by specified user 160 | """ 161 | self.order = order 162 | self.date = date 163 | self.rating = rating 164 | self.threshold = threshold 165 | self.hide_posts_in_books = hide_posts_in_books 166 | self.file_size = file_size 167 | self.file_type = file_type 168 | self.video_duration = video_duration 169 | self.recommended_for = recommended_for 170 | self.favorited_by = favorited_by 171 | self.tags = tags 172 | self.added_by = added_by 173 | self.voted = voted 174 | super().__init__( 175 | _start, 176 | _stop, 177 | _step, 178 | http_client=http_client, 179 | url=url, 180 | model=model, 181 | limit=limit 182 | ) 183 | 184 | def complete_params(self) -> None: # noqa: PLR0912 185 | """Complete params passed to paginator for further use.""" 186 | super().complete_params() 187 | if self.tags is None: 188 | self.tags = [] 189 | 190 | for k, v in self.__dict__.items(): 191 | if v is None: 192 | continue 193 | elif k in {"order", "rating", "file_type"} and v is not types.FileType.IMAGE: #noqa: E501 194 | self.tags.append(f"{k}:{v.value}") 195 | elif k in {"threshold", "recommended_for", "voted"}: 196 | self.tags.append(f"{k}:{v}") 197 | elif k == "file_size": 198 | self.tags.append(self.file_size.value) # type: ignore 199 | elif k == "date": 200 | date = "..".join(d.strftime("%Y-%m-%dT%H:%M") for d in self.date) # type: ignore # noqa: E501 201 | self.tags.append(f"date:{date}") 202 | elif k == "video_duration" and self.file_type is not types.FileType.VIDEO: # noqa 203 | raise errors.VideoDurationError 204 | elif k == "video_duration": 205 | duration = "..".join(str(sec) for sec in self.video_duration) # type: ignore # noqa: E501 206 | self.tags.append(f"duration:{duration}") 207 | elif k == "favorited_by": 208 | self.tags.append(f"fav:{self.favorited_by}") 209 | elif k == "added_by": 210 | for user in self.added_by: # type: ignore 211 | self.tags.append(f"user:{user}") 212 | 213 | if self.hide_posts_in_books is not None: 214 | self.params["hide_posts_in_books"] = self.hide_posts_in_books 215 | if self.tags: 216 | self.params["tags"] = " ".join(self.tags) 217 | 218 | 219 | class TagPaginator(Paginator[mdl.PageTag]): 220 | def __init__( 221 | self, 222 | _start: int, 223 | _stop: Optional[int] = None, 224 | _step: Optional[int] = None, 225 | /, 226 | *, 227 | http_client: HttpClient, 228 | url: str = const.TAGS_URL, 229 | model: Type[mdl.PageTag] = mdl.PageTag, 230 | limit: Annotated[int, ValueRange(1, 100)] = const.BASE_LIMIT, 231 | tag_type: Optional[types.TagType] = None, 232 | order: Optional[types.TagOrder] = None, 233 | rating: Optional[types.Rating] = None, 234 | max_post_count: Optional[int] = None, 235 | sort_parameter: Optional[types.SortParameter] = None, 236 | sort_direction: Optional[types.SortDirection] = None 237 | ) -> None: 238 | """Paginator for iteration in a certain range of tag pages. 239 | Range of pages can be specified in the same way as when using built-in 240 | `range()`. 241 | 242 | Args: 243 | _start: Start of the sequence 244 | _stop: End of the sequence (except this value itself) 245 | _step: Step of the sequence 246 | http_client: Provider used for paginator to fetch pages from server 247 | url: Target API url 248 | model: Type of response model to be returned inside page items 249 | limit: Limit of items per each fetched page 250 | tag_type: Tag type filter 251 | order: Tag order rule 252 | rating: Tag rating 253 | max_post_count: Upper threshold for number of posts with tags found 254 | sort_parameter: Tag sorting parameter 255 | sort_direction: Tag sorting direction 256 | """ 257 | self.tag_type = tag_type 258 | self.order = order 259 | self.rating = rating 260 | self.max_post_count = max_post_count 261 | self.sort_parameter = sort_parameter 262 | self.sort_direction = sort_direction or types.SortDirection.DESC 263 | super().__init__( 264 | _start, 265 | _stop, 266 | _step, 267 | http_client=http_client, 268 | url=url, 269 | model=model, 270 | limit=limit 271 | ) 272 | 273 | def complete_params(self) -> None: 274 | """Complete params passed to paginator for further use.""" 275 | super().complete_params() 276 | if self.tag_type is not None: 277 | self.params["types[]"] = str(self.tag_type.value) 278 | if self.order is not None: 279 | self.params["order"] = self.order.value 280 | if self.rating is not None: 281 | self.params["rating"] = self.rating.value 282 | if self.max_post_count is not None: 283 | self.params["amount"] = str(self.max_post_count) 284 | if self.sort_parameter is not None: 285 | self.params.update( 286 | sortBy=self.sort_parameter.value, 287 | sortDirection=self.sort_direction.value 288 | ) 289 | 290 | 291 | class BookPaginator(Paginator[mdl.PageBook]): 292 | def __init__( 293 | self, 294 | _start: int, 295 | _stop: Optional[int] = None, 296 | _step: Optional[int] = None, 297 | /, 298 | *, 299 | http_client: HttpClient, 300 | url: str = const.BOOKS_URL, 301 | model: Type[mdl.PageBook] = mdl.PageBook, 302 | limit: Annotated[int, ValueRange(1, 100)] = const.BASE_LIMIT, 303 | order: Optional[types.BookOrder] = None, 304 | rating: Optional[types.Rating] = None, 305 | recommended_for: Optional[str] = None, 306 | favorited_by: Optional[str] = None, 307 | tags: Optional[List[str]] = None, 308 | added_by: Optional[List[str]] = None, 309 | voted: Optional[str] = None 310 | ) -> None: 311 | """Paginator for iteration in a certain range of book (pool) pages. 312 | Range of pages can be specified in the same way as when using built-in 313 | `range()`. 314 | 315 | Args: 316 | _start: Start of the sequence 317 | _stop: End of the sequence (except this value itself) 318 | _step: Step of the sequence 319 | http_client: Provider used for paginator to fetch pages from server 320 | url: Target API url 321 | model: Type of response model to be returned inside page items 322 | limit: Limit of items per each fetched page 323 | order: Book order rule 324 | rating: Books rating 325 | recommended_for: Books recommended for specified user 326 | favorited_by: Books favorited by specified user 327 | tags: Tags available for search 328 | added_by: Books uploaded by specified users 329 | voted: Books voted by specified user 330 | """ 331 | self.order = order 332 | self.rating = rating 333 | self.recommended_for = recommended_for 334 | self.favorited_by = favorited_by 335 | self.tags = tags 336 | self.added_by = added_by 337 | self.voted = voted 338 | super().__init__( 339 | _start, 340 | _stop, 341 | _step, 342 | http_client=http_client, 343 | url=url, 344 | model=model, 345 | limit=limit 346 | ) 347 | 348 | def complete_params(self) -> None: 349 | """Complete params passed to paginator for further use.""" 350 | super().complete_params() 351 | if self.tags is None: 352 | self.tags = [] 353 | 354 | for k, v in self.__dict__.items(): 355 | if v is None: 356 | continue 357 | elif k in {"order", "rating"}: 358 | self.tags.append(f"{k}:{v.value}") 359 | elif k in {"recommended_for", "voted"}: 360 | self.tags.append(f"{k}:{v}") 361 | elif k == "favorited_by": 362 | self.tags.append(f"fav:{self.favorited_by}") 363 | elif k == "added_by": 364 | for user in self.added_by: # type: ignore[union-attr] 365 | self.tags.append(f"user:{user}") 366 | 367 | if self.tags: 368 | self.params["tags"] = " ".join(self.tags) 369 | 370 | 371 | class UserPaginator(Paginator[mdl.User]): 372 | def __init__( 373 | self, 374 | _start: int, 375 | _stop: Optional[int] = None, 376 | _step: Optional[int] = None, 377 | /, 378 | *, 379 | http_client: HttpClient, 380 | url: str = const.USERS_URL, 381 | model: Type[mdl.User] = mdl.User, 382 | limit: Annotated[int, ValueRange(1, 100)] = const.BASE_LIMIT, 383 | order: Optional[types.UserOrder] = None, 384 | level: Optional[types.UserLevel] = None 385 | ) -> None: 386 | """Paginator for iteration in a certain range of user profiles pages. 387 | Range of pages can be specified in the same way as when using built-in 388 | `range()`. 389 | 390 | Args: 391 | _start: Start of the sequence 392 | _stop: End of the sequence (except this value itself) 393 | _step: Step of the sequence 394 | http_client: Provider used for paginator to fetch pages from server 395 | url: Target API url 396 | model: Type of response model to be returned inside page items 397 | limit: Limit of items per each fetched page 398 | order: User order rule 399 | level: User level type 400 | """ 401 | self.order = order 402 | self.level = level 403 | super().__init__( 404 | _start, 405 | _stop, 406 | _step, 407 | http_client=http_client, 408 | url=url, 409 | model=model, 410 | limit=limit 411 | ) 412 | 413 | def complete_params(self) -> None: 414 | """Complete params passed to paginator for further use.""" 415 | super().complete_params() 416 | if self.order is not None: 417 | self.params["order"] = self.order.value 418 | if self.level is not None: 419 | self.params["level"] = str(self.level.value) 420 | -------------------------------------------------------------------------------- /sankaku/typedefs.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | try: 5 | from typing import TypedDict 6 | except (ImportError, ModuleNotFoundError): 7 | from typing_extensions import TypedDict 8 | 9 | 10 | __all__ = ["ValueRange", "Timestamp"] 11 | 12 | 13 | @dataclass(frozen=True) 14 | class ValueRange: 15 | min: int # noqa: A003 16 | max: int # noqa: A003 17 | 18 | 19 | class Timestamp(TypedDict): 20 | json_class: str 21 | s: Optional[int] 22 | n: int 23 | -------------------------------------------------------------------------------- /sankaku/types.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | __all__ = [ 5 | "Rating", 6 | "PostOrder", 7 | "SortParameter", 8 | "SortDirection", 9 | "TagOrder", 10 | "TagType", 11 | "FileType", 12 | "FileSize", 13 | "UserOrder", 14 | "UserLevel", 15 | "BookOrder" 16 | ] 17 | 18 | 19 | class Rating(str, Enum): 20 | SAFE = "s" 21 | QUESTIONABLE = "q" 22 | EXPLICIT = "e" 23 | 24 | 25 | class PostOrder(Enum): 26 | POPULARITY = "popularity" 27 | DATE = "date" 28 | QUALITY = "quality" 29 | RANDOM = "random" 30 | RECENTLY_FAVORITED = "recently_favorited" 31 | RECENTLY_VOTED = "recently_voted" 32 | 33 | 34 | class SortParameter(Enum): 35 | NAME = "name" 36 | TRANSLATIONS = "name_ja" 37 | TYPE = "type" 38 | RATING = "rating" 39 | BOOK_COUNT = "pool_count" 40 | POST_COUNT = "count" 41 | 42 | 43 | class SortDirection(Enum): 44 | ASC = "asc" 45 | DESC = "desc" 46 | 47 | 48 | class TagOrder(Enum): 49 | POPULARITY = "popularity" 50 | QUALITY = "quality" 51 | 52 | 53 | class TagType(Enum): 54 | ARTIST = 1 55 | COPYRIGHT = 3 56 | CHARACTER = 4 57 | GENERAL = 0 58 | MEDIUM = 8 59 | META = 9 60 | GENRE = 5 61 | STUDIO = 2 62 | 63 | 64 | class FileType(Enum): 65 | IMAGE = "image" # jpeg, png, webp formats 66 | GIF = "gif" # gif format 67 | VIDEO = "video" # mp4, webm formats 68 | 69 | 70 | class FileSize(Enum): 71 | LARGE = "large_filesize" 72 | HUGE = "extremely_large_filesize" 73 | LONG = "long_image" 74 | WALLPAPER = "wallpaper" 75 | A_RATIO_16_9 = "16:9_aspect_ratio" 76 | A_RATIO_4_3 = "4:3_aspect_ratio" 77 | A_RATIO_3_2 = "3:2_aspect_ratio" 78 | A_RATIO_1_1 = "1:1_aspect_ratio" 79 | 80 | 81 | class UserOrder(Enum): 82 | POSTS = "post_upload_count" 83 | FAVORITES = "favorite_count" 84 | NAME = "name" 85 | NEWEST = "newest" 86 | OLDEST = "oldest" 87 | LAST_SEEN = "active" 88 | 89 | 90 | class UserLevel(Enum): 91 | ADMIN = 50 92 | SYSTEM_USER = 45 93 | MODERATOR = 40 94 | JANITOR = 35 95 | CONTRIBUTOR = 33 96 | PRIVILEGED = 30 97 | MEMBER = 20 98 | BLOCKED = 10 99 | UNACTIVATED = 0 100 | 101 | 102 | class BookOrder(Enum): 103 | POPULARITY = "popularity" 104 | DATE = "date" 105 | QUALITY = "quality" 106 | RANDOM = "random" 107 | RECENTLY_FAVORITED = "recently_favorited" 108 | RECENTLY_VOTED = "recently_voted" 109 | -------------------------------------------------------------------------------- /sankaku/utils.py: -------------------------------------------------------------------------------- 1 | """Miscellaneous support functions that are used at different places.""" 2 | 3 | import asyncio 4 | from datetime import datetime 5 | from functools import wraps 6 | from typing import TypeVar, Optional, Callable, Awaitable 7 | 8 | from typing_extensions import ParamSpec 9 | 10 | from sankaku.errors import RateLimitError 11 | from sankaku.typedefs import Timestamp 12 | 13 | 14 | __all__ = ["ratelimit", "convert_ts_to_datetime"] 15 | 16 | _T = TypeVar("_T") 17 | _P = ParamSpec("_P") 18 | 19 | 20 | def ratelimit( 21 | *, 22 | rps: Optional[int] = None, 23 | rpm: Optional[int] = None 24 | ) -> Callable[[Callable[_P, Awaitable[_T]]], Callable[_P, Awaitable[_T]]]: 25 | """Limit the number of requests. 26 | 27 | Args: 28 | rps: Request per second 29 | rpm: Requests per minute 30 | """ 31 | if all(locals().values()): 32 | raise RateLimitError 33 | elif not any(locals().values()): 34 | raise TypeError("At least one argument must be specified.") 35 | 36 | sleep_time: float = (1 / rps) if rps else (60 / rpm) # type: ignore 37 | 38 | def wrapper(func: Callable[_P, Awaitable[_T]]) -> Callable[_P, Awaitable[_T]]: 39 | @wraps(func) 40 | async def inner(*args: _P.args, **kwargs: _P.kwargs) -> _T: 41 | await asyncio.sleep(sleep_time) 42 | return await func(*args, **kwargs) 43 | 44 | return inner 45 | 46 | return wrapper 47 | 48 | 49 | def convert_ts_to_datetime(ts: Timestamp) -> Optional[datetime]: 50 | """Convert timestamp in datetime dict into datetime class.""" 51 | if ts.get("s") is None: 52 | return None 53 | return datetime.utcfromtimestamp(ts["s"]).astimezone() # type: ignore 54 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import setuptools 5 | 6 | 7 | def _load_req(file: str): 8 | with open(file, "r", encoding="utf-8") as f: 9 | return [line.strip() for line in f.readlines() if line.strip()] 10 | 11 | 12 | requirements = _load_req("requirements.txt") 13 | 14 | _REQ_PATTERN = re.compile("^requirements-([a-zA-Z0-9_]+)\\.txt$") 15 | group_requirements = { 16 | item.group(1): _load_req(item.group(0)) 17 | for item in [_REQ_PATTERN.fullmatch(reqpath) for reqpath in os.listdir()] 18 | if item 19 | } 20 | 21 | setuptools.setup( 22 | name="sankaku", 23 | version="2.0.1", 24 | author="zerex290", 25 | author_email="zerex290@gmail.com", 26 | description="Asynchronous API wrapper for Sankaku Complex.", 27 | long_description=open("README.md", encoding="utf-8").read(), 28 | long_description_content_type="text/markdown", 29 | keywords="sankaku sankakucomplex api".split(), 30 | url="https://github.com/zerex290/sankaku", 31 | project_urls={"Issue Tracker": "https://github.com/zerex290/sankaku/issues"}, 32 | packages=setuptools.find_packages(exclude=["tests", "tests.*"]), 33 | license="MIT", 34 | classifiers=[ 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "License :: OSI Approved :: MIT License", 40 | "Operating System :: OS Independent", 41 | ], 42 | python_requires=">=3.8", 43 | install_requires=requirements, 44 | extras_require=group_requirements, 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerex290/sankaku/bf9e5f960d109326db5a5d38d04c96446a49ca75/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | 4 | import pytest 5 | 6 | from sankaku.clients import SankakuClient 7 | 8 | 9 | @pytest.fixture(scope="session") 10 | def event_loop(): # noqa: D103 11 | loop = asyncio.get_event_loop() 12 | yield loop 13 | loop.close() 14 | 15 | 16 | @pytest.fixture(scope="module") 17 | def nlclient() -> SankakuClient: 18 | """Client without performed authorization.""" 19 | 20 | return SankakuClient() 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | async def lclient() -> SankakuClient: 25 | """Client where authorization is performed.""" 26 | 27 | client = SankakuClient() 28 | await client.login( 29 | access_token=os.getenv("TOKEN"), 30 | login=os.getenv("LOGIN"), 31 | password=os.getenv("PASSWORD") 32 | ) 33 | return client 34 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zerex290/sankaku/bf9e5f960d109326db5a5d38d04c96446a49ca75/tests/models/__init__.py -------------------------------------------------------------------------------- /tests/models/test_books.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from sankaku.models import Book 6 | from sankaku import types 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["data", "expected"], 11 | [ 12 | ( 13 | { 14 | "id": 403306032, 15 | "name_en": "ABCBook", 16 | "name_ja": None, 17 | "description": "", 18 | "description_en": None, 19 | "description_ja": None, 20 | "created_at": "2021-09-28 18:07", 21 | "updated_at": "2022-06-18 13:29", 22 | "author": { 23 | "id": 2, 24 | "name": "anonymous", 25 | "avatar": "", 26 | "avatar_rating": "s" 27 | }, 28 | "is_public": False, 29 | "is_active": True, 30 | "is_flagged": False, 31 | "post_count": 50, 32 | "pages_count": 32, 33 | "visible_post_count": 25, 34 | "is_intact": True, 35 | "rating": "q", 36 | "reactions": [], 37 | "parent_id": None, 38 | "has_children": None, 39 | "is_rating_locked": False, 40 | "fav_count": 1350, 41 | "vote_count": 166, 42 | "total_score": 806, 43 | "comment_count": None, 44 | "tags": [], 45 | "post_tags": [], 46 | "artist_tags": [], 47 | "genre_tags": [], 48 | "is_favorited": False, 49 | "user_vote": None, 50 | "posts": [], 51 | "file_url": "URL", 52 | "sample_url": None, 53 | "preview_url": None, 54 | "cover_post": None, 55 | "reading": { 56 | "current_page": 17, 57 | "sequence": 15, 58 | "post_id": 23423, 59 | "series_id": None, 60 | "created_at": "2023-04-22 20:00", 61 | "updated_at": "2023-04-23 20:30", 62 | "percent": 93 63 | }, 64 | "is_premium": False, 65 | "is_pending": False, 66 | "is_raw": False, 67 | "is_trial": False, 68 | "redirect_to_signup": False, 69 | "locale": "en", 70 | "is_deleted": False, 71 | "cover_post_id": None, 72 | "name": "NAME", 73 | "parent_pool": None, 74 | "child_pools": None, 75 | "flagged_by_user": False, 76 | "prem_post_count": 0 77 | }, 78 | dict( 79 | id=403306032, 80 | name_en="ABCBook", 81 | name_ja=None, 82 | description="", 83 | description_en=None, 84 | description_ja=None, 85 | created_at=datetime(2021, 9, 28, 18, 7), 86 | updated_at=datetime(2022, 6, 18, 13, 29), 87 | author=dict( 88 | id=2, 89 | name="anonymous", 90 | avatar="", 91 | avatar_rating=types.Rating.SAFE 92 | ), 93 | is_public=False, 94 | is_active=True, 95 | is_flagged=False, 96 | post_count=50, 97 | pages_count=32, 98 | visible_post_count=25, 99 | is_intact=True, 100 | rating=types.Rating.QUESTIONABLE, 101 | reactions=[], 102 | parent_id=None, 103 | has_children=None, 104 | is_rating_locked=False, 105 | fav_count=1350, 106 | vote_count=166, 107 | total_score=806, 108 | comment_count=None, 109 | tags=[], 110 | post_tags=[], 111 | artist_tags=[], 112 | genre_tags=[], 113 | is_favorited=False, 114 | user_vote=None, 115 | posts=[], 116 | file_url="URL", 117 | sample_url=None, 118 | preview_url=None, 119 | cover_post=None, 120 | reading=dict( # Testing BookState model as well 121 | current_page=17, 122 | sequence=15, 123 | post_id=23423, 124 | series_id=None, 125 | created_at=datetime(2023, 4, 22, 20, 0), 126 | updated_at=datetime(2023, 4, 23, 20, 30), 127 | percent=93 128 | ), 129 | is_premium=False, 130 | is_pending=False, 131 | is_raw=False, 132 | is_trial=False, 133 | redirect_to_signup=False, 134 | locale="en", 135 | is_deleted=False, 136 | cover_post_id=None, 137 | name="NAME", 138 | parent_pool=None, 139 | child_pools=None, 140 | flagged_by_user=False, 141 | prem_post_count=0 142 | ) 143 | ) 144 | ] 145 | ) 146 | def test_book_model(data, expected): # noqa: D103 147 | assert Book(**data).model_dump() == expected 148 | 149 | -------------------------------------------------------------------------------- /tests/models/test_posts.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | from sankaku.models import Post, Comment, AIPost 6 | from sankaku import types 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["data", "expected"], 11 | [ 12 | ( 13 | { 14 | "id": 22144775, 15 | "rating": "q", 16 | "reactions": [], 17 | "status": "active", 18 | "author": { 19 | "id": 2, 20 | "name": "anonymous", 21 | "avatar": "URL", 22 | "avatar_rating": "s" 23 | }, 24 | "sample_url": "URL", 25 | "sample_width": 1399, 26 | "sample_height": 941, 27 | "preview_url": "URL", 28 | "preview_width": 300, 29 | "preview_height": 202, 30 | "file_url": "URL", 31 | "width": 5242, 32 | "height": 3525, 33 | "file_size": 8608194, 34 | "file_type": "image/jpeg", 35 | "created_at": { 36 | "json_class": "Time", 37 | "s": 1604093590, 38 | "n": 0 39 | }, 40 | "has_children": True, 41 | "has_comments": False, 42 | "has_notes": False, 43 | "is_favorited": False, 44 | "user_vote": None, 45 | "md5": "ab32849a455e9fca5e5fa24bd036d3e3", 46 | "parent_id": None, 47 | "change": 56235768, 48 | "fav_count": 92, 49 | "recommended_posts": -1, 50 | "recommended_score": 0, 51 | "vote_count": 20, 52 | "total_score": 94, 53 | "comment_count": None, 54 | "source": "", 55 | "in_visible_pool": False, 56 | "is_premium": False, 57 | "is_rating_locked": False, 58 | "is_note_locked": False, 59 | "is_status_locked": False, 60 | "redirect_to_signup": False, 61 | "sequence": None, 62 | "generation_directives": { 63 | "tags": [], 64 | "aspect_ratio": { 65 | "type": "-", 66 | "width": 100, 67 | "height": 150 68 | }, 69 | "rating": {"value": "G", "default": "s"}, 70 | "negative_prompt": "badTag1", 71 | "natural_input": "tag1 tag2", 72 | "denoising_strength": 32, 73 | 74 | }, 75 | "tags": [], 76 | "video_duration": None 77 | }, 78 | dict( 79 | id=22144775, 80 | rating=types.Rating.QUESTIONABLE, 81 | reactions=[], 82 | status="active", 83 | author=dict( 84 | id=2, 85 | name="anonymous", 86 | avatar="URL", 87 | avatar_rating=types.Rating.SAFE 88 | ), 89 | sample_url="URL", 90 | sample_width=1399, 91 | sample_height=941, 92 | preview_url="URL", 93 | preview_width=300, 94 | preview_height=202, 95 | file_url="URL", 96 | width=5242, 97 | height=3525, 98 | file_size=8608194, 99 | file_type=types.FileType.IMAGE, 100 | extension="jpeg", 101 | created_at=datetime(2020, 10, 30, 21, 33, 10).astimezone(), 102 | has_children=True, 103 | has_comments=False, 104 | has_notes=False, 105 | is_favorited=False, 106 | user_vote=None, 107 | md5="ab32849a455e9fca5e5fa24bd036d3e3", 108 | parent_id=None, 109 | change=56235768, 110 | fav_count=92, 111 | recommended_posts=-1, 112 | recommended_score=0, 113 | vote_count=20, 114 | total_score=94, 115 | comment_count=None, 116 | source="", 117 | in_visible_pool=False, 118 | is_premium=False, 119 | is_rating_locked=False, 120 | is_note_locked=False, 121 | is_status_locked=False, 122 | redirect_to_signup=False, 123 | sequence=None, 124 | generation_directives=dict( 125 | tags=[], 126 | aspect_ratio=dict( 127 | type="-", 128 | width=100, 129 | height=150 130 | ), 131 | rating={"value": "G", "default": "s"}, 132 | negative_prompt="badTag1", 133 | natural_input="tag1 tag2", 134 | denoising_strength=32, 135 | 136 | ), 137 | tags=[], 138 | video_duration=None, 139 | ) 140 | ) 141 | ] 142 | ) 143 | def test_post_model(data, expected): # noqa: D103 144 | assert Post(**data).model_dump() == expected 145 | 146 | 147 | @pytest.mark.parametrize( 148 | ["data", "expected"], 149 | [ 150 | ( 151 | { 152 | "id": 178711, 153 | "created_at": "2023-04-16T19:03:19.300Z", 154 | "post_id": 12345, 155 | "author": { 156 | "id": 99123, 157 | "name": "abcdef", 158 | "avatar": "", 159 | "avatar_rating": "q" 160 | }, 161 | "body": "Hello, World!", 162 | "score": 3, 163 | "parent_id": None, 164 | "children": [], 165 | "deleted": False, 166 | "deleted_by": {}, 167 | "updated_at": "2023-04-25T02:49:36.012Z", 168 | "can_reply": True, 169 | "reason": None 170 | }, 171 | dict( 172 | id=178711, 173 | created_at=datetime( 174 | 2023, 4, 16, 19, 3, 19, 300000, tzinfo=timezone.utc 175 | ), 176 | post_id=12345, 177 | author=dict( 178 | id=99123, 179 | name="abcdef", 180 | avatar="", 181 | avatar_rating=types.Rating.QUESTIONABLE 182 | ), 183 | body="Hello, World!", 184 | score=3, 185 | parent_id=None, 186 | children=[], 187 | deleted=False, 188 | deleted_by={}, 189 | updated_at=datetime( 190 | 2023, 4, 25, 2, 49, 36, 12000, tzinfo=timezone.utc 191 | ), 192 | can_reply=True, 193 | reason=None 194 | ) 195 | ) 196 | ] 197 | ) 198 | def test_comment_model(data, expected): # noqa: D103 199 | assert Comment(**data).model_dump() == expected 200 | 201 | 202 | @pytest.mark.parametrize( 203 | ["data", "expected"], 204 | [ 205 | ( 206 | { 207 | "id": 131, 208 | "created_at": { 209 | "json_class": "Time", 210 | "s": 1675452087, 211 | "n": 0 212 | }, 213 | "updated_at": { 214 | "json_class": "Time", 215 | "s": None, 216 | "n": 0 217 | }, 218 | "rating": "s", 219 | "status": "active", 220 | "author": { 221 | "id": 1, 222 | "name": "System", 223 | "avatar": "URL", 224 | "avatar_rating": "q" 225 | }, 226 | "file_url": "URL", 227 | "preview_url": "URL", 228 | "width": 512, 229 | "height": 512, 230 | "file_size": 331855, 231 | "file_type": "image/png", 232 | "post_associated_id": None, 233 | "generation_directives": { 234 | "width": 512, 235 | "height": 512, 236 | "prompt": "tatami", 237 | "batch_size": 50, 238 | "batch_count": 1, 239 | "sampling_steps": 50, 240 | "negative_prompt": "bad quality" 241 | }, 242 | "md5": "93b5f88ffe0b9ec49dd2d0b0289fd3ff", 243 | "tags": [] 244 | }, 245 | dict( 246 | id=131, 247 | created_at=datetime(2023, 2, 3, 19, 21, 27).astimezone(), 248 | updated_at=None, 249 | rating=types.Rating.SAFE, 250 | status="active", 251 | author=dict( 252 | id=1, 253 | name="System", 254 | avatar="URL", 255 | avatar_rating="q" 256 | ), 257 | file_url="URL", 258 | preview_url="URL", 259 | width=512, 260 | height=512, 261 | file_size=331855, 262 | file_type=types.FileType.IMAGE, 263 | extension="png", 264 | post_associated_id=None, 265 | generation_directives=dict( 266 | width=512, 267 | height=512, 268 | prompt="tatami", 269 | batch_size=50, 270 | batch_count=1, 271 | sampling_steps=50, 272 | negative_prompt="bad quality", 273 | version=None 274 | ), 275 | md5="93b5f88ffe0b9ec49dd2d0b0289fd3ff", 276 | tags=[] 277 | ) 278 | ) 279 | ] 280 | ) 281 | def test_ai_post_model(data, expected): # noqa: D103 282 | assert AIPost(**data).model_dump() == expected 283 | 284 | -------------------------------------------------------------------------------- /tests/models/test_tags.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | from sankaku.models import PostTag, PageTag, WikiTag 6 | from sankaku import types 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["data", "expected"], 11 | [ 12 | ( 13 | { 14 | "id": 1129497, 15 | "name_en": "hololive", 16 | "name_ja": "ホロライブ", 17 | "type": 3, 18 | "count": 182184, 19 | "post_count": 182184, 20 | "pool_count": 549, 21 | "series_count": 0, 22 | "locale": "en", 23 | "rating": "s", 24 | "version": 1, 25 | "tagName": "hololive", 26 | "total_post_count": 182184, 27 | "total_pool_count": 549, 28 | "name": "hololive" 29 | }, 30 | dict( 31 | id=1129497, 32 | name_en="hololive", 33 | name_ja="ホロライブ", 34 | type=types.TagType(3), 35 | count=182184, 36 | post_count=182184, 37 | pool_count=549, 38 | series_count=0, 39 | locale="en", 40 | rating=types.Rating.SAFE, 41 | version=1, 42 | tag_name="hololive", 43 | total_post_count=182184, 44 | total_pool_count=549, 45 | name="hololive" 46 | ) 47 | ), 48 | ( 49 | { 50 | "id": 1497, 51 | "name_en": "qwerty", 52 | "name_ja": None, 53 | "type": 5, 54 | "count": 182184, 55 | "post_count": 182184, 56 | "pool_count": 549, 57 | "series_count": 0, 58 | "locale": "en", 59 | "rating": None, 60 | "version": None, 61 | "tagName": "hololive", 62 | "total_post_count": 182184, 63 | "total_pool_count": 549, 64 | "name": "hololive" 65 | }, 66 | dict( 67 | id=1497, 68 | name_en="qwerty", 69 | name_ja=None, 70 | type=types.TagType.GENRE, 71 | count=182184, 72 | post_count=182184, 73 | pool_count=549, 74 | series_count=0, 75 | locale="en", 76 | rating=None, 77 | version=None, 78 | tag_name="hololive", 79 | total_post_count=182184, 80 | total_pool_count=549, 81 | name="hololive" 82 | ) 83 | ) 84 | ] 85 | ) 86 | def test_post_tag_model(data, expected): # noqa: D103 87 | assert PostTag(**data).model_dump() == expected 88 | 89 | 90 | @pytest.mark.parametrize( 91 | ["data", "expected"], 92 | [ 93 | ( 94 | { 95 | "id": 34240, 96 | "name_en": "female", 97 | "name_ja": "女性", 98 | "type": 0, 99 | "count": 10821550, 100 | "post_count": 10821550, 101 | "pool_count": 97939, 102 | "series_count": 0, 103 | "locale": "en", 104 | "rating": "s", 105 | "version": 1, 106 | "translations": [ 107 | { 108 | "rootId": 34240, 109 | "lang": "ja", 110 | "translation": "女性" 111 | } 112 | ], 113 | "tagName": "female", 114 | "total_post_count": 10821550, 115 | "total_pool_count": 97939, 116 | "name": "female", 117 | "related_tags": [], 118 | "child_tags": [ 119 | { 120 | "name": "yuri", 121 | "postCount": 337420, 122 | "cachedRelated": "209,3273,104803,462,3199", 123 | "cachedRelatedExpiresOn": "2023-07-06T12:36:01.109Z", 124 | "tagType": 5, 125 | "nameEn": "yuri", 126 | "nameJa": "百合", 127 | "scTagPopularityAll": 0.00072376704, 128 | "scTagQualityAll": 26.17608, 129 | "scTagPopularityEro": 0.0011845038, 130 | "scTagPopularitySafe": 0.0003316918, 131 | "scTagQualityEro": 25.622679, 132 | "scTagQualitySafe": 6.277924, 133 | "parentTags": "34240 104803", 134 | "childTags": "159972 674372 914037 920061", 135 | "poolCount": 21530, 136 | "rating": "q", 137 | "version": 363, 138 | "premPostCount": 0, 139 | "nonPremPostCount": 290179, 140 | "premPoolCount": 0, 141 | "nonPremPoolCount": 21467, 142 | "seriesCount": 0, 143 | "premSeriesCount": 0, 144 | "nonPremSeriesCount": 0, 145 | "isTrained": True, 146 | "child": 209, 147 | "parent": 34240, 148 | "id": 209 149 | } 150 | ], 151 | "parent_tags": [ 152 | { 153 | "name": "femdom", 154 | "postCount": 92372, 155 | "cachedRelated": "", 156 | "cachedRelatedExpiresOn": "2023-07-06T17:06:17.902Z", 157 | "tagType": 5, 158 | "nameEn": "femdom", 159 | "nameJa": None, 160 | "scTagPopularityAll": None, 161 | "scTagQualityAll": None, 162 | "scTagPopularityEro": None, 163 | "scTagPopularitySafe": None, 164 | "scTagQualityEro": None, 165 | "scTagQualitySafe": None, 166 | "parentTags": None, 167 | "childTags": None, 168 | "poolCount": 18729, 169 | "rating": None, 170 | "version": None, 171 | "premPostCount": 0, 172 | "nonPremPostCount": 74602, 173 | "premPoolCount": 0, 174 | "nonPremPoolCount": 18724, 175 | "seriesCount": 0, 176 | "premSeriesCount": 0, 177 | "nonPremSeriesCount": 0, 178 | "isTrained": True, 179 | "child": 3386, 180 | "parent": 34240, 181 | "id": 3386 182 | } 183 | ] 184 | }, 185 | dict( 186 | id=34240, 187 | name_en="female", 188 | name_ja="女性", 189 | type=types.TagType.GENERAL, 190 | count=10821550, 191 | post_count=10821550, 192 | pool_count=97939, 193 | series_count=0, 194 | locale="en", 195 | rating=types.Rating.SAFE, 196 | version=1, 197 | translations=[ 198 | dict( 199 | root_id=34240, 200 | lang="ja", 201 | translation="女性" 202 | ) 203 | ], 204 | tag_name="female", 205 | total_post_count=10821550, 206 | total_pool_count=97939, 207 | name="female", 208 | related_tags=[], 209 | child_tags=[ 210 | dict( 211 | name="yuri", 212 | post_count=337420, 213 | cached_related=[209, 3273, 104803, 462, 3199], 214 | cached_related_expires_on=datetime( 215 | 2023, 7, 6, 12, 36, 1, 109000, tzinfo=timezone.utc 216 | ), 217 | type=types.TagType(5), 218 | name_en="yuri", 219 | name_ja="百合", 220 | popularity_all=0.00072376704, 221 | quality_all=26.17608, 222 | popularity_ero=0.0011845038, 223 | popularity_safe=0.0003316918, 224 | quality_ero=25.622679, 225 | quality_safe=6.277924, 226 | parent_tags=[34240, 104803], 227 | child_tags=[159972, 674372, 914037, 920061], 228 | pool_count=21530, 229 | rating=types.Rating.QUESTIONABLE, 230 | version=363, 231 | premium_post_count=0, 232 | non_premium_post_count=290179, 233 | premium_pool_count=0, 234 | non_premium_pool_count=21467, 235 | series_count=0, 236 | premium_series_count=0, 237 | non_premium_series_count=0, 238 | is_trained=True, 239 | child=209, 240 | parent=34240, 241 | id=209 242 | ) 243 | ], 244 | parent_tags=[ 245 | dict( 246 | name="femdom", 247 | post_count=92372, 248 | cached_related=None, 249 | cached_related_expires_on=datetime( 250 | 2023, 7, 6, 17, 6, 17, 902000, tzinfo=timezone.utc 251 | ), 252 | type=types.TagType(5), 253 | name_en="femdom", 254 | name_ja=None, 255 | popularity_all=None, 256 | quality_all=None, 257 | popularity_ero=None, 258 | popularity_safe=None, 259 | quality_ero=None, 260 | quality_safe=None, 261 | parent_tags=None, 262 | child_tags=None, 263 | pool_count=18729, 264 | rating=None, 265 | version=None, 266 | premium_post_count=0, 267 | non_premium_post_count=74602, 268 | premium_pool_count=0, 269 | non_premium_pool_count=18724, 270 | series_count=0, 271 | premium_series_count=0, 272 | non_premium_series_count=0, 273 | is_trained=True, 274 | child=3386, 275 | parent=34240, 276 | id=3386 277 | ) 278 | ] 279 | ) 280 | ) 281 | ] 282 | ) 283 | def test_page_tag_model(data, expected): # noqa: D103 284 | assert PageTag(**data).model_dump() == expected 285 | 286 | 287 | @pytest.mark.parametrize( 288 | ["data", "expected"], 289 | [ 290 | ( 291 | { 292 | "id": 100, 293 | "name": "randosel", 294 | "name_en": "randosel", 295 | "name_ja": "ランドセル", 296 | "tagName": "randosel", 297 | "type": 0, 298 | "count": 24071, 299 | "post_count": 24071, 300 | "pool_count": 925, 301 | "series_count": 0, 302 | "rating": "s", 303 | "related_tags": [], 304 | "child_tags": [], 305 | "parent_tags": [], 306 | "alias_tags": [], 307 | "implied_tags": [], 308 | "translations": [ 309 | { 310 | "translation": "100 maitres de la peinture bishoujo", 311 | "lang": "fr", 312 | "status": 4, 313 | "opacity": 0.8 314 | } 315 | ], 316 | "total_post_count": 10, 317 | "total_pool_count": 5, 318 | "wiki": { 319 | "id": 4, 320 | "title": "randosel", 321 | "body": "The Japanese elementary school backpack.", 322 | "created_at": { 323 | "json_class": "Time", 324 | "s": 1226516733, 325 | "n": 0 326 | }, 327 | "updated_at": { 328 | "json_class": "Time", 329 | "s": None, 330 | "n": 0 331 | }, 332 | "user": { 333 | "id": 483218, 334 | "name": "SpaceEntity", 335 | "avatar": "URL", 336 | "avatar_rating": "s" 337 | }, 338 | "is_locked": False, 339 | "version": 6 340 | } 341 | }, 342 | dict( 343 | id=100, 344 | name="randosel", 345 | name_en="randosel", 346 | name_ja="ランドセル", 347 | tag_name="randosel", 348 | type=types.TagType.GENERAL, 349 | count=24071, 350 | post_count=24071, 351 | pool_count=925, 352 | series_count=0, 353 | rating=types.Rating("s"), 354 | related_tags=[], 355 | child_tags=[], 356 | parent_tags=[], 357 | alias_tags=[], 358 | implied_tags=[], 359 | translations=[ 360 | dict( 361 | translation="100 maitres de la peinture bishoujo", 362 | lang="fr", 363 | status=4, 364 | opacity=0.8, 365 | id=None 366 | ) 367 | ], 368 | total_post_count=10, 369 | total_pool_count=5, 370 | wiki=dict( 371 | id=4, 372 | title="randosel", 373 | body="The Japanese elementary school backpack.", 374 | created_at=datetime(2008, 11, 12, 19, 5, 33).astimezone(), 375 | updated_at=None, 376 | author=dict( 377 | id=483218, 378 | name="SpaceEntity", 379 | avatar="URL", 380 | avatar_rating=types.Rating.SAFE 381 | ), 382 | is_locked=False, 383 | version=6 384 | ) 385 | ) 386 | ) 387 | ] 388 | ) 389 | def test_wiki_tag_model(data, expected): # noqa: D103 390 | assert WikiTag(**data).model_dump() == expected 391 | 392 | -------------------------------------------------------------------------------- /tests/models/test_users.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | 3 | import pytest 4 | 5 | from sankaku.models import Author, User, ExtendedUser 6 | from sankaku import types 7 | 8 | 9 | @pytest.mark.parametrize( 10 | ["data", "expected"], 11 | [ 12 | ( 13 | { 14 | "id": 49321, 15 | "name": "afern", 16 | "avatar": "", 17 | "avatar_rating": "q" 18 | }, 19 | dict( 20 | id=49321, 21 | name="afern", 22 | avatar="", 23 | avatar_rating=types.Rating.QUESTIONABLE 24 | ) 25 | ) 26 | ] 27 | ) 28 | def test_author_model(data, expected): # noqa: D103 29 | assert Author(**data).model_dump() == expected 30 | 31 | 32 | @pytest.mark.parametrize( 33 | ["data", "expected"], 34 | [ 35 | ( 36 | { 37 | "last_logged_in_at": "2023-05-09T03:07:39.650Z", 38 | "favorite_count": 0, 39 | "post_favorite_count": 0, 40 | "pool_favorite_count": 0, 41 | "vote_count": 16, 42 | "post_vote_count": 16, 43 | "pool_vote_count": 0, 44 | "recommended_posts_for_user": "200", 45 | "subscriptions": ["a", "b"], 46 | "id": 49276, 47 | "name": "reichan", 48 | "level": 45, 49 | "upload_limit": 1000, 50 | "created_at": "2013-03-02T17:31:47.688Z", 51 | "favs_are_private": False, 52 | "avatar_url": "URL", 53 | "avatar_rating": "s", 54 | "post_upload_count": 2370825, 55 | "pool_upload_count": 0, 56 | "comment_count": 1, 57 | "post_update_count": 3297994, 58 | "note_update_count": 0, 59 | "wiki_update_count": 0, 60 | "forum_post_count": 0, 61 | "pool_update_count": 0, 62 | "series_update_count": 0, 63 | "tag_update_count": 0, 64 | "artist_update_count": 0 65 | }, 66 | dict( 67 | last_logged_in_at=datetime( 68 | 2023, 5, 9, 3, 7, 39, 650000, tzinfo=timezone.utc 69 | ), 70 | favorite_count=0, 71 | post_favorite_count=0, 72 | pool_favorite_count=0, 73 | vote_count=16, 74 | post_vote_count=16, 75 | pool_vote_count=0, 76 | recommended_posts_for_user=200, 77 | subscriptions=["a", "b"], 78 | id=49276, 79 | name="reichan", 80 | level=45, 81 | upload_limit=1000, 82 | created_at=datetime( 83 | 2013, 3, 2, 17, 31, 47, 688000, tzinfo=timezone.utc 84 | ), 85 | favs_are_private=False, 86 | avatar="URL", 87 | avatar_rating=types.Rating.SAFE, 88 | post_upload_count=2370825, 89 | pool_upload_count=0, 90 | comment_count=1, 91 | post_update_count=3297994, 92 | note_update_count=0, 93 | wiki_update_count=0, 94 | forum_post_count=0, 95 | pool_update_count=0, 96 | series_update_count=0, 97 | tag_update_count=0, 98 | artist_update_count=0 99 | ) 100 | ), 101 | ( 102 | { 103 | "id": 49276, 104 | "name": "reichan", 105 | "level": 45, 106 | "upload_limit": 1000, 107 | "created_at": "2013-03-02T17:31:47.688Z", 108 | "favs_are_private": False, 109 | "avatar_url": "URL", 110 | "avatar_rating": "s", 111 | "post_upload_count": 2370825, 112 | "pool_upload_count": 0, 113 | "comment_count": 1, 114 | "post_update_count": 3297994, 115 | "note_update_count": 0, 116 | "wiki_update_count": 0, 117 | "forum_post_count": 0, 118 | "pool_update_count": 0, 119 | "series_update_count": 0, 120 | "tag_update_count": 0, 121 | "artist_update_count": 0 122 | }, 123 | dict( 124 | last_logged_in_at=None, 125 | favorite_count=None, 126 | post_favorite_count=None, 127 | pool_favorite_count=None, 128 | vote_count=None, 129 | post_vote_count=None, 130 | pool_vote_count=None, 131 | recommended_posts_for_user=None, 132 | subscriptions=[], 133 | id=49276, 134 | name="reichan", 135 | level=45, 136 | upload_limit=1000, 137 | created_at=datetime( 138 | 2013, 3, 2, 17, 31, 47, 688000, tzinfo=timezone.utc 139 | ), 140 | favs_are_private=False, 141 | avatar="URL", 142 | avatar_rating=types.Rating.SAFE, 143 | post_upload_count=2370825, 144 | pool_upload_count=0, 145 | comment_count=1, 146 | post_update_count=3297994, 147 | note_update_count=0, 148 | wiki_update_count=0, 149 | forum_post_count=0, 150 | pool_update_count=0, 151 | series_update_count=0, 152 | tag_update_count=0, 153 | artist_update_count=0 154 | ) 155 | ) 156 | ] 157 | ) 158 | def test_user_model(data, expected): # noqa: D103 159 | assert User(**data).model_dump() == expected 160 | 161 | 162 | @pytest.mark.parametrize( 163 | ["data", "expected"], 164 | [ 165 | ( 166 | { 167 | "id": 17488, 168 | "name": "ABC", 169 | "avatar_url": "", 170 | "avatar_rating": "e", 171 | "last_logged_in_at": "2022-04-09T17:31:47.658Z", 172 | "favorite_count": 143, 173 | "post_favorite_count": 198, 174 | "pool_favorite_count": 9, 175 | "vote_count": 27, 176 | "post_vote_count": 2, 177 | "pool_vote_count": 6, 178 | "recommended_posts_for_user": 13, 179 | "subscriptions": [], 180 | "level": 20, 181 | "upload_limit": 381, 182 | "created_at": "2015-07-08T23:57:16.723Z", 183 | "favs_are_private": True, 184 | "post_upload_count": 0, 185 | "pool_upload_count": 0, 186 | "comment_count": 0, 187 | "post_update_count": 0, 188 | "note_update_count": 0, 189 | "wiki_update_count": 0, 190 | "forum_post_count": 0, 191 | "pool_update_count": 0, 192 | "series_update_count": 0, 193 | "tag_update_count": 0, 194 | "artist_update_count": 0, 195 | "show_popup_version": 1, 196 | "credits": 0, 197 | "credits_subs": 0, 198 | "email": "ABCDEFU@eksdee.xyz", 199 | "hide_ads": False, 200 | "subscription_level": 0, 201 | "filter_content": False, 202 | "has_mail": False, 203 | "receive_dmails": True, 204 | "email_verification_status": "verified", 205 | "is_verified": True, 206 | "verifications_count": 2, 207 | "blacklist_is_hidden": True, 208 | "blacklisted_tags": [ 209 | ["loli"], 210 | ["shota"], 211 | ["yaoi"] 212 | ], 213 | "blacklisted": [ 214 | "loli\nshota\nyaoi" 215 | ], 216 | "mfa_method": 1 217 | }, 218 | dict( 219 | id=17488, 220 | name="ABC", 221 | avatar="", 222 | avatar_rating=types.Rating.EXPLICIT, 223 | last_logged_in_at=datetime( 224 | 2022, 4, 9, 17, 31, 47, 658000, tzinfo=timezone.utc 225 | ), 226 | favorite_count=143, 227 | post_favorite_count=198, 228 | pool_favorite_count=9, 229 | vote_count=27, 230 | post_vote_count=2, 231 | pool_vote_count=6, 232 | recommended_posts_for_user=13, 233 | subscriptions=[], 234 | level=20, 235 | upload_limit=381, 236 | created_at=datetime( 237 | 2015, 7, 8, 23, 57, 16, 723000, tzinfo=timezone.utc 238 | ), 239 | favs_are_private=True, 240 | post_upload_count=0, 241 | pool_upload_count=0, 242 | comment_count=0, 243 | post_update_count=0, 244 | note_update_count=0, 245 | wiki_update_count=0, 246 | forum_post_count=0, 247 | pool_update_count=0, 248 | series_update_count=0, 249 | tag_update_count=0, 250 | artist_update_count=0, 251 | show_popup_version=1, 252 | credits=0, 253 | credits_subs=0, 254 | email="ABCDEFU@eksdee.xyz", 255 | hide_ads=False, 256 | subscription_level=0, 257 | filter_content=False, 258 | has_mail=False, 259 | receive_dmails=True, 260 | email_verification_status="verified", 261 | is_verified=True, 262 | verifications_count=2, 263 | blacklist_is_hidden=True, 264 | blacklisted_tags=["loli", "shota", "yaoi"], 265 | blacklisted=["loli\nshota\nyaoi"], 266 | mfa_method=1 267 | ) 268 | 269 | ) 270 | ] 271 | ) 272 | def test_extended_user_model(data, expected): # noqa: D103 273 | assert ExtendedUser(**data).model_dump() == expected 274 | -------------------------------------------------------------------------------- /tests/test_clients.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from sankaku import errors, models as mdl, types 6 | from sankaku.clients import SankakuClient 7 | 8 | 9 | class TestBaseClient: 10 | @pytest.mark.parametrize( 11 | ["data", "expected"], 12 | [ 13 | ({"access_token": "invalid"}, errors.AuthorizationError), 14 | ({"login": "invalid", "password": "invalid"}, errors.AuthorizationError), 15 | ({}, errors.SankakuError) 16 | ] 17 | ) 18 | async def test_login_with_invalid_data( # noqa: D102 19 | self, 20 | nlclient: SankakuClient, 21 | data, 22 | expected 23 | ): 24 | with pytest.raises(expected): 25 | await nlclient.login(**data) 26 | 27 | async def test_login_with_valid_data(self, lclient: SankakuClient): # noqa: D102 28 | assert isinstance(lclient.profile, mdl.ExtendedUser) 29 | 30 | 31 | class TestPostClient: 32 | async def test_browse_default(self, nlclient: SankakuClient): 33 | """Default behaviour when unauthorized user don't set any arguments.""" 34 | assert isinstance(await nlclient.browse_posts(1).__anext__(), mdl.Post) 35 | 36 | @pytest.mark.parametrize( 37 | ["file_type", "video_duration", "expected"], 38 | [(types.FileType.IMAGE, [1, 60], errors.VideoDurationError)] 39 | ) 40 | async def test_browse_with_incompatible_args( 41 | self, 42 | nlclient: SankakuClient, 43 | file_type, 44 | video_duration, 45 | expected 46 | ): 47 | """Case when arguments are incompatible.""" 48 | with pytest.raises(expected): 49 | await nlclient.browse_posts( 50 | 1, 51 | file_type=file_type, 52 | video_duration=video_duration 53 | ).__anext__() 54 | 55 | @pytest.mark.parametrize( 56 | [ 57 | "order", "date", "rating", 58 | "threshold", "hide_posts_in_books", 59 | "file_size", "file_type", 60 | "video_duration", "recommended_for", 61 | "favorited_by", "tags", 62 | "added_by", "voted" 63 | 64 | ], 65 | [ 66 | ( 67 | types.PostOrder.POPULARITY, None, None, 68 | None, "always", 69 | None, None, 70 | None, None, 71 | None, None, 72 | None, None 73 | ), 74 | ( 75 | types.PostOrder.QUALITY, None, types.Rating.EXPLICIT, 76 | None, "in-larger-tags", 77 | None, types.FileType.IMAGE, 78 | None, "Nigredo", 79 | None, None, 80 | None, None 81 | ), 82 | ( 83 | types.PostOrder.DATE, [datetime(2018, 6, 16)], None, 84 | 4, None, 85 | None, types.FileType.VIDEO, 86 | [1, 900], None, 87 | None, ["animated"], 88 | ["anonymous"], "Nigredo" 89 | ), 90 | ( 91 | types.PostOrder.DATE, None, None, 92 | 4, None, 93 | types.FileSize.LARGE, None, 94 | None, None, 95 | "Nigredo", ["female", "solo"], 96 | None, None 97 | ) 98 | ] 99 | ) 100 | async def test_browse_with_random_args( # noqa: D102 101 | self, 102 | lclient: SankakuClient, 103 | order, 104 | date, 105 | rating, 106 | threshold, 107 | hide_posts_in_books, 108 | file_size, 109 | file_type, 110 | video_duration, 111 | recommended_for, 112 | favorited_by, 113 | tags, 114 | added_by, 115 | voted 116 | ): 117 | post = await lclient.browse_posts( 118 | 1, 119 | order=order, 120 | date=date, 121 | rating=rating, 122 | threshold=threshold, 123 | hide_posts_in_books=hide_posts_in_books, 124 | file_size=file_size, 125 | file_type=file_type, 126 | video_duration=video_duration, 127 | recommended_for=recommended_for, 128 | favorited_by=favorited_by, 129 | tags=tags, 130 | added_by=added_by, 131 | voted=voted 132 | ).__anext__() 133 | assert isinstance(post, mdl.Post) 134 | 135 | async def test_get_favorited_posts_unauthorized(self, nlclient: SankakuClient): # noqa: D102, E501 136 | with pytest.raises(errors.LoginRequirementError): 137 | await nlclient.get_favorited_posts(1).__anext__() 138 | 139 | async def test_get_favorited_posts_authorized(self, lclient: SankakuClient): # noqa: D102, E501 140 | assert isinstance(await lclient.get_favorited_posts(1).__anext__(), mdl.Post) 141 | 142 | async def test_get_top_posts(self, nlclient: SankakuClient): # noqa: D102 143 | assert isinstance(await nlclient.get_top_posts(1).__anext__(), mdl.Post) 144 | 145 | async def test_get_popular_posts(self, nlclient: SankakuClient): # noqa: D102 146 | assert isinstance(await nlclient.get_popular_posts(1).__anext__(), mdl.Post) 147 | 148 | async def test_get_recommended_posts_unauthorized(self, nlclient: SankakuClient): # noqa: D102, E501 149 | with pytest.raises(errors.LoginRequirementError): 150 | await nlclient.get_recommended_posts(1).__anext__() 151 | 152 | async def test_get_recommended_posts_authorized(self, lclient: SankakuClient): # noqa: D102, E501 153 | assert isinstance(await lclient.get_recommended_posts(1).__anext__(), mdl.Post) 154 | 155 | @pytest.mark.parametrize(["post_id"], [(32948875,)]) 156 | async def test_get_similar_posts(self, lclient: SankakuClient, post_id): # noqa: D102, E501 157 | assert isinstance( 158 | await lclient.get_similar_posts(1, post_id=post_id).__anext__(), 159 | mdl.Post 160 | ) 161 | 162 | @pytest.mark.parametrize(["post_id"], [(33108291,)]) 163 | async def test_get_post_comments(self, lclient: SankakuClient, post_id): # noqa: D102, E501 164 | assert isinstance( 165 | await lclient.get_post_comments(post_id).__anext__(), 166 | mdl.Comment 167 | ) 168 | 169 | @pytest.mark.parametrize(["post_id"], [(32948875,), (33108291,)]) 170 | async def test_get_post(self, nlclient: SankakuClient, post_id): # noqa: D102 171 | post = await nlclient.get_post(post_id) 172 | assert isinstance(post, mdl.Post) 173 | 174 | async def test_get_non_existent_post(self, lclient: SankakuClient): # noqa: D102 175 | with pytest.raises(errors.PageNotFoundError): 176 | await lclient.get_post(-10_000) 177 | 178 | async def test_create_post(self, lclient: SankakuClient): # noqa: D102 179 | with pytest.raises(NotImplementedError): 180 | await lclient.create_post() 181 | 182 | 183 | class TestAIClient: 184 | async def test_browse_default(self, nlclient: SankakuClient): 185 | """Default behaviour when unauthorized user don't set any arguments.""" 186 | assert isinstance(await nlclient.browse_ai_posts(1).__anext__(), mdl.AIPost) 187 | 188 | @pytest.mark.parametrize(["post_id"], [(123,), (1721,)]) 189 | async def test_get_ai_post(self, nlclient: SankakuClient, post_id): # noqa: D102 190 | post = await nlclient.get_ai_post(post_id,) 191 | assert isinstance(post, mdl.AIPost) 192 | 193 | async def test_get_non_existent_ai_post(self, lclient: SankakuClient): # noqa: D102 194 | with pytest.raises(errors.PageNotFoundError): 195 | await lclient.get_ai_post(-10_000) 196 | 197 | async def test_create_ai_post(self, lclient: SankakuClient): # noqa: D102 198 | with pytest.raises(NotImplementedError): 199 | await lclient.create_ai_post() 200 | 201 | 202 | class TestTagClient: 203 | async def test_browse_default(self, nlclient: SankakuClient): 204 | """Default behaviour when unauthorized user don't set any arguments.""" 205 | assert isinstance(await nlclient.browse_tags(1).__anext__(), mdl.PageTag) 206 | 207 | @pytest.mark.parametrize( 208 | [ 209 | "tag_type", "order", 210 | "rating", "max_post_count", 211 | "sort_parameter", "sort_direction" 212 | ], 213 | [ 214 | ( 215 | types.TagType.CHARACTER, None, 216 | types.Rating.SAFE, None, 217 | None, types.SortDirection.DESC 218 | ), 219 | ( 220 | types.TagType.COPYRIGHT, types.TagOrder.POPULARITY, 221 | types.Rating.QUESTIONABLE, None, 222 | None, types.SortDirection.ASC 223 | ), 224 | ( 225 | None, None, 226 | types.Rating.QUESTIONABLE, 2_537_220, 227 | types.SortParameter.NAME, types.SortDirection.ASC 228 | ) 229 | ] 230 | ) 231 | async def test_browse_with_random_args( # noqa: D102 232 | self, 233 | lclient: SankakuClient, 234 | tag_type, 235 | order, 236 | rating, 237 | max_post_count, 238 | sort_parameter, 239 | sort_direction 240 | ): 241 | tag = await lclient.browse_tags( 242 | 1, 243 | tag_type=tag_type, 244 | order=order, 245 | rating=rating, 246 | max_post_count=max_post_count, 247 | sort_parameter=sort_parameter, 248 | sort_direction=sort_direction 249 | ).__anext__() 250 | assert isinstance(tag, mdl.PageTag) 251 | 252 | @pytest.mark.parametrize(["name_or_id"], [("animated",), (100,)]) 253 | async def test_get_tag(self, lclient: SankakuClient, name_or_id): # noqa: D102 254 | wiki_tag = await lclient.get_tag(name_or_id) 255 | assert isinstance(wiki_tag, mdl.WikiTag) 256 | 257 | async def test_get_non_existent_tag(self, lclient: SankakuClient): # noqa: D102 258 | with pytest.raises(errors.PageNotFoundError): 259 | await lclient.get_tag(-10_000) 260 | 261 | 262 | class TestBookClient: 263 | async def test_browse_default(self, nlclient: SankakuClient): # noqa: D102 264 | assert isinstance(await nlclient.browse_books(1).__anext__(), mdl.PageBook) 265 | 266 | @pytest.mark.parametrize( 267 | [ 268 | "order", "rating", "recommended_for", 269 | "favorited_by", "tags", "added_by", 270 | "voted" 271 | ], 272 | [ 273 | ( 274 | types.BookOrder.RANDOM, types.Rating.EXPLICIT, "reichan", 275 | None, None, None, None 276 | ), 277 | ( 278 | types.BookOrder.POPULARITY, None, None, 279 | "Nigredo", None, None, "Nigredo" 280 | ), 281 | ( 282 | None, None, None, 283 | None, ["genshin_impact"], ["yanququ"], None 284 | ) 285 | ] 286 | ) 287 | async def test_browse_with_random_args( # noqa: D102 288 | self, 289 | lclient: SankakuClient, 290 | order, 291 | rating, 292 | recommended_for, 293 | favorited_by, 294 | tags, 295 | added_by, 296 | voted 297 | ): 298 | book = await lclient.browse_books( 299 | 1, 300 | order=order, 301 | rating=rating, 302 | recommended_for=recommended_for, 303 | favorited_by=favorited_by, 304 | tags=tags, 305 | added_by=added_by, 306 | voted=voted 307 | ).__anext__() 308 | assert isinstance(book, mdl.PageBook) 309 | 310 | async def test_favorited_books_unauthorized(self, nlclient: SankakuClient): # noqa: D102, E501 311 | with pytest.raises(errors.LoginRequirementError): 312 | await nlclient.get_favorited_books(1).__anext__() 313 | 314 | async def test_favorited_books_authorized(self, lclient: SankakuClient): # noqa: D102, E501 315 | assert isinstance( 316 | await lclient.get_favorited_books(1).__anext__(), 317 | mdl.PageBook 318 | ) 319 | 320 | async def test_recommended_books_unauthorized(self, nlclient: SankakuClient): # noqa: D102, E501 321 | with pytest.raises(errors.LoginRequirementError): 322 | await nlclient.get_recommended_books(1).__anext__() 323 | 324 | async def test_recommended_books_authorized(self, lclient: SankakuClient): # noqa: D102, E501 325 | assert isinstance( 326 | await lclient.get_recommended_books(1).__anext__(), 327 | mdl.PageBook 328 | ) 329 | 330 | async def test_recently_read_books_unauthorized(self, nlclient: SankakuClient): # noqa: D102, E501 331 | with pytest.raises(errors.LoginRequirementError): 332 | await nlclient.get_recently_read_books(1).__anext__() 333 | 334 | async def test_recently_read_books_authorized(self, lclient: SankakuClient): # noqa: D102, E501 335 | assert isinstance( 336 | await lclient.get_recently_read_books(1).__anext__(), 337 | mdl.PageBook 338 | ) 339 | 340 | @pytest.mark.parametrize(["post_id"], [(27038477,)]) 341 | async def test_get_related_books(self, lclient: SankakuClient, post_id): # noqa: D102, E501 342 | assert isinstance( 343 | await lclient.get_related_books(1, post_id=post_id).__anext__(), 344 | mdl.PageBook 345 | ) 346 | 347 | @pytest.mark.parametrize(["book_id"], [(1000,)]) 348 | async def test_get_book(self, nlclient: SankakuClient, book_id): # noqa: D102 349 | book = await nlclient.get_book(book_id) 350 | assert isinstance(book, mdl.Book) 351 | 352 | async def test_get_non_existent_book(self, lclient: SankakuClient): # noqa: D102 353 | with pytest.raises(errors.PageNotFoundError): 354 | await lclient.get_book(-10_000) 355 | 356 | 357 | class TestUserClient: 358 | async def test_browse_users(self, nlclient: SankakuClient): 359 | """Default behaviour when unauthorized user don't set any arguments.""" 360 | 361 | assert isinstance(await nlclient.browse_users(1).__anext__(), mdl.User) 362 | assert isinstance( 363 | await nlclient.browse_users(1, level=types.UserLevel.MEMBER).__anext__(), 364 | mdl.User 365 | ) 366 | 367 | @pytest.mark.parametrize(["name_or_id"], [("anonymous",), (1423490,)]) 368 | async def test_get_user(self, nlclient: SankakuClient, name_or_id): # noqa: D102 369 | assert isinstance(await nlclient.get_user(name_or_id), mdl.User) 370 | 371 | @pytest.mark.parametrize(["name_or_id"], [("!@#sdcvjkj|",), (-1000,)]) 372 | async def test_get_user_with_wrong_name_or_id( # noqa: D102 373 | self, 374 | nlclient: SankakuClient, 375 | name_or_id 376 | ): 377 | with pytest.raises(errors.PageNotFoundError): 378 | await nlclient.get_user(name_or_id) 379 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from sankaku import utils, errors 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ["rps", "rpm", "expected"], 10 | [(200, 200, errors.RateLimitError), (None, None, TypeError)] 11 | ) 12 | async def test_ratelimit_with_incompatible_args(rps, rpm, expected): # noqa: D103 13 | with pytest.raises(expected): 14 | @utils.ratelimit(rps=rps, rpm=rpm) 15 | async def idle_request(): 16 | assert True 17 | 18 | await idle_request() 19 | 20 | 21 | @pytest.mark.parametrize(["rps", "rpm"], [(3, None), (180, None)]) 22 | async def test_ratelimit(rps, rpm): # noqa: D103 23 | @utils.ratelimit(rps=rps, rpm=rpm) 24 | async def idle_request(): 25 | assert True 26 | 27 | await idle_request() 28 | 29 | 30 | @pytest.mark.parametrize( 31 | ["ts", "expected"], 32 | [ 33 | ( 34 | { 35 | "json_class": "Time", 36 | "s": 1680471860, 37 | "n": 0 38 | }, 39 | datetime.utcfromtimestamp(1680471860).astimezone() 40 | ), 41 | ( 42 | { 43 | "json_class": "Time", 44 | "s": None, 45 | "n": 0 46 | }, 47 | None 48 | ), 49 | ] 50 | ) 51 | def test_convert_ts_to_datetime(ts, expected): # noqa: D103 52 | assert utils.convert_ts_to_datetime(ts) == expected 53 | --------------------------------------------------------------------------------