diff --git a/CHANGELOG.md b/CHANGELOG.md index 3367386..ce27499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [unreleased] - 2025-08-24 + +### Added + - Added a `rule34.autocomplete` method. + - Added `AutocompleteTag` class. + +### Changed + - Updated API wrapper to support website’s new authentication system. + - The underlying website API now **requires authentication** (`api_key` and `user_id`) for all requests. + ## [3.0.0] - 2025-06-09 ### Added diff --git a/Makefile b/Makefile index 8212798..d109c0b 100644 --- a/Makefile +++ b/Makefile @@ -44,7 +44,7 @@ $(wheels) &: $(dist_files) $(sdist) : $(dist_files) - $(POETRY) --output $(builddir) --format=sdist + $(POETRY) build --output $(builddir) --format=sdist # PHONY TARGETS # @@ -52,7 +52,7 @@ $(sdist) : $(dist_files) # Build all binary targets necessary for installation. # Does not build documentation or source distributions. -all : $(wheels) +all : $(wheels) $(sdist) .PHONY : all diff --git a/README.md b/README.md index 0d41f1b..7bf6c13 100644 --- a/README.md +++ b/README.md @@ -24,28 +24,30 @@ See the [Developer Guide](https://b3yc0d3.github.io/rule34Py/dev/developer-guide ```python from rule34Py import rule34Py -r34Py = rule34Py() +client = rule34Py() +client.api_key = "YOUR_API_KEY" +client.user_id = "YOUR_USER_ID" # Get comments of an post. -r34Py.get_comments(4153825) +client.get_comments(4153825) # Get post by its id. -r34Py.get_post(4153825) +client.get_post(4153825) # Get top 100 icame. -r34Py.icame() +client.icame() # Search for posts by tag(s). -r34Py.search(["neko"], page_id=2, limit=50) +client.search(["neko"], page_id=2, limit=50) # Get pool by id. -r34Py.get_pool(28) +client.get_pool(28) # Get a random post. -random = r34Py.random_post() +random = client.random_post() # Get just a random post ID. -random_id = r34Py.random_post_id() +random_id = client.random_post_id() ``` diff --git a/docs/_static/api-credentials.png b/docs/_static/api-credentials.png new file mode 100644 index 0000000..d72748a Binary files /dev/null and b/docs/_static/api-credentials.png differ diff --git a/docs/conf.py b/docs/conf.py index eb5020e..cf85cf7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,7 @@ # sphinx.ext.autodoc configuration # autodoc_default_options = { + "private-members": False } autodoc_typehints = "both" # Show typehints in the signature and as content of the function or method diff --git a/docs/guides/api-credentials.rst b/docs/guides/api-credentials.rst new file mode 100644 index 0000000..29e19f4 --- /dev/null +++ b/docs/guides/api-credentials.rst @@ -0,0 +1,44 @@ +================================= +How to set Rule34 api credentials +================================= + +Since Aug 19, 2025 the `api.rule34.xxx `_ REST API requires credentials for all requests made with the REST API. With that changes also came a rate limit of 60 request per 60 seconds. + +In order to be able to make requests with `rule34Py `_, you now have to set the api key and your user id. + +Reference the :doc:`rule34Py.rule34Py <../../api/rule34Py/rule34>` class documentation for an indication of which workflows have these limits. + +Note that as of now, you need an `rule34.xxx `_ account. + + +Setting api credentials +======================= + +A `rule34.xxx `_ account is required to receive REST API credentials. + +#. Use any reasonable browser with javascript enabled to open any URL to the https://rule34.xxx site. + +#. Login without account + +#. Navigate to https://rule34.xxx/index.php?page=account&s=options + +#. Scroll down to **API Access Credentials**, there you will find a long string similar to the one bellow. If you don't see a text similar to the one below, click the checkbox *Generate New Key?* and then *Save* at the bottom, revisit the site. + + .. image:: /_static/api-credentials.png + + - The `api_key` is highlighted in yellow (the long block of text with 128 letters and numbers) + - The `user_id` is highlighted in green (the short block of seven digits) + + .. important:: + + Do not share those information with anyone online! + +#. Set the in the previous step retrieved credentials like the following. + + .. code-block:: python + + import rule34Py as r34 + client = r34.rule34Py() + + client.api_key="00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + client.user_id="0000000" diff --git a/docs/guides/index.rst b/docs/guides/index.rst index c4f4c8d..37a8b1e 100644 --- a/docs/guides/index.rst +++ b/docs/guides/index.rst @@ -7,4 +7,5 @@ This section contains how-to guides and special topics of interest to module use :maxdepth: 1 captcha-clearance + api-credentials package-metadata diff --git a/docs/tutorials/downloader.py b/docs/tutorials/downloader.py index cc97519..044be4e 100644 --- a/docs/tutorials/downloader.py +++ b/docs/tutorials/downloader.py @@ -1,6 +1,8 @@ from rule34Py import rule34Py client = rule34Py() +client.api_key = "YOUR_API_KEY" +client.user_id = "YOUR_USER_ID" TAGS = ["neko", "sort:score", "-video"] diff --git a/rule34Py/rule34.py b/rule34Py/rule34.py index c49f378..7cdbe69 100644 --- a/rule34Py/rule34.py +++ b/rule34Py/rule34.py @@ -52,7 +52,7 @@ SEARCH_RESULT_MAX: int = 1000 -class rule34Py(): +class rule34Py: """The rule34.xxx API client. This class is the primary broker for interactions with the real Rule34 servers. @@ -62,6 +62,9 @@ class rule34Py(): .. code-block:: python client = rule34Py() + client.api_key="API_KEY" + client.user_id="USER_ID" + post = client.get_post(1234) """ @@ -77,9 +80,21 @@ class rule34Py(): #: Defaults to either the value of the ``R34_USER_AGENT`` environment variable; or the ``rule34Py.rule34.DEFAULT_USER_AGENT``, if not asserted. #: Can be overridden by the user at runtime to change User-Agents. user_agent: str = os.environ.get("R34_USER_AGENT", DEFAULT_USER_AGENT) + #: User id required for requests by `rule34.xxx `_ + user_id: str = None + #: Api key required for requests by `rule34.xxx `_ + api_key: str = None def __init__(self): - """Initialize a new rule34 API client instance.""" + """Initialize a new rule34 API client instance. + + Args: + api_key: Api key from rule34.xxx + + user_id: User id from rule34.xxx account + + The api key and the user id can both be found at . + """ self.session = requests.session() self.session.mount(__base_url__, self._base_site_rate_limiter) @@ -99,20 +114,19 @@ def autocomplete(self, tag_string: str) -> list[AutocompleteTag]: """ # noqa: DOC502 params = [["q", tag_string]] formatted_url = self._parseUrlParams(API_URLS.AUTOCOMPLETE.value, params) - response = self._get(formatted_url, headers = { - "Referer": "https://rule34.xxx/", - "Origin": "https://rule34.xxx", - "Accept": "*/*" - }) + response = self._get( + formatted_url, + headers={ + "Referer": "https://rule34.xxx/", + "Origin": "https://rule34.xxx", + "Accept": "*/*", + }, + ) response.raise_for_status() raw_data = response.json() results = [ - AutocompleteTag( - label=item["label"], - value=item["value"], - type=item["type"] - ) + AutocompleteTag(label=item["label"], value=item["value"], type=item["type"]) for item in raw_data ] return results @@ -124,11 +138,24 @@ def _get(self, *args, **kwargs) -> requests.Response: Returns: The Response object from the GET request. + + Raises: + ValueError: API credentials aer not supplied. """ + is_api_request = args[0].startswith(__api_url__) == True + + # check if api credentials are set + if is_api_request and self.user_id == None and self.api_key == None: + raise ValueError("API credentials must be supplied, api_key and user_id can not be None!\nSee https://api.rule34.xxx/ for more information.") + # headers kwargs.setdefault("headers", {}) kwargs["headers"].setdefault("User-Agent", self.user_agent) + # api authentication + if is_api_request: + kwargs["params"] = {"api_key": self.api_key, "user_id": self.user_id} + # cookies kwargs.setdefault("cookies", {}) if self.captcha_clearance is not None: @@ -152,9 +179,7 @@ def get_comments(self, post_id: int) -> list[PostComment]: Raises: requests.HTTPError: The backing HTTP GET operation failed. """ # noqa: DOC502 - params = [ - ["POST_ID", str(post_id)] - ] + params = [["POST_ID", str(post_id)]] formatted_url = self._parseUrlParams(API_URLS.COMMENTS, params) response = self._get(formatted_url) response.raise_for_status() @@ -163,11 +188,11 @@ def get_comments(self, post_id: int) -> list[PostComment]: comment_soup = BeautifulSoup(response.content.decode("utf-8"), features="xml") for e_comment in comment_soup.find_all("comment"): comment = PostComment( - id = e_comment["id"], - owner_id = e_comment["creator_id"], - body = e_comment["body"], - post_id = e_comment["post_id"], - creation = e_comment["created_at"], + id=e_comment["id"], + owner_id=e_comment["creator_id"], + body=e_comment["body"], + post_id=e_comment["post_id"], + creation=e_comment["created_at"], ) comments.append(comment) @@ -188,9 +213,7 @@ def get_pool(self, pool_id: int) -> Pool: Raises: requests.HTTPError: The backing HTTP GET operation failed. """ # noqa: DOC502 - params = [ - ["POOL_ID", str(pool_id)] - ] + params = [["POOL_ID", str(pool_id)]] response = self._get(self._parseUrlParams(API_URLS.POOL.value, params)) response.raise_for_status() return PoolPage.pool_from_html(response.text) @@ -207,9 +230,7 @@ def get_post(self, post_id: int) -> Union[Post, None]: Raises: requests.HTTPError: The backing HTTP GET operation failed. """ # noqa: DOC502 - params = [ - ["POST_ID", str(post_id)] - ] + params = [["POST_ID", str(post_id)]] formatted_url = self._parseUrlParams(API_URLS.GET_POST.value, params) response = self._get(formatted_url) response.raise_for_status() @@ -311,7 +332,7 @@ def random_post(self) -> Post: Returns: A random Post. - + Raises: requests.HTTPError: The backing HTTP GET operation failed. """ # noqa: DOC502 @@ -335,9 +356,10 @@ def random_post_id(self) -> int: response = self._get(API_URLS.RANDOM_POST.value) response.raise_for_status() parsed = urlparse.urlparse(response.url) - return int(parse_qs(parsed.query)['id'][0]) + return int(parse_qs(parsed.query)["id"][0]) - def search(self, + def search( + self, tags: list[str] = [], page_id: Union[int, None] = None, limit: int = SEARCH_RESULT_MAX, diff --git a/tests/fixtures/mock34/responses.yml b/tests/fixtures/mock34/responses.yml index d52c76f..d32aa2e 100644 --- a/tests/fixtures/mock34/responses.yml +++ b/tests/fixtures/mock34/responses.yml @@ -33468,7 +33468,7 @@ responses: vary: Accept-Encoding method: GET status: 200 - url: https://api.rule34.xxx/index.php?page=dapi&s=comment&q=index&post_id=4153825 + url: https://api.rule34.xxx/index.php?page=dapi&s=comment&q=index&post_id=4153825&api_key=0000000&user_id=0000000 - response: auto_calculate_content_length: false body: '[{"preview_url":"https:\/\/api-cdn.rule34.xxx\/thumbnails\/3672\/thumbnail_629eb1432db601f8555a02d3e22b5fa7.jpg","sample_url":"https:\/\/api-cdn.rule34.xxx\/images\/3672\/629eb1432db601f8555a02d3e22b5fa7.jpeg","file_url":"https:\/\/api-cdn.rule34.xxx\/images\/3672\/629eb1432db601f8555a02d3e22b5fa7.jpeg","directory":3672,"hash":"629eb1432db601f8555a02d3e22b5fa7","width":811,"height":811,"id":4153825,"image":"629eb1432db601f8555a02d3e22b5fa7.jpeg","change":1710722744,"owner":"chocola-chan","parent_id":3984379,"rating":"explicit","sample":false,"sample_height":0,"sample_width":0,"score":118,"tags":"1girls @@ -33484,7 +33484,7 @@ responses: alt-svc: h3=":443"; ma=86400 method: GET status: 200 - url: https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&id=4153825&json=1 + url: https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&id=4153825&json=1&api_key=0000000&user_id=0000000 - response: auto_calculate_content_length: false body: "\n\n\t\n\t\t\n\t\t\n\n\t\n\t\t\n\t\t\n\n\n
\n \ @@ -103503,7 +103503,7 @@ responses: vary: Accept-Encoding method: GET status: 200 - url: https://api.rule34.xxx/index.php?page=dapi&s=comment&q=index&post_id=4153825 + url: https://api.rule34.xxx/index.php?page=dapi&s=comment&q=index&post_id=4153825&api_key=0000000&user_id=0000000 - response: auto_calculate_content_length: false body: \n\n\n Example Domain\n\n\ diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1a8dcba..d5e2cf8 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -7,4 +7,6 @@ @pytest.fixture(scope="module") def rule34(mock34): r34 = rule34Py() + r34.api_key = "0000000" + r34.user_id = "0000000" yield r34