from __future__ import annotations
import re
from collections.abc import Callable
from typing import Generic, NamedTuple, TypeVar, overload
from lxml import html
from .errors import InvalidURLError
T = TypeVar("T")
T_co = TypeVar("T_co", covariant=True)
AO3_STORY_REGEX = re.compile(r"(?:https://|)(?:www\.|)archiveofourown\.org/(?:works|series)/(?P<id>\d+)")
ICON_URL_USER_ID_REGEX = re.compile(r".*/(\d+)/")
__all__ = (
"Constraint",
"get_id_from_url",
)
AO3_LOGO_URL = "https://archiveofourown.org/images/ao3_logos/logo.png"
class CachedSlotProperty(Generic[T, T_co]):
"""An implementation of a cached property for slotted classes.
Copied from discord.py: https://github.com/Rapptz/discord.py/blob/master/discord/utils.py#L208.
Credit goes to Rapptz and contributors.
"""
def __init__(self, name: str, function: Callable[[T], T_co]) -> None:
self.name = name
self.function = function
self.__doc__ = function.__doc__
@overload
def __get__(self, instance: T, owner: type[T] | None = ...) -> T_co:
...
@overload
def __get__(self, instance: None, owner: type[T] | None = ...) -> CachedSlotProperty[T, T_co]:
...
def __get__(self, instance: T | None, owner: type[T] | None = None) -> T_co | CachedSlotProperty[T, T_co]:
if instance is None:
return self
try:
return getattr(instance, self.name)
except AttributeError:
value = self.function(instance)
setattr(instance, self.name, value)
return value
def cached_slot_property(name: str) -> Callable[[Callable[[T], T_co]], CachedSlotProperty[T, T_co]]:
def decorator(func: Callable[[T], T_co]) -> CachedSlotProperty[T, T_co]:
return CachedSlotProperty(name, func)
return decorator
[docs]
class Constraint(NamedTuple):
"""A representation for a constraint on integer amounts via a range.
Attributes
----------
min_val: :class:`int`, default=0
The lower end of the constraint. Defaults to 0.
max_val: :class:`int` | None, optional
The upper bound of the constraint. Defaults to None.
"""
min_val: int = 0
max_val: int | None = None
@property
def string(self) -> str:
"""Stringify the constraint in a way that AO3 can understand."""
if self.min_val == 0 and self.max_val is None:
return ""
if self.min_val == 0:
return f"<{self.max_val}"
if self.max_val is None:
return f">{self.min_val}"
if self.min_val == self.max_val:
return str(self.min_val)
return f"{self.min_val}-{self.max_val}"
[docs]
def get_id_from_url(url: str, *, will_raise: bool = False) -> int | None:
"""Get the work/series ID from an AO3 website url.
Parameters
----------
url: :class:`str`
The AO3 url. Could be for a series or a work.
will_raise: :class:`bool`, optional
Whether to raise an exception if an id is not found in the given string. Defaults to False.
Returns
-------
:class:`int` | None
The work/series ID, or None if not found.
Raises
------
InvalidURLError
The given URL doesn't match the expected AO3 work/series URL structure.
"""
result = AO3_STORY_REGEX.search(url)
if result:
return int(result.group("id"))
if not will_raise:
return None
raise InvalidURLError
def parse_max_pages_num(element: html.HtmlElement) -> int:
default_page_num = 1
try:
num_gen = (int(li.text_content().strip()) for li in element.cssselect("ol[title=pagination] li"))
return max(num_gen)
except AttributeError:
return default_page_num
def extract_login_auth_token(text: str | html.HtmlElement) -> str | None:
element = html.fromstring(text) if isinstance(text, str) else text
try:
return element.cssselect("input[name=authenticity_token]")[0].get("value", None)
except IndexError:
return None
def extract_csrf_token(text: str | html.HtmlElement) -> str | None:
element = html.fromstring(text) if isinstance(text, str) else text
try:
return element.cssselect("meta[name=csrf-token]")[0].get("content", None)
except IndexError:
return None
def extract_pseud_id(element: html.HtmlElement, specified_pseud: str | None = None) -> str | None:
pseuds = element.cssselect('input[name$="[pseud_id]"]')
if len(pseuds) > 0:
return pseuds[0].get("value")
pseuds = element.cssselect('select[name$="[pseud_id]"]')
if len(pseuds) > 0:
return next(
(
option.get("value")
for option in pseuds[0].cssselect("option")
if bool(
(str(option.text_content()) == specified_pseud) if specified_pseud else option.get("selected"),
)
),
None,
)
return None
def int_or_none(data: str | None) -> int | None:
"""Remove commas from a string and attempt conversion to an int. If anything fails along the way, return None."""
if data is None:
return None
try:
return int(data.replace(",", ""))
except ValueError:
return None