Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@

# Ruff
**/.ruff_cache
dist/
dist/

*debug*
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
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).

## [4.1.0] - 2026-05-02

### Added
- Added a `rule34Py.get_favorites` method.
- Added a `rule34Py.get_favorites_id` method.
- Added a logo and favicon to the docs.

## [4.0.2] - 2025-12-22

### Fixed
Expand Down
Binary file added docs/_static/favicon.ico
Binary file not shown.
Binary file added docs/_static/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/_static/logo_text.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import tomllib as toml

PROJECT_ROOT = Path(__file__).parent.resolve() / ".."
add_module_names = False

# Read the pyproject.toml
with open(PROJECT_ROOT / "pyproject.toml", "rb") as fp_pyproject:
Expand Down Expand Up @@ -65,6 +66,7 @@

# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output

html_theme = 'sphinx_rtd_theme'
html_static_path = ['_static']
html_favicon = '_static/favicon.ico'
html_logo = "_static/logo_text.png"
279 changes: 140 additions & 139 deletions poetry.lock

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ dynamic = [
]
dependencies = [ # project runtime dependencies
"beautifulsoup4",
"lxml",
"lxml (>=6.0.0)",
"requests-ratelimiter",
"requests",
"requests_ratelimiter",
]
keywords = [
"adult",
Expand All @@ -30,7 +31,7 @@ maintainers = [
]
readme = "README.md"
requires-python = ">=3.9, <4.0"
version = "4.0.2"
version = "4.1.0"


[project.urls]
Expand Down
2 changes: 1 addition & 1 deletion rule34Py/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class API_URLS(str, Enum):
#: The XML Post comments endpoint.
COMMENTS = f"{__api_url__}index.php?page=dapi&s=comment&q=index&post_id={{POST_ID}}"
#: An HTML User favorites endpoint.
USER_FAVORITES = f"{__api_url__}index.php?page=favorites&s=view&id={{USR_ID}}"
USER_FAVORITES = f"{__base_url__}index.php?page=favorites&s=view&id={{USER_ID}}&pid={{PAGE_ID}}"
#: The JSON Post endpoint.
GET_POST = f"{__api_url__}index.php?page=dapi&s=post&q=index&id={{POST_ID}}&json=1"
#: The HTML ICAME page URL.
Expand Down
35 changes: 35 additions & 0 deletions rule34Py/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from rule34Py.icame import ICame
from rule34Py.pool import Pool, PoolHistoryEvent
from rule34Py.toptag import TopTag
from rule34Py.post import Post


class ICamePage():
Expand Down Expand Up @@ -258,3 +259,37 @@ def top_tags_from_html(html: str) -> list[TopTag]:
))

return top_tags


class UserFavorites():
"""The https://rule34.xxx/index.php?page=favorites page.

This class can be instantiated as an object that automatically parses
the useful information from the page's html, or used as a static class
to parse the page's html directly.

Args:
html: The Favorites page HTML, as a string.
"""

#: The favorites on this page.
favorites: list[Post] = []

def __init__(self, html: str):
self.favorites = UserFavorites.favorites_from_html(html)

@staticmethod
def favorites_from_html(html: str) -> list[int]:
"""Parse the favorites from the favorite page.

Args:
html: The Favorites page HTML, as a string.

Returns:
A list of post ids.
"""

vdoc = BeautifulSoup(html, features="html.parser")
img_list = vdoc.select("div#content div.image-list span span.thumb a[id]")

return [int(img.get("id")[1:]) for img in img_list]
100 changes: 92 additions & 8 deletions rule34Py/rule34.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# rule34Py - Python api wrapper for rule34.xxx
#
# Copyright (C) 2022 MiningXL <[email protected]>
# Copyright (C) 2022-2025 b3yc0d3 <[email protected]>
# Copyright (C) 2022-2026 b3yc0d3 <[email protected]>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
Expand All @@ -19,7 +19,7 @@
"""A module containing the top-level Rule34 API client class."""

