├── .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 |
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 | 
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 |
--------------------------------------------------------------------------------