from collections.abc import Iterator
from typing import Union
from typing import Union, Tuple
from urllib.parse import parse_qs
import importlib.metadata
import os
Expand All @@ -35,7 +35,7 @@
__base_url__,
API_URLS,
)
from rule34Py.html import TagMapPage, ICamePage, TopTagsPage, PoolPage
from rule34Py.html import TagMapPage, ICamePage, TopTagsPage, PoolPage, UserFavorites
from rule34Py.pool import Pool
from rule34Py.post import Post
from rule34Py.post_comment import PostComment
Expand Down Expand Up @@ -84,6 +84,8 @@ class rule34Py:
user_id: str = None
#: Api key required for requests by `rule34.xxx <https://api.rule34.xxx/>`_
api_key: str = None
#: HTTP Requests timeout in seconds. Defaults to 5 seconds. `See more <https://requests.readthedocs.io/en/latest/user/advanced/#timeouts>`_
timeout: Union[int, float, Tuple[Union[int, float], Union[int, float]]] = 5

def __init__(self):
"""Initialize a new rule34 API client instance.
Expand Down Expand Up @@ -135,21 +137,28 @@ def _get(self, *args, **kwargs) -> requests.Response:
Raises:
ValueError: API credentials aer not supplied.
"""
is_api_request = args[0].startswith(__api_url__) == True
is_api_request = args[0].startswith(__api_url__)

# check if api credentials are set
if is_api_request and self.user_id == None or self.api_key == None or (self.user_id == None and self.api_key == None):
if is_api_request and ((self.user_id is None or self.api_key is None) or (self.user_id is None and self.api_key is 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", {})
if not "headers" in kwargs:
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}
else:
kwargs["headers"]["Host"] = "rule34.xxx"
kwargs["headers"]["Referer"] = "https://rule34.xxx/index.php?page=account&s=home"

# timeout
kwargs["timeout"] = self.timeout

# cookies
kwargs.setdefault("cookies", {})
Expand Down Expand Up @@ -193,6 +202,81 @@ def get_comments(self, post_id: int) -> list[PostComment]:

return comments

def get_favorites_ids(self, user_id: int, page_id: int = 0) -> list[int]:
"""Retrieve the IDs of a user's favourite posts.

Note:
This method scrapes the interactive website and is rate-limited.
A valid ``captcha_clearance`` cookie must be set on this instance
to bypass Cloudflare protection.

This method is faster than get_favorites as it does not fetch
full post data for each result, only the post IDs.

Args:
user_id: The user's rule34.xxx account ID.
page_id: The page offset of favorites to retrieve, starting at 0. Defaults to 0. (Optional)

Returns:
List of Post IDs favorited by the user.
If the user has no favorites, an empty list will be returned.

Raises:
requests.HTTPError: The backing HTTP GET operation failed.

"""

page_id = (50 * page_id) + 50 if page_id >= 1 else 0
params = [
["USER_ID", user_id],
["PAGE_ID", page_id],
]

url = API_URLS.USER_FAVORITES.value
formatted_url = self._parseUrlParams(url, params)
kwargs = {
"headers": {
"Host": "rule34.xxx",
"Referer": "https://rule34.xxx/index.php?page=account&s=home"
}
}

resp = self._get(formatted_url, **kwargs)
resp.raise_for_status()
return UserFavorites.favorites_from_html(resp.text)

def get_favorites(self, user_id: int, page_id: int = 0) -> list[Post]:
"""Retrieves a user's favorites posts.

Note:
This method scrapes the interactive website and is rate-limited.
A valid ``captcha_clearance`` cookie must be set on this instance
to bypass Cloudflare protection.

This method is quite slow because it sends a post data request to build post objects for each id it scrapes.
Use get_favorites_ids instead if you just need the ids of the posts

Args:
user_id: The user's rule34.xxx account ID.
page_id: The page of favorites to retrieve, starting at 0. Defaults to 0. (Optional).

Returns:
List of Posts favorited by the user.
If the user has no favorites, an empty list will be returned.

Raises:
requests.HTTPError: The backing HTTP GET operation failed.
"""

favorites = []
for id in self.get_favorites_ids(user_id, page_id):
post = self.get_post(id)
if post is None:
continue
favorites.append(post)

return favorites

def get_pool(self, pool_id: int) -> Pool:
"""Retrieve a pool of Posts.

Expand Down Expand Up @@ -310,8 +394,8 @@ def _parseUrlParams(self, url: str, params: list) -> str:
retURL = url

for g in params:
key = g[0]
value = g[1]
key = str(g[0])
value = str(g[1])

retURL = retURL.replace("{" + key + "}", value)

Expand Down
13 changes: 13 additions & 0 deletions tests/fixtures/mock34/responses.yml

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions tests/unit/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
HISTORY_POOL_ID = 720 # Arbitrary old pool w/ lots of history, that hasn't been updated recently.
TAGMAP_LOCATION_COUNT = 285 # There are 285 districts on the map
TOP_TAGS_CHART_LEN = 100 # It's a top-100 chart.
USER_FAVORITES_LEN = 50


# FIXTURES #
Expand Down Expand Up @@ -44,6 +45,21 @@ def toptags_html(rule34):
return resp.text


@pytest.fixture(scope="module")
def favorites_html(rule34):
page_id = 0
params = [
["USER_ID", 1069907],
["PAGE_ID", 0],
]

url = API_URLS.USER_FAVORITES.value
formatted_url = rule34._parseUrlParams(url, params)
resp = rule34._get(formatted_url)
resp.raise_for_status()
return resp.text


# TESTS #
#########

Expand Down Expand Up @@ -138,3 +154,17 @@ def test_TopTagsPage_top_tags_from_html(toptags_html):
assert isinstance(top_tags, list)
assert len(top_tags) == TOP_TAGS_CHART_LEN
assert isinstance(top_tags[0], TopTag)


def test_UserFavorites(favorites_html):
"""The UserFavorites class can be instantiated from html."""
user_favorites = UserFavorites(favorites_html)
assert len(user_favorites.favorites) == USER_FAVORITES_LEN


def text_UserFavorites_favorites_from_html(favorites_html):
"""UserFavorites.favorites_from_html() parses the favorites list from html."""
user_favorites = UserFavorites.favorites_from_html(favorites_html)
assert isinstance(user_favorites, list)
assert len(user_favorites) == USER_FAVORITES_LEN
assert isinstance(user_favorites[0], int)
21 changes: 17 additions & 4 deletions tests/unit/test_rule34Py.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ def test_rule34Py_search(rule34):
with pytest.raises(ValueError):
rule34.search([], limit=SEARCH_RESULT_MAX + 1)


def test_rule34Py_search_exclude_ai(rule34):
"""The client can search for posts by tags, with excluding ai generated content."""
# search by single tag
Expand Down Expand Up @@ -251,24 +252,36 @@ def test_rule34Py_top_tags(rule34):
assert len(top_tags) == 100
assert isinstance(top_tags[0], TopTag)


def test_rule34Py_api_key(rule34):
rule34.api_key = None
with pytest.raises(ValueError) as execinfo:
rule34.top_tags()
rule34.get_post(00000)

assert str(execinfo.value) == "API credentials must be supplied, api_key and user_id can not be None!\nSee https://api.rule34.xxx/ for more information."


def test_rule34Py_user_id(rule34):
rule34.user_id = None
with pytest.raises(ValueError) as execinfo:
rule34.top_tags()
rule34.get_post(00000)

assert str(execinfo.value) == "API credentials must be supplied, api_key and user_id can not be None!\nSee https://api.rule34.xxx/ for more information."


def test_rule34Py_credentials(rule34):
rule34.api_key = None
rule34.user_id = None
with pytest.raises(ValueError) as execinfo:
rule34.top_tags()
rule34.get_post(00000)

assert str(execinfo.value) == "API credentials must be supplied, api_key and user_id can not be None!\nSee https://api.rule34.xxx/ for more information."
assert str(execinfo.value) == "API credentials must be supplied, api_key and user_id can not be None!\nSee https://api.rule34.xxx/ for more information."


def test_rule34Py_get_favorites_ids(rule34):
"""The get_favorites_id() method returns a list of the users favorites posts as id.
"""
user_favorites_ids = rule34.get_favorites_ids(1069907, 0)
assert isinstance(user_favorites_ids, list)
assert len(user_favorites_ids) == 50
assert isinstance(user_favorites_ids[0], int)
Loading