"""
Danbooru Image Board API implementation.
"""

import asyncio
import os
from typing import AsyncIterable

import httpx
import pandas as pd
from asyncstdlib import enumerate as aenumerate
from httpx._types import AuthTypes
from lxml import etree

from .booru import Booru, BooruComponent
from .utils import logger

__all__ = [
    # base classes
    "Danbooru",
    "DanbooruComponent",
    # client classes
    "DanbooruClient",
    # component classes
    # Versioned Types
    "DanbooruArtists",
    "DanbooruNotes",
    "DanbooruPools",
    "DanbooruPosts",
    "DanbooruWikiPages",
    # Type Versions
    "DanbooruPostVersions",
    "DanbooruPoolVersions"
    # Non-versioned Types
    "DanbooruComments",
    "DanbooruForumPosts",
    "DanbooruTags",
    "DanbooruUsers",
]


class Danbooru(Booru):
    """
    Danbooru Image Board API
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)


class DanbooruComponent(BooruComponent):
    """
    Danbooru Image Board Component
    """

    def __init__(self, client: Danbooru):
        super().__init__(client)


class DanbooruClient(Danbooru):
    """
    Danbooru API reference document: https://danbooru.donmai.us/wiki_pages/help%3Aapi

        - Danbooru Common Search Parameters used for search endpoint:
            - page - Returns the given page. Subject to maximum page limits (see help:users)
            - limit - The number of results to return per page. The maximum limit is 200 for /posts.json and 1000 for everything else.
            - search[id]
            - search[created_at]
            - search[updated_at]
            - search[order]=custom Returns results in the same order as given by search[id]=3,2,1.

    Danbooru Common URL Parameters used for navigation around the site: https://danbooru.donmai.us/wiki_pages/help%3Acommon_url_parameters

    Note:
        Danbooru 性能和安全由 Cloudflare 提供，访问该网站时，若使用常见的 User-Agent 或将 User-Agent 设置为 ''，会被拦截跳转到验证是否真人的页面，但随便给一个 User-Agent 即可避免上述情况
        wft? 你把 python-requests/2.32.4 和 python-httpx/0.28.0 这种 UA 都通过了不让我正常的 UA 通过？还有，为什么不禁全呢，换成 python-httpx/0.28.1 版本就又不通过了？
    """

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        # 设置发送相对 URL 请求时使用的基础 URL
        self.base_url = "https://danbooru.donmai.us/"

        # 初始化各组件
        self.posts = DanbooruPosts(self)
        self.tags = DanbooruTags(self)
        self.artists = DanbooruArtists(self)
        # self.comments = DanbooruComments(self)
        self.wiki_pages = DanbooruWikiPages(self)
        # self.notes = DanbooruNotes(self)
        # self.users = DanbooruUsers(self)
        # self.forum_posts = DanbooruForumPosts(self)
        self.pools = DanbooruPools(self)

        # self.post_versions = DanbooruPostVersions(self)
        # self.pool_versions = DanbooruPoolVersions(self)

        # 登录后修改（默认无账号）
        self.MAX_PAGE = 1000

    def login(self, auth: AuthTypes):
        # TODO
        raise NotImplementedError("The method is not implemented")


class DanbooruPosts(DanbooruComponent):
    """
    Posts: https://danbooru.donmai.us/wiki_pages/api%3Aposts
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        tags: str = "",
        random: bool = False,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定标签帖子列表的最大页码

        Note:
            danbooru post 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际帖子数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/posts?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/posts.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/logical/post_query_builder.rb:171:in 'PostQueryBuilder#paginated_posts'",
                    "app/logical/post_query.rb:86:in 'PostQuery#paginated_posts'",
                    "app/logical/post_sets/post.rb:109:in 'PostSets::Post#posts'",
                    "app/controllers/posts_controller.rb:21:in 'PostsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            tags (str, optional): 使用标签和元标签进行搜索的帖子查询 (Help:Cheatsheet)，post[tag] 也可以使用。要组合的不同标签使用空格连接，同一标签中的空格使用 _ 替换. Defaults to ''. 表示搜索全站
            random (bool, optional): 在帖子查询下选择随机抽样。若设置为 True，则仅返回 1 页内容. Defaults to False.

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        # 随机抽样，仅返回 1 页内容
        if random:
            return 1

        url = "/posts"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
            "tags": tags,  # 使用标签和元标签进行搜索的帖子查询 (Help:Cheatsheet)，post[tag] 也可以使用。要组合的不同标签使用空格连接，同一标签中的空格使用 _ 替换
            "random": random,  # 在帖子查询下选择随机抽样
        }

        try:
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为最后一页（且为 hidden 属性）
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                return int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        tags: str = "",
        random: bool = False,
        md5: str | None = None,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /posts.json
        Type	        read request
        Description	    The default order is ID descending

        Index parameters

        - tags - The post query to search for using tags and metatags (Help:Cheatsheet).
            - post[tags] can also be used.
        - random - Selects a random sampling under the post query.
        - format - Chooses which format to return. Can be: html, json, xml, atom.
        - md5 - Search for an MD5 match. Takes priority over all other parameters.

        Search attributes

        The following are the base fields along with their associated type. Check the syntax pages for all of the available variations.

        Number syntax
            - id
            - created_at
            - updated_at
            - pixiv_id
            - fav_count
            - score
            - up_score
            - down_score
            - file_size
            - image_width
            - image_height
            - tag_count
            - last_comment_bumped_at
            - last_commented_at
            - last_noted_at

        Text syntax
            - rating
            - source
            - md5
            - file_ext

        Boolean syntax
            - has_children
            - has_active_children
            - is_pending
            - is_flagged
            - is_deleted
            - is_banned

        User syntax
            - uploader
            - approver

        Post syntax
            - parent (unavailable due to current limitations)
            - children (unavailable due to current limitations)

        Chaining syntax
            - artist_commentary
            - flags
            - appeals
            - notes
            - comments
            - approvals
            - replacements
            - media_metadata

        Special search parameters

        The following are additional search fields.

            - tags - Behaves the same as the regular post search.

        Return json format:
        ```
        [
            {
                "id": 9755122,
                "created_at": "2025-08-07T03:06:03.772-04:00",
                "uploader_id": 434318,
                "score": 1,
                "source": "https://twitter.com/jasper_xandros/status/1946618564607115753",
                "md5": "b3c448861c60fbe54e2aa13d9d56f7bd",
                "last_comment_bumped_at": null,
                "rating": "s",
                "image_width": 1536,
                "image_height": 2048,
                "tag_string": "2girls barefoot blush breasts cloak collarbone couple english_text facial_mark halo height_difference highres hololive hololive_english jasper_xandros long_hair mori_calliope multiple_girls open_mouth takanashi_kiara virtual_youtuber",
                "fav_count": 0,
                "file_ext": "jpg",
                "last_noted_at": null,
                "parent_id": null,
                "has_children": false,
                "approver_id": null,
                "tag_count_general": 15,
                "tag_count_artist": 1,
                "tag_count_character": 2,
                "tag_count_copyright": 2,
                "file_size": 260316,
                "up_score": 1,
                "down_score": 0,
                "is_pending": false,
                "is_flagged": false,
                "is_deleted": false,
                "tag_count": 21,
                "updated_at": "2025-08-07T03:48:52.934-04:00",
                "is_banned": false,
                "pixiv_id": null,
                "last_commented_at": null,
                "has_active_children": false,
                "bit_flags": 0,
                "tag_count_meta": 1,
                "has_large": true,
                "has_visible_children": false,
                "media_asset": {
                    "id": 30919583,
                    "created_at": "2025-08-07T03:02:37.285-04:00",
                    "updated_at": "2025-08-07T03:02:38.864-04:00",
                    "md5": "b3c448861c60fbe54e2aa13d9d56f7bd",
                    "file_ext": "jpg",
                    "file_size": 260316,
                    "image_width": 1536,
                    "image_height": 2048,
                    "duration": null,
                    "status": "active",
                    "file_key": "xbUZ6jEOQ",
                    "is_public": true,
                    "pixel_hash": "05d3e53f510fed97e4c4f3854a013ffc",
                    "variants": [
                        {
                            "type": "180x180",
                            "url": "https://cdn.donmai.us/180x180/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                            "width": 135,
                            "height": 180,
                            "file_ext": "jpg"
                        },
                        {
                            "type": "360x360",
                            "url": "https://cdn.donmai.us/360x360/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                            "width": 270,
                            "height": 360,
                            "file_ext": "jpg"
                        },
                        {
                            "type": "720x720",
                            "url": "https://cdn.donmai.us/720x720/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.webp",
                            "width": 540,
                            "height": 720,
                            "file_ext": "webp"
                        },
                        {
                            "type": "sample",
                            "url": "https://cdn.donmai.us/sample/b3/c4/sample-b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                            "width": 850,
                            "height": 1133,
                            "file_ext": "jpg"
                        },
                        {
                            "type": "original",
                            "url": "https://cdn.donmai.us/original/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                            "width": 1536,
                            "height": 2048,
                            "file_ext": "jpg"
                        }
                    ]
                },
                "tag_string_general": "2girls barefoot blush breasts cloak collarbone couple english_text facial_mark halo height_difference long_hair multiple_girls open_mouth virtual_youtuber",
                "tag_string_character": "mori_calliope takanashi_kiara",
                "tag_string_copyright": "hololive hololive_english",
                "tag_string_artist": "jasper_xandros",
                "tag_string_meta": "highres",
                "file_url": "https://cdn.donmai.us/original/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                "large_file_url": "https://cdn.donmai.us/sample/b3/c4/sample-b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                "preview_file_url": "https://cdn.donmai.us/180x180/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg"
            },
            ...
        ]
        ```

        获取在起始页码与结束页码范围内，指定标签的帖子列表；若 all_page 为 True，则获取当前查询标签下所有页码的帖子列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前查询标签下所有页码的帖子列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            tags (str, optional): 使用标签和元标签进行搜索的帖子查询 (Help:Cheatsheet)，post[tag] 也可以使用。要组合的不同标签使用空格连接，同一标签中的空格使用 _ 替换. Defaults to ''. 表示搜索全站
            random (bool, optional): 在帖子查询下选择随机抽样。若设置为 True，则仅返回 1 页内容. Defaults to False.
            md5 (str, optional): 搜索 MD5 匹配项。优先于所有其他参数. Defaults to ''.

        Note:
            danbooru post 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际帖子数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/posts?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/posts.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/logical/post_query_builder.rb:171:in 'PostQueryBuilder#paginated_posts'",
                    "app/logical/post_query.rb:86:in 'PostQuery#paginated_posts'",
                    "app/logical/post_sets/post.rb:109:in 'PostSets::Post#posts'",
                    "app/controllers/posts_controller.rb:21:in 'PostsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            list[dict] | None. 若获取成功，则返回对应的帖子内容列表；若获取失败，则返回 None
        """
        if limit > 200:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 200
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 200."
            )

        url = "/posts.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
            "tags": tags,  # 使用标签和元标签进行搜索的帖子查询 (Help:Cheatsheet)，post[tag] 也可以使用。要组合的不同标签使用空格连接，同一标签中的空格使用 _ 替换
            "random": random,  # 在帖子查询下选择随机抽样
            "md5": md5,  # 搜索 MD5 匹配项。优先于所有其他参数
        }

        if md5 is not None:
            result = await self.client.fetch_page(  # danbooru 在搜索 md5 时，返回的结果列表仅包含一个帖子
                url,
                headers=headers,
                params=params,
            )
            yield result
            return

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前查询标签下所有页码的帖子列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                tags=tags,
                random=random,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {tags = }, {random = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {tags = }, {random = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标签的帖子列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {tags = }, {random = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {tags = }, {random = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res

    async def show(
        self,
        id: int,
    ) -> list[dict] | None:
        """
        Show

        HTTP Method	    GET
        Base URL	    /posts/$id.json
        Type	        read request
        Description	    $id is the post ID

        Return json format:
        ```
        {
            "id": 9755122,
            "created_at": "2025-08-07T03:06:03.772-04:00",
            "uploader_id": 434318,
            "score": 3,
            "source": "https://twitter.com/jasper_xandros/status/1946618564607115753",
            "md5": "b3c448861c60fbe54e2aa13d9d56f7bd",
            "last_comment_bumped_at": null,
            "rating": "s",
            "image_width": 1536,
            "image_height": 2048,
            "tag_string": "2girls barefoot blush breasts cloak collarbone couple english_text facial_mark halo height_difference highres hololive hololive_english jasper_xandros long_hair mori_calliope multiple_girls open_mouth takanashi_kiara virtual_youtuber",
            "fav_count": 2,
            "file_ext": "jpg",
            "last_noted_at": null,
            "parent_id": null,
            "has_children": false,
            "approver_id": null,
            "tag_count_general": 15,
            "tag_count_artist": 1,
            "tag_count_character": 2,
            "tag_count_copyright": 2,
            "file_size": 260316,
            "up_score": 3,
            "down_score": 0,
            "is_pending": false,
            "is_flagged": false,
            "is_deleted": false,
            "tag_count": 21,
            "updated_at": "2025-08-07T03:48:52.934-04:00",
            "is_banned": false,
            "pixiv_id": null,
            "last_commented_at": null,
            "has_active_children": false,
            "bit_flags": 0,
            "tag_count_meta": 1,
            "has_large": true,
            "has_visible_children": false,
            "media_asset": {
                "id": 30919583,
                "created_at": "2025-08-07T03:02:37.285-04:00",
                "updated_at": "2025-08-07T03:02:38.864-04:00",
                "md5": "b3c448861c60fbe54e2aa13d9d56f7bd",
                "file_ext": "jpg",
                "file_size": 260316,
                "image_width": 1536,
                "image_height": 2048,
                "duration": null,
                "status": "active",
                "file_key": "xbUZ6jEOQ",
                "is_public": true,
                "pixel_hash": "05d3e53f510fed97e4c4f3854a013ffc",
                "variants": [
                    {
                        "type": "180x180",
                        "url": "https://cdn.donmai.us/180x180/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                        "width": 135,
                        "height": 180,
                        "file_ext": "jpg"
                    },
                    {
                        "type": "360x360",
                        "url": "https://cdn.donmai.us/360x360/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                        "width": 270,
                        "height": 360,
                        "file_ext": "jpg"
                    },
                    {
                        "type": "720x720",
                        "url": "https://cdn.donmai.us/720x720/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.webp",
                        "width": 540,
                        "height": 720,
                        "file_ext": "webp"
                    },
                    {
                        "type": "sample",
                        "url": "https://cdn.donmai.us/sample/b3/c4/sample-b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                        "width": 850,
                        "height": 1133,
                        "file_ext": "jpg"
                    },
                    {
                        "type": "original",
                        "url": "https://cdn.donmai.us/original/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
                        "width": 1536,
                        "height": 2048,
                        "file_ext": "jpg"
                    }
                ]
            },
            "tag_string_general": "2girls barefoot blush breasts cloak collarbone couple english_text facial_mark halo height_difference long_hair multiple_girls open_mouth virtual_youtuber",
            "tag_string_character": "mori_calliope takanashi_kiara",
            "tag_string_copyright": "hololive hololive_english",
            "tag_string_artist": "jasper_xandros",
            "tag_string_meta": "highres",
            "file_url": "https://cdn.donmai.us/original/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
            "large_file_url": "https://cdn.donmai.us/sample/b3/c4/sample-b3c448861c60fbe54e2aa13d9d56f7bd.jpg",
            "preview_file_url": "https://cdn.donmai.us/180x180/b3/c4/b3c448861c60fbe54e2aa13d9d56f7bd.jpg"
        }
        ```

        获取指定 id 的帖子列表

        Args:
            id (int): 帖子 id

        Note:
            该方法与指定 md5 参数的 index 方法类似，**但是**返回的结果列表仅包含一个帖子

        Returns:
            list[dict] | None. 若获取成功，则返回对应的帖子内容列表；若获取失败，则返回 None
        """
        url = f"/posts/{id}.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {}

        # 结果列表
        result = await self.client.fetch_page(  # danbooru 在搜索 id 时，返回的结果列表仅包含一个帖子
            url,
            headers=headers,
            params=params,
        )
        return result

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def revert(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    async def download(
        self,
        limit: int = 20,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        tags: str = "",
        random: bool = False,
        md5: str | None = None,
        save_raws: bool = False,
        save_tags: bool = False,
        overwrite: bool = False,
    ) -> None:
        """
        下载在起始页码与结束页码范围内，指定标签的帖子列表中的帖子；若 all_page 为 True，则下载当前查询标签下所有页码的帖子列表中的帖子

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前查询标签下所有页码的帖子列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            tags (str, optional): 使用标签和元标签进行搜索的帖子查询 (Help:Cheatsheet)，post[tag] 也可以使用。要组合的不同标签使用空格连接，同一标签中的空格使用 _ 替换. Defaults to ''. 表示搜索全站
            random (bool, optional): 在帖子查询下选择随机抽样。若设置为 True，则仅返回 1 页内容. Defaults to False.
            md5 (str, optional): 搜索 MD5 匹配项。优先于所有其他参数. Defaults to ''.
            save_raws (bool, optional): 是否保存帖子 api 响应的元数据（json 格式）. Defaults to False.
            save_tags (bool, optional): 是否保存帖子标签. Defaults to False.
            overwrite (bool, optional): 是否覆盖已有同名文件. Defaults to False.
        """
        # 获取当前查询标签下所有页码的帖子列表中的帖子
        async for i, posts in aenumerate(
            self.index(
                limit=limit,
                start_page=start_page,
                end_page=end_page,
                all_page=all_page,
                tags=tags,
                random=random,
                md5=md5,
            )
        ):
            posts = pd.DataFrame(posts)

            if posts.empty:
                logger.info(f"All of the posts {i + 1} are empty.")
                continue

            # 下载帖子
            urls = posts["file_url"]  # 帖子 URLs
            if md5 is not None:  # 存储文件目录
                posts_directory = os.path.join(self.directory, f"{md5}")  # 帖子文件目录
                images_directory = os.path.join(
                    posts_directory, "images"
                )  # 图像文件目录
            else:
                posts_directory = os.path.join(
                    self.directory, f"{tags if tags != '' else 'all'}"
                )  # 帖子文件目录
                images_directory = os.path.join(
                    posts_directory, "images"
                )  # 图像文件目录

            success_count, failure_count = 0, 0
            async for index, res in aenumerate(
                self.client.concurrent_download_file(
                    urls,
                    images_directory,
                )
            ):
                if res is None:
                    failure_count += 1
                    continue
                else:
                    success_count += 1

                url, filepath = res

                # 保存帖子 api 响应的元数据（json 格式）
                if save_raws:
                    # 保存元数据
                    raws = posts.loc[[index]]  # 筛选后的元数据
                    raws_directory = os.path.join(
                        posts_directory, "raws"
                    )  # 元数据文件目录
                    raws_filename = (
                        os.path.splitext(os.path.basename(filepath))[0] + ".json"
                    )  # 元数据文件名
                    await self.client.save_raws(
                        raws,
                        directory=raws_directory,
                        filename=raws_filename,
                        overwrite=overwrite,
                    )

                # 保存标签
                if save_tags:
                    # 帖子标签
                    tags = posts.at[index, "tag_string"]  # 筛选后的 tags
                    tags_directory = os.path.join(
                        posts_directory, "tags"
                    )  # 标签文件目录
                    tags_filename = (
                        os.path.splitext(os.path.basename(filepath))[0] + ".txt"
                    )  # 标签文件名
                    await self.client.save_tags(
                        tags,
                        directory=tags_directory,
                        filename=tags_filename,
                        overwrite=overwrite,
                    )

            logger.info(
                f"Downloaded {success_count} successful, {failure_count} failed for posts: {posts['id'].tolist()}"
            )


class DanbooruTags(DanbooruComponent):
    """
    Tags: https://danbooru.donmai.us/wiki_pages/api%3Atags
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数 tags 的最大页码

        Note:
            danbooru tags 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际 tags 数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/tags?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/tags.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/tags_controller.rb:10:in 'TagsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {
                "search[hide_empty]": False,
                "search[order]": "date",
            }

        url = "/tags"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        try:
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为最后一页（且为 hidden 属性）
            #!若访问 pools/gallery 页面，则无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
            #!但由于 pools/gallery 与 pools 内容一致，因此可以间接从 pools 页面中获取最后一页页码
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                return int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /tags.json
        Type	        read request
        Description	    The default order is ID descending.

        Search attributes

        The following are the base fields along with their associated type. Check the syntax pages for all of the available variations.

        Number syntax
            - id
            - category
            - post_count
            - created_at
            - updated_at

        String syntax
            - name

        Boolean syntax
            - is_deprecated

        Chaining syntax
            - wiki_page
            - artist
            - antecedent_alias
            - consequent_aliases
            - antecedent_implications
            - consequent_implications
            - consequent_implications
            - dtext_links

        Special search parameters

        The following are additional search fields.

        - fuzzy_name_matches - Shows tags with names that are within a certain percentage of similarity.
        - name_matches - Normalized wildcard search on the name field.
        - name_normalize - Normalized wildcard search on the name field that supports multiple tags split by a comma ",".
        - name_or_alias_matches - Normalized wildcard search on the name field that also checks for aliases where the search term is the antecedent.
        - hide_empty - Shows tags with a post count of 0 (true) or all tags (false) (Help:Boolean syntax). (default False) (M)
        - is_empty - Shows tags with a post count of 0 (true) or a post count greater than 0 (false) (Help:Boolean syntax).

        Search order

        Using the search parameter order with one of the following values changes the order of the results.

        - name - Name ascending.
        - date - ID descending. (default order) (M)
        - count - Post count descending.
        - similarity - Orders by similarity to the search term, then by post count descending, then tag name ascending.
            - Only when the fuzzy_name_matches parameter is used.
        - custom - Help:Common URL parameters
            - In order to use this order, search[id] must also be set with a list of comma-separated IDs.

        Return json format:
        ```
        [
            {
                "id": 2572471,
                "name": "qwqiii_p",
                "post_count": 0,
                "category": 1,
                "created_at": "2026-03-10T05:32:38.423-04:00",
                "updated_at": "2026-03-10T05:32:38.423-04:00",
                "is_deprecated": false,
                "words": [
                    "qwqiii",
                    "p"
                ]
            },
            ...
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的 tags 列表；若 all_page 为 True，则获取当前搜索参数下所有页码的 tags 列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的 tags 列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru tags 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际 tags 数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/tags?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/tags.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/tags_controller.rb:10:in 'TagsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的 tags 内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {
                "search[hide_empty]": False,
                "search[order]": "date",
            }
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/tags.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的图集列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的图集列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res

    async def show(
        self,
        id: int,
    ) -> list[dict] | None:
        """
        Show

        HTTP Method	    GET
        Base URL	    /tags/$id.json
        Type	        read request
        Description	    $id is the tag ID.

        Return json format:
        ```
        {
            "id": 2572471,
            "name": "qwqiii_p",
            "post_count": 0,
            "category": 1,
            "created_at": "2026-03-10T05:32:38.423-04:00",
            "updated_at": "2026-03-10T05:32:38.423-04:00",
            "is_deprecated": false,
            "words": [
                "qwqiii",
                "p"
            ]
        }
        ```

        获取指定 id 的 tags 列表

        Args:
            id (int): tags id

        Note:
            该方法与指定 search[id]/search[name] 参数的 index 方法类似，**但是**返回的结果列表仅包含一个 tags

        Returns:
            list[dict] | None: 若获取成功，则返回对应的 tags 内容列表；若获取失败，则返回 None
        """
        url = f"/tags/{id}.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {}

        # 结果列表
        result: list[
            dict
        ] = await self.client.fetch_page(  # danbooru 在搜索 id 时，返回的结果列表仅包含一个图集
            url,
            headers=headers,
            params=params,
        )
        return result

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def related(self):
        # see Other Functions section: https://danbooru.donmai.us/wiki_pages/help%3Aapi
        # TODO
        raise NotImplementedError("The method is not implemented")

    async def download(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        overwrite: bool = False,
    ) -> None:
        """
        下载在起始页码与结束页码范围内，指定搜索参数的 tags 列表；若 all_page 为 True，则获取当前搜索参数下所有页码的 tags 列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的 tags 列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            overwrite (bool, optional): 是否覆盖已有同名文件. Defaults to False.
        """
        # 获取当前查询下所有页码的 tags 列表
        async for i, tags in aenumerate(
            self.index(
                limit=limit,
                query=query,
                start_page=start_page,
                end_page=end_page,
                all_page=all_page,
            )
        ):
            tags = pd.DataFrame(tags)
            if tags.empty:
                logger.info(f"All of the tags {i + 1} are empty.")
                continue

            # 下载 tags
            tags_directory = os.path.join(self.directory, "tags")  # 标签文件目录
            for index, tag in tags.iterrows():
                tags_filename = f"{tag['id']}.json"  # 标签文件名
                await self.client.save_raws(
                    tags.loc[[index]],
                    directory=tags_directory,
                    filename=tags_filename,
                    overwrite=overwrite,
                )


class DanbooruArtists(DanbooruComponent):
    """
    Artists: https://danbooru.donmai.us/wiki_pages/api%3Aartists
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数 tags 的最大页码

        Note:
            danbooru artists 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际艺术家数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/artists?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/artists.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/artists_controller.rb:9:in 'ArtistsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {
                "search[order]": "created_at",
            }

        url = "/artists"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        try:
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为最后一页（且为 hidden 属性）
            #!若访问 pools/gallery 页面，则无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
            #!但由于 pools/gallery 与 pools 内容一致，因此可以间接从 pools 页面中获取最后一页页码
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                return int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /artists.json
        Type	        read request
        Description	    The default order is ID descending.

        Search attributes

        The following are the base fields along with their associated type. Check the syntax pages for all of the available variations.

        Number syntax
            - id
            - created_at
            - updated_at

        String syntax
            - name
            - group_name

        Boolean syntax
            - is_deleted
            - is_banned

        Array syntax
            - other_names

        Chaining syntax
            - urls
            - wiki_page
            - tag_alias
            - tag

        Special search parameters

        The following are additional search fields.

        - any_other_name_like - Search for artists that have an other name matching this value. Supports wildcards.
        - any_name_matches - Search for artists that have a matching name, group name, or other name. Supports wildcards and regexes.
        - url_matches - Search for artists with a matching URL.
            - Does a regex match when the query starts and ends with a forward slash "/".
                - Regexes must follow the Ruby's format.
            - Does a wildcard match when there are asterisks "*" present.
            - Uses the artist URL finder when the value is prefaced by http:// or https://
                - This does a recursive search on URLs stripping the pathname one level at a time to search for matches.
                - It will keep searching until it finds an exact match or 10 similar entries.
            - Otherwise it does a wildcard search with wildcard placed at the start and end.
        - any_name_or_url_matches - Searches for the artist by name or URL.
            - Does a URL search if the value is prefaced by http:// or https://
            - Does a name search otherwise.

        Search order

        Using the search parameter order with one of the following values changes the order of the results.

        - name - Name descending.
        - created_at - Created at descending. (default order) (A)
        - updated_at - Updated at descending.
        - post_count - Post count descending.
        - custom - Help:Common URL parameters
            - In order to use this order, search[id] must also be set with a list of comma-separated IDs.

        Return json format:
        ```
        [
            {
                "id": 616177,
                "created_at": "2026-03-11T01:45:55.135-04:00",
                "name": "kazari4510_imas",
                "updated_at": "2026-03-11T01:45:55.141-04:00",
                "is_deleted": false,
                "group_name": "",
                "is_banned": false,
                "other_names": [
                    "かざり@"
                ]
            },
            ...
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的艺术家列表；若 all_page 为 True，则获取当前查询参数下所有页码的艺术家列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的艺术家列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru artists 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际艺术家数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/artists?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/artists.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/artists_controller.rb:9:in 'ArtistsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的艺术家内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {
                "search[order]": "created_at",
            }
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/artists.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的图集列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的图集列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res

    async def show(
        self,
        id: int,
    ) -> list[dict] | None:
        """
        Show

        HTTP Method	    GET
        Base URL	    /artists/$id.json
        Type	        read request
        Description	    $id is the artist ID.

        Return json format:
        ```
        {
            "id": 616177,
            "created_at": "2026-03-11T01:45:55.135-04:00",
            "name": "kazari4510_imas",
            "updated_at": "2026-03-11T01:45:55.141-04:00",
            "is_deleted": false,
            "group_name": "",
            "is_banned": false,
            "other_names": [
                "かざり@"
            ]
        }
        ```

        获取指定 id 的艺术家列表

        Args:
            id (int): 艺术家 id

        Note:
            该方法与指定 search[id]/search[name] 参数的 index 方法类似，**但是**返回的结果列表仅包含一个艺术家

        Returns:
            list[dict] | None: 若获取成功，则返回对应的艺术家内容列表；若获取失败，则返回 None
        """
        url = f"/artists/{id}.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {}

        # 结果列表
        result: list[
            dict
        ] = await self.client.fetch_page(  # danbooru 在搜索 id 时，返回的结果列表仅包含一个图集
            url,
            headers=headers,
            params=params,
        )
        return result

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def banned(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def revert(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def ban(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def unban(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    async def download(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        overwrite: bool = False,
    ) -> None:
        """
        下载在起始页码与结束页码范围内，指定搜索参数的艺术家列表；若 all_page 为 True，则获取当前查询参数下所有页码的艺术家列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的艺术家列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            overwrite (bool, optional): 是否覆盖已有同名文件. Defaults to False.
        """
        # 获取当前查询下所有页码的艺术家列表
        async for i, artists in aenumerate(
            self.index(
                limit=limit,
                query=query,
                start_page=start_page,
                end_page=end_page,
                all_page=all_page,
            )
        ):
            artists = pd.DataFrame(artists)
            if artists.empty:
                logger.info(f"All of the artists {i + 1} are empty.")
                continue

            # 下载艺术家
            artists_directory = os.path.join(
                self.directory, "artists"
            )  # 艺术家文件目录
            for index, artist in artists.iterrows():
                artists_filename = f"{artist['id']}.json"  # 艺术家文件名
                await self.client.save_raws(
                    artists.loc[[index]],
                    directory=artists_directory,
                    filename=artists_filename,
                    overwrite=overwrite,
                )


class DanbooruComments(DanbooruComponent):
    """
    Comments: https://danbooru.donmai.us/wiki_pages/api%3Acomments
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    def index(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def show(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def undelete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")


class DanbooruWikiPages(DanbooruComponent):
    """
    Wiki: https://danbooru.donmai.us/wiki_pages/api%3Awiki_pages
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数 wiki 的最大页码

        Note:
            danbooru wiki 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际 wiki 数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/wiki_pages?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/wiki_pages.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/wiki_pages_controller.rb:12:in 'WikiPagesController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {
                "search[order]": "created_at",
            }

        url = "/wiki_pages"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        try:
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为最后一页（且为 hidden 属性）
            #!若访问 pools/gallery 页面，则无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
            #!但由于 pools/gallery 与 pools 内容一致，因此可以间接从 pools 页面中获取最后一页页码
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                return int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /wiki_pages.json
        Type	        read request
        Description	    The default order is updated at descending.

        Search attributes

        The following are the base fields along with their associated type. Check the syntax pages for all of the available variations.

        Number syntax
        - id
        - created_at
        - updated_at

        String syntax
        - title

        Text syntax
        - body

        Array syntax
        - other_names

        Boolean syntax
        - is_deleted
        - is_locked

        Chaining syntax
        - tag
        - artist
        - dtext_links

        Special search parameters

        The following are additional search fields.

        - title_normalize - Normalized case-insensitive wildcard searching on the title text field.
        - other_names_match - Case-insensitive wildcard search on any of the other names.
        - linked_to - Wiki pages that have dtext links to the given wiki page.
            - The parameter must use the same format as the wiki title.
            - I.e. all lowercase and underscores instead of spaces.
        - not_linked_to - Wiki pages that don't have dtext links to the given wiki page.
            - Needs the same format as linked_to.
        - hide_deleted - Hides all deleted wikis (Boolean syntax).
            - Shortcut for search[is_deleted]=false
        - other_names_present - Shows wikis based on the presence of other names (Boolean syntax).
            - Shortcut for search[other_name_count]=>0 (TRUE) and search[other_name_count]=0 (FALSE)
        - has_embedded_media - Wiki pages that have embedded media on them (Boolean syntax).

        Search order

        Using the search parameter order with one of the following values changes the order of the results.

        - title - Title descending.
        - post_count - Post count descending.
        - created_at - Orders by creation time. (default order) (A)
        - custom - Help:Common URL parameters
            - In order to use this order, search[id] must also be set with a list of comma-separated IDs.

        Return json format:
        ```
        [
            {
                "id": 4540,
                "created_at": "2007-01-13T19:36:49.000-05:00",
                "updated_at": "2026-02-13T18:30:02.814-05:00",
                "title": "tag_group:posture",
                "body": "[See [[tag groups]].]\r\n\r\n[expand=Table of Contents]\r\n* 1. \"Basic positions\":#dtext-basic\r\n* 2. \"Movement of the body\":#dtext-move\r\n* 3. \"Other postures potentially involving the whole body\":#dtext-whole\r\n* 4. \"Other rest points of the body\":#dtext-rest\r\n* 5. \"Posture of the head\":#dtext-head\r\n* 6. \"Torso inclination\":#dtext-torso\r\n* 7. \"Arms\":#dtext-arms\r\n** 7.1 \"Basic arm position\":#dtext-basicarm\r\n** 7.2 \"More specific arm position\":#dtext-specificarm\r\n** 7.3 \"Hand position\":#dtext-hand\r\n** 7.4 \"Hands touching each other (one or more characters)\":#dtext-hands\r\n* 8. \"Legs\":#dtext-legs\r\n** 8.1 \"Leg location\":#dtext-leg\r\n** 8.2 \"Knee location\":#dtext-knee\r\n** 8.3 \"Foot position\":#dtext-foot\r\n* 9. \"Posture of at least two characters\":#dtext-two\r\n* 10. \"Posture of at least three characters\":#dtext-three\r\n* 11 \"Hugging\":#dtext-hug\r\n** 11.1 \"Hugging doable by one or more characters\":#dtext-hugone\r\n** 11.2 \"Hugging doable by two or more characters\":#dtext-hugtwo\r\n* 12. \"Carrying someone\":#dtext-carry\r\n* 13. \"Poses\":#dtext-poses\r\n** 13.1 \"Signature poses\":#dtext-signature\r\n* 14. \"See Also\":#dtext-also\r\n[/expand]\r\n\r\nExisting tags describing one person's posture.\r\n\r\nh4#basic. Basic positions\r\n\r\n* [[Kneeling]]\r\n** [[On one knee]]\r\n* [[Lying]]\r\n** [[Crossed legs]]\r\n** [[Fetal position]]\r\n** [[On back]]\r\n** [[On side]]\r\n** [[On stomach]]\r\n* [[Sitting]]\r\n** [[Butterfly sitting]]\r\n** [[Crossed legs]]\r\n*** [[Figure four sitting]]\r\n** [[Indian style]]\r\n*** [[Lotus position]]\r\n** [[Hugging own legs]]\r\n** [[Reclining]]\r\n** [[Seiza]]\r\n** [[Sitting on person]]\r\n*** [[Sitting on head]]\r\n*** [[Sitting on lap]]\r\n*** [[Shoulder carry]]\r\n*** [[Human chair]]\r\n** [[Straddling]]\r\n*** [[Thigh straddling]]\r\n*** [[Upright straddle]]\r\n** [[Wariza]]\r\n** [[Yokozuwari]]\r\n* [[Standing]]\r\n** [[Balancing]]\r\n** [[Crossed legs]]\r\n** [[Legs apart]]\r\n** [[Standing on one leg]]\r\n\r\nh4#move. Movement of the body\r\n\r\n* [[Balancing]]\r\n* [[Crawling]]\r\n* [[Idle animation]]\r\n* [[Midair]]\r\n** [[Falling]]\r\n** [[Floating]]\r\n** [[Flying]]\r\n** [[Jumping]]\r\n*** [[Hopping]]\r\n*** [[Pouncing]]\r\n* [[Running]]\r\n* [[Walking]]\r\n** [[Walk cycle]]\r\n** [[Walking on wall]]\r\n\r\nh4#whole. Other postures potentially involving the whole body\r\n\r\n* [[All fours]]\r\n** [[Top-down bottom-up]]\r\n** [[Prostration]]\r\n** [[Bear position]]\r\n* [[Bowlegged pose]]\r\n* [[Chest stand]]\r\n* [[Chest stand handstand]]\r\n* [[Triplefold]]\r\n* [[Ruppelbend]]\r\n* [[Quadfold]]\r\n* [[Cowering]]\r\n* [[Crucifixion]]\r\n* [[Faceplant]]\r\n** [[Full scorpion]]\r\n* [[Fighting stance]]\r\n** [[Battoujutsu stance]]\r\n* [[Spread eagle position]]\r\n* [[Squatting]]\r\n* [[Stretching]]\r\n* [[Superhero landing]]\r\n* [[Upside-down]]\r\n** [[Handstand]]\r\n** [[Headstand]]\r\n* [[Yoga]]\r\n** [[Scorpion pose]]\r\n** [[revolved head-to-knee pose]] \r\n\r\nh4#rest. Other rest points of the body\r\n\r\n* [[Arm support]]\r\n* [[Head rest]]\r\n\r\nh4#head. Posture of the head\r\n\r\n* See [[tag group:eyes tags]] for the direction of a character's gaze, including [[looking at viewer]], [[looking to the side]], [[looking afar]], etc.\r\n* [[Head down]]\r\n* [[Head tilt]]\r\n* [[Head back]]\r\n\r\nh4#torso. Torso inclination\r\n\r\n* [[Arched back]] — Middle of spine pushed forwards\r\n* [[Bent back]] — Middle of spine pushed backwards\r\n* [[Bent over]]\r\n* [[Leaning back]]\r\n* [[Leaning forward]]\r\n* [[Slouching]]\r\n* [[Sway back]]\r\n* [[Twisted torso]]\r\n\r\nh4#arms. Arms\r\nSee also \"Poses\":[#dtext-poses] section.\r\n\r\nh6#basicarm. Basic arm position\r\n\r\n* [[Arm behind back]] / [[Arms behind back]]\r\n* [[Arm up]]\r\n** [[Arm behind head]]\r\n** See [[tag group:gestures]] for various gestures involving one arm up (including [[akanbe]], [[salute]], [[shushing]], [[v over eye]], [[waving]], etc.)\r\n** [[Victory pose]]\r\n* [[Arms up]]\r\n** [[\\o/]]\r\n** [[Arms behind head]]\r\n** See [[tag group:gestures]] for various gestures involving two arms up (including [[carry me]], [[heart hands]], [[finger frame]], [[horns pose]], etc.)\r\n* [[Outstretched arm]] / [[Outstretched arms]]\r\n* [[Spread arms]]\r\n* [[Arm at side]] / [[Arms at sides]]\r\n\r\nh6#specificarm. More specific arm position\r\n\r\n* [[Airplane arms]]\r\n* [[Crossed arms]]\r\n* [[Flexing]]\r\n* [[Praise the sun]]\r\n* [[Reaching]]\r\n* [[Shrugging]]\r\n* [[T-pose]]\r\n* [[A-pose]]\r\n* [[V arms]]\r\n* [[W arms]]\r\n\r\nh6#hand. Hand position\r\n\r\n* [[Stroking own chin]]\r\n* [[Outstretched hand]]\r\n* See [[tag group:hands]] for the location of the hand, and things touched by a hand (includes [[hand on own ear]], [[hand on own ass]], [[hand in pocket]], etc.)\r\n* See [[tag group:gestures]] for various gestures involving one or both hands.\r\n* [[V]]\r\n\r\nh6#hands. Hands touching each other (one or more characters)\r\n\r\n* [[Interlocked fingers]]\r\n* [[Own hands clasped]]\r\n* [[Own hands together]]\r\n* [[Star hands]]\r\n\r\nh4#hips. Hips\r\n* [[Contrapposto]]\r\n* [[Sway back]]\r\n\r\nh4#legs. Legs\r\n\r\nh6#leg. Leg location\r\n\r\n* [[Crossed ankles]]\r\n* [[Folded]]\r\n* [[Leg up]]\r\n* [[Legs up]]\r\n** [[Knees to chest]]\r\n** [[Legs over head]]\r\n* [[Leg lift]]\r\n* [[Outstretched leg]]\r\n* [[Pin legs]]\r\n* [[Split]]\r\n** [[Pigeon pose]]\r\n** [[Standing split]]\r\n* [[Spread legs]]\r\n* [[Watson cross]] \r\n* [[Uneven footing]]\r\n\r\nh6#knee. Knee location\r\n\r\n* [[Knees apart feet together]]\r\n* [[Knees together feet apart]] \r\n* [[Knee up]] \r\n* [[Knees up]]\r\n\r\nh6#foot. Foot position\r\n\r\n* [[Dorsiflexion]]\r\n* [[Pigeon-toed]]\r\n* [[Plantar flexion]]\r\n* [[Toe scrunch]] \r\n* [[Tiptoes]]\r\n** [[Tiptoe kiss]]\r\n\r\nh4#two. Posture of at least two characters\r\n\r\n* [[Ass-to-ass]]\r\n* [[Back-to-back]]\r\n* [[Belly-to-belly]]\r\n* [[Cheek-to-breast]]\r\n* [[Cheek-to-cheek]]\r\n* [[Eye contact]]\r\n* [[Face-to-face]]\r\n* [[Forehead-to-forehead]]\r\n* [[Head on chest]]\r\n* [[Heads together]]\r\n* [[Holding hands]]\r\n* [[Leg lock]]\r\n* [[Locked arms]]\r\n* [[Over the knee]]\r\n* [[Nipple-to-nipple]]\r\n* [[Noses touching]]\r\n* [[Shoulder-to-shoulder]]\r\n* [[Tail lock]]\r\n\r\nh4#three. Posture of at least three characters\r\n\r\n* [[Circle formation]]\r\n* [[Group hug]]\r\n\r\nh4#hug. Hugging\r\n\r\n* [[Hug]]\r\n\r\nh6#hugone. Hugging doable by one or more characters\r\n\r\n* [[Hugging own legs]]\r\n* [[Hugging object]]\r\n* [[Hugging tail]]\r\n* [[Wing hug]]\r\n\r\nh6#hugtwo. Hugging doable by two or more characters\r\n\r\n* [[Arm hug]]\r\n* [[Hug from behind]]\r\n* [[Waist hug]]\r\n\r\nh4#carry. Carrying someone\r\n\r\n* [[Baby carry]]\r\n* [[Carrying]]\r\n** [[Carried breast rest]]\r\n** [[Carrying over shoulder]]\r\n** [[Carrying under arm]]\r\n** [[Child carry]]\r\n** [[Fireman's carry]]\r\n** [[Piggyback]]\r\n** [[Princess carry]]\r\n** [[Shoulder carry]]\r\n** [[Sitting on shoulder]]\r\n** [[Standing on shoulder]]\r\n\r\nh4#poses. Poses\r\n\r\n* Animal pose\r\n** [[Rabbit pose]]\r\n** [[Horns pose]]\r\n** [[Paw pose]]\r\n** [[Claw pose]]\r\n* [[Archer pose]]\r\n* [[Bras d'honneur]]\r\n* [[Body bridge]]\r\n* [[Contrapposto]]\r\n* [[Dojikko pose]]\r\n* [[Ghost pose]]\r\n* [[Inugami-ke no Ichizoku pose]] — Upside-down with only legs visible\r\n* [[Letter pose]]\r\n* [[Ojou-sama pose]]\r\n* [[Saboten pose]]\r\n* [[Symmetrical hand pose]]\r\n* [[Victory pose]]\r\n* [[Villain pose]]\r\n* [[Zombie pose]]\r\n\r\nh5#signature. Signature poses\r\n\r\n* [[Charizard pose]] \r\n* [[Gendou pose]] — Fingers intertwined above the mouth\r\n* [[Henshin pose]]\r\n* [[JoJo pose]]\r\n** [[Dio Brando's pose]] — Menacing, mostly facing away, but glancing at the viewer\r\n** [[Giorno Giovanna's pose]] — Legs apart, one arm doing a half-[[Superman exposure]]\r\n** [[Jonathan Joestar's pose]] — One hand in front of the face, one at the side facing back\r\n** [[Kujo Jotaro's pose]] — Pointing disdainfully\r\n* [[Kongou pose]] — Confident stance with an outstretched hand\r\n* [[Kujou Karen pose]] — Double finger guns with one arm bent\r\n* [[Z-Move trainer pose]] \r\n\r\nh4#also. See Also\r\n\r\n* [[Tag groups]]\r\n* [[Tag group:Gestures]]\r\n* [[Tag group:Sexual positions]]\r\n* [[List of poses]]\r\n* {{pose|Pose tag search}}",
                "is_locked": false,
                "other_names": [],
                "is_deleted": false
            }
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的 wiki 列表；若 all_page 为 True，则获取当前搜索参数下所有页码的 wiki 列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的 wiki 列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru wiki 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际 wiki 数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/wiki_pages?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/wiki_pages.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/wiki_pages_controller.rb:12:in 'WikiPagesController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的 wiki 内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {
                "search[order]": "created_at",
            }
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/wiki_pages.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的图集列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的图集列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res

    async def show(
        self,
        id: int,
    ) -> list[dict] | None:
        """
        Show

        HTTP Method	    GET
        Base URL	    /wiki_pages/$id.json
        Type	        read request
        Description	    $id is the wiki page ID or title.

        Return json format:
        ```
        {
            "id": 4540,
            "created_at": "2007-01-13T19:36:49.000-05:00",
            "updated_at": "2026-02-13T18:30:02.814-05:00",
            "title": "tag_group:posture",
            "body": "[See [[tag groups]].]\r\n\r\n[expand=Table of Contents]\r\n* 1. \"Basic positions\":#dtext-basic\r\n* 2. \"Movement of the body\":#dtext-move\r\n* 3. \"Other postures potentially involving the whole body\":#dtext-whole\r\n* 4. \"Other rest points of the body\":#dtext-rest\r\n* 5. \"Posture of the head\":#dtext-head\r\n* 6. \"Torso inclination\":#dtext-torso\r\n* 7. \"Arms\":#dtext-arms\r\n** 7.1 \"Basic arm position\":#dtext-basicarm\r\n** 7.2 \"More specific arm position\":#dtext-specificarm\r\n** 7.3 \"Hand position\":#dtext-hand\r\n** 7.4 \"Hands touching each other (one or more characters)\":#dtext-hands\r\n* 8. \"Legs\":#dtext-legs\r\n** 8.1 \"Leg location\":#dtext-leg\r\n** 8.2 \"Knee location\":#dtext-knee\r\n** 8.3 \"Foot position\":#dtext-foot\r\n* 9. \"Posture of at least two characters\":#dtext-two\r\n* 10. \"Posture of at least three characters\":#dtext-three\r\n* 11 \"Hugging\":#dtext-hug\r\n** 11.1 \"Hugging doable by one or more characters\":#dtext-hugone\r\n** 11.2 \"Hugging doable by two or more characters\":#dtext-hugtwo\r\n* 12. \"Carrying someone\":#dtext-carry\r\n* 13. \"Poses\":#dtext-poses\r\n** 13.1 \"Signature poses\":#dtext-signature\r\n* 14. \"See Also\":#dtext-also\r\n[/expand]\r\n\r\nExisting tags describing one person's posture.\r\n\r\nh4#basic. Basic positions\r\n\r\n* [[Kneeling]]\r\n** [[On one knee]]\r\n* [[Lying]]\r\n** [[Crossed legs]]\r\n** [[Fetal position]]\r\n** [[On back]]\r\n** [[On side]]\r\n** [[On stomach]]\r\n* [[Sitting]]\r\n** [[Butterfly sitting]]\r\n** [[Crossed legs]]\r\n*** [[Figure four sitting]]\r\n** [[Indian style]]\r\n*** [[Lotus position]]\r\n** [[Hugging own legs]]\r\n** [[Reclining]]\r\n** [[Seiza]]\r\n** [[Sitting on person]]\r\n*** [[Sitting on head]]\r\n*** [[Sitting on lap]]\r\n*** [[Shoulder carry]]\r\n*** [[Human chair]]\r\n** [[Straddling]]\r\n*** [[Thigh straddling]]\r\n*** [[Upright straddle]]\r\n** [[Wariza]]\r\n** [[Yokozuwari]]\r\n* [[Standing]]\r\n** [[Balancing]]\r\n** [[Crossed legs]]\r\n** [[Legs apart]]\r\n** [[Standing on one leg]]\r\n\r\nh4#move. Movement of the body\r\n\r\n* [[Balancing]]\r\n* [[Crawling]]\r\n* [[Idle animation]]\r\n* [[Midair]]\r\n** [[Falling]]\r\n** [[Floating]]\r\n** [[Flying]]\r\n** [[Jumping]]\r\n*** [[Hopping]]\r\n*** [[Pouncing]]\r\n* [[Running]]\r\n* [[Walking]]\r\n** [[Walk cycle]]\r\n** [[Walking on wall]]\r\n\r\nh4#whole. Other postures potentially involving the whole body\r\n\r\n* [[All fours]]\r\n** [[Top-down bottom-up]]\r\n** [[Prostration]]\r\n** [[Bear position]]\r\n* [[Bowlegged pose]]\r\n* [[Chest stand]]\r\n* [[Chest stand handstand]]\r\n* [[Triplefold]]\r\n* [[Ruppelbend]]\r\n* [[Quadfold]]\r\n* [[Cowering]]\r\n* [[Crucifixion]]\r\n* [[Faceplant]]\r\n** [[Full scorpion]]\r\n* [[Fighting stance]]\r\n** [[Battoujutsu stance]]\r\n* [[Spread eagle position]]\r\n* [[Squatting]]\r\n* [[Stretching]]\r\n* [[Superhero landing]]\r\n* [[Upside-down]]\r\n** [[Handstand]]\r\n** [[Headstand]]\r\n* [[Yoga]]\r\n** [[Scorpion pose]]\r\n** [[revolved head-to-knee pose]] \r\n\r\nh4#rest. Other rest points of the body\r\n\r\n* [[Arm support]]\r\n* [[Head rest]]\r\n\r\nh4#head. Posture of the head\r\n\r\n* See [[tag group:eyes tags]] for the direction of a character's gaze, including [[looking at viewer]], [[looking to the side]], [[looking afar]], etc.\r\n* [[Head down]]\r\n* [[Head tilt]]\r\n* [[Head back]]\r\n\r\nh4#torso. Torso inclination\r\n\r\n* [[Arched back]] — Middle of spine pushed forwards\r\n* [[Bent back]] — Middle of spine pushed backwards\r\n* [[Bent over]]\r\n* [[Leaning back]]\r\n* [[Leaning forward]]\r\n* [[Slouching]]\r\n* [[Sway back]]\r\n* [[Twisted torso]]\r\n\r\nh4#arms. Arms\r\nSee also \"Poses\":[#dtext-poses] section.\r\n\r\nh6#basicarm. Basic arm position\r\n\r\n* [[Arm behind back]] / [[Arms behind back]]\r\n* [[Arm up]]\r\n** [[Arm behind head]]\r\n** See [[tag group:gestures]] for various gestures involving one arm up (including [[akanbe]], [[salute]], [[shushing]], [[v over eye]], [[waving]], etc.)\r\n** [[Victory pose]]\r\n* [[Arms up]]\r\n** [[\\o/]]\r\n** [[Arms behind head]]\r\n** See [[tag group:gestures]] for various gestures involving two arms up (including [[carry me]], [[heart hands]], [[finger frame]], [[horns pose]], etc.)\r\n* [[Outstretched arm]] / [[Outstretched arms]]\r\n* [[Spread arms]]\r\n* [[Arm at side]] / [[Arms at sides]]\r\n\r\nh6#specificarm. More specific arm position\r\n\r\n* [[Airplane arms]]\r\n* [[Crossed arms]]\r\n* [[Flexing]]\r\n* [[Praise the sun]]\r\n* [[Reaching]]\r\n* [[Shrugging]]\r\n* [[T-pose]]\r\n* [[A-pose]]\r\n* [[V arms]]\r\n* [[W arms]]\r\n\r\nh6#hand. Hand position\r\n\r\n* [[Stroking own chin]]\r\n* [[Outstretched hand]]\r\n* See [[tag group:hands]] for the location of the hand, and things touched by a hand (includes [[hand on own ear]], [[hand on own ass]], [[hand in pocket]], etc.)\r\n* See [[tag group:gestures]] for various gestures involving one or both hands.\r\n* [[V]]\r\n\r\nh6#hands. Hands touching each other (one or more characters)\r\n\r\n* [[Interlocked fingers]]\r\n* [[Own hands clasped]]\r\n* [[Own hands together]]\r\n* [[Star hands]]\r\n\r\nh4#hips. Hips\r\n* [[Contrapposto]]\r\n* [[Sway back]]\r\n\r\nh4#legs. Legs\r\n\r\nh6#leg. Leg location\r\n\r\n* [[Crossed ankles]]\r\n* [[Folded]]\r\n* [[Leg up]]\r\n* [[Legs up]]\r\n** [[Knees to chest]]\r\n** [[Legs over head]]\r\n* [[Leg lift]]\r\n* [[Outstretched leg]]\r\n* [[Pin legs]]\r\n* [[Split]]\r\n** [[Pigeon pose]]\r\n** [[Standing split]]\r\n* [[Spread legs]]\r\n* [[Watson cross]] \r\n* [[Uneven footing]]\r\n\r\nh6#knee. Knee location\r\n\r\n* [[Knees apart feet together]]\r\n* [[Knees together feet apart]] \r\n* [[Knee up]] \r\n* [[Knees up]]\r\n\r\nh6#foot. Foot position\r\n\r\n* [[Dorsiflexion]]\r\n* [[Pigeon-toed]]\r\n* [[Plantar flexion]]\r\n* [[Toe scrunch]] \r\n* [[Tiptoes]]\r\n** [[Tiptoe kiss]]\r\n\r\nh4#two. Posture of at least two characters\r\n\r\n* [[Ass-to-ass]]\r\n* [[Back-to-back]]\r\n* [[Belly-to-belly]]\r\n* [[Cheek-to-breast]]\r\n* [[Cheek-to-cheek]]\r\n* [[Eye contact]]\r\n* [[Face-to-face]]\r\n* [[Forehead-to-forehead]]\r\n* [[Head on chest]]\r\n* [[Heads together]]\r\n* [[Holding hands]]\r\n* [[Leg lock]]\r\n* [[Locked arms]]\r\n* [[Over the knee]]\r\n* [[Nipple-to-nipple]]\r\n* [[Noses touching]]\r\n* [[Shoulder-to-shoulder]]\r\n* [[Tail lock]]\r\n\r\nh4#three. Posture of at least three characters\r\n\r\n* [[Circle formation]]\r\n* [[Group hug]]\r\n\r\nh4#hug. Hugging\r\n\r\n* [[Hug]]\r\n\r\nh6#hugone. Hugging doable by one or more characters\r\n\r\n* [[Hugging own legs]]\r\n* [[Hugging object]]\r\n* [[Hugging tail]]\r\n* [[Wing hug]]\r\n\r\nh6#hugtwo. Hugging doable by two or more characters\r\n\r\n* [[Arm hug]]\r\n* [[Hug from behind]]\r\n* [[Waist hug]]\r\n\r\nh4#carry. Carrying someone\r\n\r\n* [[Baby carry]]\r\n* [[Carrying]]\r\n** [[Carried breast rest]]\r\n** [[Carrying over shoulder]]\r\n** [[Carrying under arm]]\r\n** [[Child carry]]\r\n** [[Fireman's carry]]\r\n** [[Piggyback]]\r\n** [[Princess carry]]\r\n** [[Shoulder carry]]\r\n** [[Sitting on shoulder]]\r\n** [[Standing on shoulder]]\r\n\r\nh4#poses. Poses\r\n\r\n* Animal pose\r\n** [[Rabbit pose]]\r\n** [[Horns pose]]\r\n** [[Paw pose]]\r\n** [[Claw pose]]\r\n* [[Archer pose]]\r\n* [[Bras d'honneur]]\r\n* [[Body bridge]]\r\n* [[Contrapposto]]\r\n* [[Dojikko pose]]\r\n* [[Ghost pose]]\r\n* [[Inugami-ke no Ichizoku pose]] — Upside-down with only legs visible\r\n* [[Letter pose]]\r\n* [[Ojou-sama pose]]\r\n* [[Saboten pose]]\r\n* [[Symmetrical hand pose]]\r\n* [[Victory pose]]\r\n* [[Villain pose]]\r\n* [[Zombie pose]]\r\n\r\nh5#signature. Signature poses\r\n\r\n* [[Charizard pose]] \r\n* [[Gendou pose]] — Fingers intertwined above the mouth\r\n* [[Henshin pose]]\r\n* [[JoJo pose]]\r\n** [[Dio Brando's pose]] — Menacing, mostly facing away, but glancing at the viewer\r\n** [[Giorno Giovanna's pose]] — Legs apart, one arm doing a half-[[Superman exposure]]\r\n** [[Jonathan Joestar's pose]] — One hand in front of the face, one at the side facing back\r\n** [[Kujo Jotaro's pose]] — Pointing disdainfully\r\n* [[Kongou pose]] — Confident stance with an outstretched hand\r\n* [[Kujou Karen pose]] — Double finger guns with one arm bent\r\n* [[Z-Move trainer pose]] \r\n\r\nh4#also. See Also\r\n\r\n* [[Tag groups]]\r\n* [[Tag group:Gestures]]\r\n* [[Tag group:Sexual positions]]\r\n* [[List of poses]]\r\n* {{pose|Pose tag search}}",
            "is_locked": false,
            "other_names": [],
            "is_deleted": false
        }
        ```

        获取指定 id 的 wiki 列表

        Args:
            id (int): wiki id

        Note:
            该方法与指定 search[id]/search[name] 参数的 index 方法类似，**但是**返回的结果列表仅包含一个 wiki 页面

        Returns:
            list[dict] | None: 若获取成功，则返回对应的 wiki 内容列表；若获取失败，则返回 None
        """
        url = f"/wiki_pages/{id}.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {}

        # 结果列表
        result: list[
            dict
        ] = await self.client.fetch_page(  # danbooru 在搜索 id 时，返回的结果列表仅包含一个图集
            url,
            headers=headers,
            params=params,
        )
        return result

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def revert(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    async def download(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        overwrite: bool = False,
    ) -> None:
        """
        下载在起始页码与结束页码范围内，指定搜索参数的 wiki 列表；若 all_page 为 True，则获取当前搜索参数下所有页码的 wiki 列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的 wiki 列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            overwrite (bool, optional): 是否覆盖已有同名文件. Defaults to False.
        """
        # 获取当前查询下所有页码的 wiki 列表
        async for i, wikis in aenumerate(
            self.index(
                limit=limit,
                query=query,
                start_page=start_page,
                end_page=end_page,
                all_page=all_page,
            )
        ):
            wikis = pd.DataFrame(wikis)
            if wikis.empty:
                logger.info(f"All of the wikis {i + 1} are empty.")
                continue

            # 下载 wiki
            wiki_directory = os.path.join(self.directory, "wikis")  # wiki 文件目录
            for index, wiki in wikis.iterrows():
                wiki_filename = f"{wiki['id']}.json"  # wiki 文件名
                await self.client.save_raws(
                    wikis.loc[[index]],
                    directory=wiki_directory,
                    filename=wiki_filename,
                    overwrite=overwrite,
                )


class DanbooruNotes(DanbooruComponent):
    """
    Notes: https://danbooru.donmai.us/wiki_pages/api%3Anotes
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    def index(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def show(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def revert(self):
        # TODO
        raise NotImplementedError("The method is not implemented")


class DanbooruUsers(DanbooruComponent):
    """
    Users: https://danbooru.donmai.us/wiki_pages/api%3Ausers
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    def index(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def show(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")


class DanbooruForumPosts(DanbooruComments):
    """
    Forum Posts: https://danbooru.donmai.us/wiki_pages/api%3Aforum_posts
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    def index(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def show(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def undelete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")


class DanbooruPools(DanbooruComponent):
    """
    Pools: https://danbooru.donmai.us/wiki_pages/api%3Apools
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数图集的最大页码

        Note:
            danbooru pool 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际图集数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/pools?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/pools.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/pools_controller.rb:20:in 'PoolsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {}

        url = "/pools"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        try:
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为最后一页（且为 hidden 属性）
            #!若访问 pools/gallery 页面，则无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
            #!但由于 pools/gallery 与 pools 内容一致，因此可以间接从 pools 页面中获取最后一页页码
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                return int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /pools.json
        Type	        read request
        Description	    The default order is updated at descending.

        Search attributes

        All of the following are standard attributes with all of their available formats and qualifiers.

        - Number syntax
            - id
            - created_at
            - updated_at

        - Text syntax
            - name
            - description

        - Array syntax
            - post_ids

        - Boolean syntax
            - is_deleted

        Special search parameters

        - name_matches - Normalized case-insensitive wildcard searching on the name text field.
        - description_matches - Case-insensitive wildcard searching on the description text field.
        - post_tags_match - The pools's post's tags match the given terms. Meta-tags not supported.
        - name_contains - same as name_matches. (A)

        - category
            - series - Only series-type pools.
            - collection - Only collection-type pools.
        - order - Sets the order of the results.
            - updated_at - Orders by last updated time. (default order) (M)
            - name - Alphabetic order by name.
            - created_at - Orders by creation time.
            - post_count - Orders by post count.

        Return json format:
        ```
        [
            {
                "id": 25767,
                "name": "Girls_Band_Cry_and_Various_-_100_Days_Challenge_(yun_cao_bing)",
                "created_at": "2025-05-20T01:23:26.659-04:00",
                "updated_at": "2025-08-07T11:45:41.889-04:00",
                "description": "Artist: [[yun_cao_bing]]\r\nOrdered by the datetime of posting (on bilibili). Earliest post first.",
                "is_active": true,
                "is_deleted": false,
                "post_ids": [9010243, 9010244, 9014311, 9019533, 9020707, 9035262, 9035264, 9035294, 9035296, 9040170, 9042951, 9048727, 9049726, 9052845, 9057388, 9058039, 9062619, 9063534, 9067162, 9069531, 9072575, 9074083, 9078824, 9082553, 9082831, 9084833, 9087985, 9093112, 9096829, 9099633, 9104261, 9105053, 9114809, 9114797, 9115504, 9119794, 9124730, 9129614, 9134214, 9139246, 9145623, 9146696, 9150634, 9158009, 9160080, 9160892, 9170450, 9170455, 9177628, 9176669, 9186695, 9190286, 9191692, 9192507, 9197299, 9197872, 9203217, 9207738, 9207746, 9213064, 9213191, 9220683, 9218351, 9217986, 9222382, 9230860, 9231573, 9231583, 9236637, 9241311, 9241294, 9246839, 9254557, 9254558, 9257069, 9262469, 9335067, 9276436, 9278466, 9283178, 9335084, 9295031, 9303663, 9335100, 9305054, 9311246, 9314899, 9315618, 9320750, 9329180, 9340814, 9341231, 9341451, 9351622, 9351626, 9356375, 9357906, 9361750, 9361755, 9366438, 9367126, 9376322, 9376326, 9380890, 9382704, 9386440, 9387665, 9390664, 9391322, 9391384, 9403709, 9403713, 9404616, 9408321, 9414354, 9414697, 9419024, 9429320, 9430577, 9430591, 9436293, 9442566, 9447301, 9452519, 9457358, 9466457, 9468799, 9474177, 9474970, 9479506, 9485999, 9492681, 9493639, 9498176, 9503207, 9503208, 9508829, 9508835, 9514749, 9514753, 9520176, 9520180, 9539389, 9539394, 9542784, 9542816, 9551233, 9562986, 9584331, 9589851, 9590826, 9607135, 9678528, 9678538, 9694827, 9701048, 9709089, 9720352, 9729953, 9741358, 9746778, 9756829],
                "category": "series",
                "post_count": 162
            },
            ...
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的图集列表；若 all_page 为 True，则获取当前搜索参数下所有页码的图集列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的图集列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru pool 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际图集数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/pools?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/pools.json?limit=1000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/pools_controller.rb:20:in 'PoolsController#index'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的图集内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {}
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/pools.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的图集列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的图集列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res

    async def show(
        self,
        id: int,
    ) -> list[dict] | None:
        """
        Show

        HTTP Method	    GET
        Base URL	    /pools/$id.json
        Type	        read request
        Description	    $id is the pool ID

        Return json format:
        ```
        {
            "id": 25767,
            "name": "Girls_Band_Cry_and_Various_-_100_Days_Challenge_(yun_cao_bing)",
            "created_at": "2025-05-20T01:23:26.659-04:00",
            "updated_at": "2025-08-07T11:45:41.889-04:00",
            "description": "Artist: [[yun_cao_bing]]\r\nOrdered by the datetime of posting (on bilibili). Earliest post first.",
            "is_active": true,
            "is_deleted": false,
            "post_ids": [9010243, 9010244, 9014311, 9019533, 9020707, 9035262, 9035264, 9035294, 9035296, 9040170, 9042951, 9048727, 9049726, 9052845, 9057388, 9058039, 9062619, 9063534, 9067162, 9069531, 9072575, 9074083, 9078824, 9082553, 9082831, 9084833, 9087985, 9093112, 9096829, 9099633, 9104261, 9105053, 9114809, 9114797, 9115504, 9119794, 9124730, 9129614, 9134214, 9139246, 9145623, 9146696, 9150634, 9158009, 9160080, 9160892, 9170450, 9170455, 9177628, 9176669, 9186695, 9190286, 9191692, 9192507, 9197299, 9197872, 9203217, 9207738, 9207746, 9213064, 9213191, 9220683, 9218351, 9217986, 9222382, 9230860, 9231573, 9231583, 9236637, 9241311, 9241294, 9246839, 9254557, 9254558, 9257069, 9262469, 9335067, 9276436, 9278466, 9283178, 9335084, 9295031, 9303663, 9335100, 9305054, 9311246, 9314899, 9315618, 9320750, 9329180, 9340814, 9341231, 9341451, 9351622, 9351626, 9356375, 9357906, 9361750, 9361755, 9366438, 9367126, 9376322, 9376326, 9380890, 9382704, 9386440, 9387665, 9390664, 9391322, 9391384, 9403709, 9403713, 9404616, 9408321, 9414354, 9414697, 9419024, 9429320, 9430577, 9430591, 9436293, 9442566, 9447301, 9452519, 9457358, 9466457, 9468799, 9474177, 9474970, 9479506, 9485999, 9492681, 9493639, 9498176, 9503207, 9503208, 9508829, 9508835, 9514749, 9514753, 9520176, 9520180, 9539389, 9539394, 9542784, 9542816, 9551233, 9562986, 9584331, 9589851, 9590826, 9607135, 9678528, 9678538, 9694827, 9701048, 9709089, 9720352, 9729953, 9741358, 9746778, 9756829],
            "category": "series",
            "post_count": 162
        }
        ```

        获取指定 id 的图集列表

        Args:
            id (int): 图集 id

        Note:
            该方法与指定 search[id]/search[name] 参数的 index 方法类似，**但是**返回的结果列表仅包含一个图集

        Returns:
            list[dict] | None: 若获取成功，则返回对应的图集内容列表；若获取失败，则返回 None
        """
        url = f"/pools/{id}.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {}

        # 结果列表
        result: list[
            dict
        ] = await self.client.fetch_page(  # danbooru 在搜索 id 时，返回的结果列表仅包含一个图集
            url,
            headers=headers,
            params=params,
        )
        return result

    def create(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def update(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def delete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def undelete(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    def revert(self):
        # TODO
        raise NotImplementedError("The method is not implemented")

    async def download(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
        save_raws: bool = False,
        save_tags: bool = False,
        overwrite: bool = False,
    ) -> None:
        """
        下载在起始页码与结束页码范围内，指定搜索参数的图集列表中的帖子；若 all_page 为 True，则下载当前搜索参数下所有页码的图集列表中的帖子

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否下载当前搜索参数下所有页码的图集列表中的帖子，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.
            save_raws (bool, optional): 是否保存帖子 api 响应的元数据（json 格式）. Defaults to False.
            save_tags (bool, optional): 是否保存帖子标签. Defaults to False.
            overwrite (bool, optional): 是否覆盖已有同名文件. Defaults to False.
        """
        if query is None:
            query = {}

        # 获取当前搜索参数下所有页码的图集列表
        async for i, pools in aenumerate(
            self.index(
                limit=limit,
                query=query,
                start_page=start_page,
                end_page=end_page,
                all_page=all_page,
            )
        ):
            pools = pd.DataFrame(pools)

            # 过滤空图集
            empty_mask = pools["post_count"] == 0  # danbooru 中可能存在空图集
            if not empty_mask.empty:
                empty_pools = pools[empty_mask]
                logger.info(
                    f"Found {len(empty_pools)} empty pools, which will be ignored. Empty pools: {empty_pools['name'].to_list()}"
                )
                pools = pools[~empty_mask]

            if pools.empty:
                logger.info(f"All of the pools {i + 1} are empty.")
                continue

            # 图集中的帖子 id 列表
            post_ids = pools["post_ids"]
            # 图集名称
            names = pools["name"]

            # 遍历图集列表
            for ids, name in zip(post_ids, names):
                # 异步任务列表
                tasks = [
                    self.client.posts.show(id=id) for id in ids
                ]  # 委托给 DanbooruPosts 类的 show 方法以获得单个 id 下的帖子
                # 并发获取图集 ID 下所有帖子
                task_results: list[
                    list[dict] | None
                ] = await self.client.batch_process_tasks(tasks)
                # 合并所有帖子
                posts = pd.DataFrame(
                    [post for res in task_results for post in res if res is not None]
                )

                # 下载帖子
                urls = posts["file_url"]  # 帖子 URLs
                posts_directory = os.path.join(
                    self.directory, f"{name}"
                )  # 帖子文件目录
                images_directory = os.path.join(
                    posts_directory, "images"
                )  # 图像文件目录

                success_count, failure_count = 0, 0
                async for index, res in aenumerate(
                    self.client.concurrent_download_file(
                        urls,
                        images_directory,
                    )
                ):
                    if res is None:
                        failure_count += 1
                        continue
                    else:
                        success_count += 1
                    url, filepath = res

                    # 保存帖子 api 响应的元数据（json 格式）
                    if save_raws:
                        # 保存元数据
                        raws = posts.loc[[index]]  # 筛选后的元数据
                        raws_directory = os.path.join(
                            posts_directory, "raws"
                        )  # 元数据文件目录
                        raws_filename = (
                            os.path.splitext(os.path.basename(filepath))[0] + ".json"
                        )  # 元数据文件名
                        await self.client.save_raws(
                            raws,
                            directory=raws_directory,
                            filename=raws_filename,
                            overwrite=overwrite,
                        )

                    # 保存标签
                    if save_tags:
                        # 帖子标签
                        tags = posts.at[index, "tag_string"]  # 筛选后的 tags
                        tags_directory = os.path.join(
                            posts_directory, "tags"
                        )  # 标签文件目录
                        tags_filename = (
                            os.path.splitext(os.path.basename(filepath))[0] + ".txt"
                        )  # 标签文件名
                        await self.client.save_tags(
                            tags,
                            directory=tags_directory,
                            filename=tags_filename,
                            overwrite=overwrite,
                        )

                logger.info(
                    f"Downloaded {success_count} successful, {failure_count} failed for pool: {name}"
                )


class DanbooruPostVersions(DanbooruComponent):
    """
    Post Version: https://danbooru.donmai.us/wiki_pages/api%3Apost_versions
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数帖子版本的最大页码

        Note:
            danbooru post history 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际帖子数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/post_versions?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/post_versions.json?limit=10000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/post_versions_controller.rb:11:in 'PostVersionsController#index'",
                    "app/controllers/post_versions_controller.rb:37:in 'PostVersionsController#set_timeout'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {}

        url = "/post_versions"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        #!当前 post_versions 页码，无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
        try:
            return self.client.MAX_PAGE  # 暂未实现，返回最大页数（超出最大页数的请求返回为空，不会影响最后数据获取的总量，但会延长程序运行的时间）

            #!very slowly way, start with page 1
            current_page = 1
            #!first request, check pagination is exist or not
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为当前页向后 +4 页或最后一页
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                current_page = int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1

            # TODO 改变 xpath 表达式（并非从第一页开始）
            #!gap some page
            params["page"] = gap_page = current_page
            while gap_page < 1000:
                current_page = gap_page
                response = await self.client.get(url, headers=headers, params=params)
                # 解析 html 分页器中的最大页码
                tree = etree.HTML(response.text)
                # TODO
                pagination = tree.xpath("???")
                if pagination:  # 存在分页器，说明该页面至少有两页
                    gap_page  # TODO update gap_page
                    params["page"] = gap_page
                else:
                    pass

            # TODO 改变 xpath 表达式（并非从第一页开始）
            #!slow down, check any remain page is exist or not
            for i in range(
                current_page, 1000 + 1
            ):  # (1 + 4 * 249) = 997, range in [997, 1000]
                params["page"] = i
                response = await self.client.get(url, headers=headers, params=params)
                # 解析 html 分页器中的最大页码
                tree = etree.HTML(response.text)
                # TODO
                pagination = tree.xpath("???")
                if pagination:  # 存在分页器，说明该页面至少有两页
                    pass
                else:  # 不存在分页器，说明该页面只有一页
                    pass
            return current_page
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /post_versions.json
        Type	        read request
        Description	    The default order is ID descending.

        Search attributes

        All of the following are standard attributes with all of their available formats and qualifiers.

        - Number syntax
            - id
            - post_id
            - parent_id
            - updater_id
            - version
            - created_at
            - updated_at

        - Text syntax
            - tags
            - rating
            - source

        - Boolean syntax
            - rating_changed
            - parent_changed
            - source_changed

        - Array syntax
            - added_tags
            - removed_tags

        Special search parameters

        - changed_tags - Search where all tags must be either an added tag or removed tag
            - The list of tags is space-delineated
        - all_changed_tags - The same as changed_tags
        - changed_tags - Search where at least one tag must be either an added tag or removed tag
            - The list of tags is space-delineated
        - tag_matches - Case-insensitive search of the tag string with first tag from the input
            - If asterisks ( * ) are missing from the input, it adds an asterisk to either side of the tag
        - updater_name - Searches by updater name instead of updater ID
        - is_new - Boolean syntax
            - Shorthand search for version=1 or version=>1

        Return json format:
        ```
        [
            {
                "id": 9755122,
                "post_id": 1382152,
                "tags": "/\\/\\/\\ 1boy 1girl ? breasts chinese cleavage comic forehead_jewel forehead_protector highres kumiko_(aleron) large_breasts league_of_legends monochrome navel popped_collar staff sweatdrop swimsuit talon_(league_of_legends) thighhighs translation_request trembling",
                "added_tags": [],
                "removed_tags": [
                    "sling_bikini"
                ],
                "updater_id": 23449,
                "updated_at": "2013-03-24T20:11:07.925-04:00",
                "rating": "q",
                "rating_changed": false,
                "parent_id": null,
                "parent_changed": false,
                "source": "http://i1.pixiv.net/img79/img/allan9706/34464563_big_p29.jpg",
                "source_changed": false,
                "version": 5,
                "obsolete_added_tags": "",
                "obsolete_removed_tags": "",
                "unchanged_tags": "/\\/\\/\\ 1boy 1girl ? breasts chinese cleavage comic forehead_jewel forehead_protector highres kumiko_(aleron) large_breasts league_of_legends monochrome navel popped_collar staff sweatdrop swimsuit talon_(league_of_legends) thighhighs translation_request trembling"
            }
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的帖子版本列表；若 all_page 为 True，则获取当前搜索参数下所有页码的帖子版本列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:post versions](https://danbooru.donmai.us/wiki_pages/api%3Apost_versions). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的帖子版本列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru post history 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际帖子版本数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/post_versions?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/post_versions.json?limit=10000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/post_versions_controller.rb:11:in 'PostVersionsController#index'",
                    "app/controllers/post_versions_controller.rb:37:in 'PostVersionsController#set_timeout'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的帖子版本内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {}
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/post_versions.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的帖子版本列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的帖子版本列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res


class DanbooruPoolVersions(DanbooruComponent):
    """
    Pools: https://danbooru.donmai.us/wiki_pages/api%3Apool_versions
    """

    def __init__(self, client: DanbooruClient):
        super().__init__(client)

    async def index_page(
        self,
        limit: int = 20,
        query: dict | None = None,
    ) -> int:
        """
        使用定位 html 分页器的方式，获取指定搜索参数图集版本的最大页码

        Note:
            danbooru pool history 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际图集版本数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/pool_versions?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/pool_versions.json?limit=10000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/pool_versions_controller.rb:10:in 'PoolVersionsController#index'",
                    "app/controllers/pool_versions_controller.rb:35:in 'PoolVersionsController#set_timeout'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:pools](https://danbooru.donmai.us/wiki_pages/api%3Apools). Defaults to None. 表示搜索全站

        Returns:
            int: html 分页器中的最大页码，实际的最大页码等于该页码
        """
        if query is None:
            query = {}

        url = "/pool_versions"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        #!当前 pool_versions 页码，无法获取最后一页页码（原网页中唯独缺少了该页码的 a 标签）
        try:
            return self.client.MAX_PAGE  # 暂未实现，返回最大页数（超出最大页数的请求返回为空，不会影响最后数据获取的总量，但会延长程序运行的时间）

            #!very slowly way, start with page 1
            current_page = 1
            #!first request, check pagination is exist or not
            response = await self.client.get(url, headers=headers, params=params)
            # 解析 html 分页器中的最大页码
            tree = etree.HTML(response.text)
            # 当前页为 span 标签，只有一页时，仅存在 span 标签；超过一页时，余下的页为 a 标签，最后一个 a 标签为下一页，倒数第二个 a 标签为当前页向后 +4 页或最后一页
            pagination = tree.xpath('//div[contains(@class, "paginator")]/a')
            if pagination:  # 存在分页器，说明该页面至少有两页
                current_page = int(pagination[-2].xpath("./text()")[0])
            else:  # 不存在分页器，说明该页面只有一页
                return 1

            # TODO 改变 xpath 表达式（并非从第一页开始）
            #!gap some page
            params["page"] = gap_page = current_page
            while gap_page < 1000:
                current_page = gap_page
                response = await self.client.get(url, headers=headers, params=params)
                # 解析 html 分页器中的最大页码
                tree = etree.HTML(response.text)
                # TODO
                pagination = tree.xpath("???")
                if pagination:  # 存在分页器，说明该页面至少有两页
                    gap_page  # TODO update gap_page
                    params["page"] = gap_page
                else:
                    pass

            # TODO 改变 xpath 表达式（并非从第一页开始）
            #!slow down, check any remain page is exist or not
            for i in range(
                current_page, 1000 + 1
            ):  # (1 + 4 * 249) = 997, range in [997, 1000]
                params["page"] = i
                response = await self.client.get(url, headers=headers, params=params)
                # 解析 html 分页器中的最大页码
                tree = etree.HTML(response.text)
                # TODO
                pagination = tree.xpath("???")
                if pagination:  # 存在分页器，说明该页面至少有两页
                    pass
                else:  # 不存在分页器，说明该页面只有一页
                    pass
            return current_page
        except httpx.HTTPError as exc:
            logger.error(f"{exc.__class__.__name__} for {exc.request.url} - {exc}")

    async def index_version(
        self,
        limit: int = 20,
        query: dict | None = None,
        start_page: int = 1,
        end_page: int = 1,
        all_page: bool = False,
    ) -> AsyncIterable[list[dict] | None]:
        """
        Index

        HTTP Method	    GET
        Base URL	    /pool_versions.json
        Type	        read request
        Description	    The default order is ID descending.

        Search attributes

        All of the following are standard attributes with all of their available formats and qualifiers.

        - Number syntax
            - id
            - pool_id
            - updater_id
            - version
            - created_at
            - updated_at

        - Text syntax
            - name
            - description
            - category

        - Boolean syntax
            - name_changed
            - description_changed
            - is_active
            - is_deleted

        - Array syntax
            - post_ids
            - added_post_ids
            - removed_post_ids

        Special search parameters

        - name_matches - Case-insensitive normalized wildcard search on the name field. current version can't use. (M)
        - updater_name - Case-insensitive updater name to updater ID search.
        - post_id - Searches for a single post being added or removed from a pool.
        - is_new - Boolean syntax
        - Shorthand search for version=1 or version=>1
        - name_contains - same as name_matches. (A)

        Return json format:
        ```
        [
            {
                "id": 747009,
                "pool_id": 25767,
                "post_ids": [9010243, 9010244, 9014311, 9019533, 9020707, 9035262, 9035264, 9035294, 9035296, 9040170, 9042951, 9048727, 9049726, 9052845, 9057388, 9058039, 9062619, 9063534, 9067162, 9069531, 9072575, 9074083, 9078824, 9082553, 9082831, 9084833, 9087985, 9093112, 9096829, 9099633, 9104261, 9105053, 9114809, 9114797, 9115504, 9119794, 9124730, 9129614, 9134214, 9139246, 9145623, 9146696, 9150634, 9158009, 9160080, 9160892, 9170450, 9170455, 9177628, 9176669, 9186695, 9190286, 9191692, 9192507, 9197299, 9197872, 9203217, 9207738, 9207746, 9213064, 9213191, 9220683, 9218351, 9217986, 9222382, 9230860, 9231573, 9231583, 9236637, 9241311, 9241294, 9246839, 9254557, 9254558, 9257069, 9262469, 9335067, 9276436, 9278466, 9283178, 9335084, 9295031, 9303663, 9335100, 9305054, 9311246, 9314899, 9315618, 9320750, 9329180, 9340814, 9341231, 9341451, 9351622, 9351626, 9356375, 9357906, 9361750, 9361755, 9366438, 9367126, 9376322, 9376326, 9380890, 9382704, 9386440, 9387665, 9390664, 9391322, 9391384, 9403709, 9403713, 9404616, 9408321, 9414354, 9414697, 9419024, 9429320, 9430577, 9430591, 9436293, 9442566, 9447301, 9452519, 9457358, 9466457, 9468799, 9474177, 9474970, 9479506, 9485999, 9492681, 9493639, 9498176, 9503207, 9503208, 9508829, 9508835, 9514749, 9514753, 9520176, 9520180, 9539389, 9539394, 9542784, 9542816, 9551233, 9562986, 9584331, 9589851, 9590826, 9607135, 9678528, 9678538, 9694827, 9701048, 9709089, 9720352, 9729953, 9741358, 9746778, 9756829, 9766771],
                "added_post_ids": [9766771],
                "removed_post_ids": [],
                "updater_id": 480070,
                "description": "Artist: [[yun_cao_bing]]\r\nOrdered by the datetime of posting (on bilibili). Earliest post first.",
                "description_changed": false,
                "name": "Girls_Band_Cry_and_Various_-_100_Days_Challenge_(yun_cao_bing)",
                "name_changed": false,
                "created_at": "2025-05-20T01:23:26.000-04:00",
                "updated_at": "2025-08-09T07:47:27.000-04:00",
                "version": 61,
                "is_active": true,
                "boolean": false,
                "is_deleted": false,
                "category": "series"
            },
            ...
        ]
        ```

        获取在起始页码与结束页码范围内，指定搜索参数的图集版本列表；若 all_page 为 True，则获取当前搜索参数下所有页码的图集版本列表

        Args:
            limit (int, optional): 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000. Defaults to 20.
            query (dict | None, optional): 搜索参数，必须遵循 danbooru 中定义的语法，参见 [help:common url parameters](https://danbooru.donmai.us/wiki_pages/help:common_url_parameters) 以及 [api:post versions](https://danbooru.donmai.us/wiki_pages/api%3Apost_versions). Defaults to None. 表示搜索全站
            start_page (int, optional): 查询起始页码. Defaults to 1.
            end_page (int, optional): 查询结束页码. Defaults to 1.
            all_page (bool, optional): 是否获取当前搜索参数下所有页码的图集版本列表，若为 True，则忽略 start_page 与 end_page 参数. Defaults to False.

        Note:
            danbooru pool history 页面展示策略受 limit 参数影响，会自动根据 limit 参数与实际图集版本数量调整 html 分页器中的最大页码
            page 受账号等级影响 (see help:users)，对普通无账户或会员用户，每次搜索的最大页数为 1000
            否则在 https://danbooru.donmai.us/pool_versions?limit=1000&page=1001 网页中会弹出 Search Error. You cannot go beyond page 1000. Try narrowing your search terms, or upgrade your account to go beyond page 1000. 提示
            在 https://danbooru.donmai.us/pool_versions.json?limit=10000&page=1001 网页中会返回：
            ```
            {
                "success": false,
                "error": "PaginationExtension::PaginationError",
                "message": "You cannot go beyond page 1000.",
                "backtrace": [
                    "app/logical/pagination_extension.rb:54:in 'PaginationExtension#paginate'",
                    "app/models/application_record.rb:18:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginate'",
                    "app/models/application_record.rb:37:in 'ApplicationRecord::PaginationMethods::ClassMethods#paginated_search'",
                    "app/controllers/pool_versions_controller.rb:10:in 'PoolVersionsController#index'",
                    "app/controllers/pool_versions_controller.rb:35:in 'PoolVersionsController#set_timeout'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

            search[name_matches] 搜索参数版本当前无法使用，返回结果为：
            ```
            {
                "success": false,
                "error": "ActiveRecord::StatementInvalid",
                "message": "",
                "backtrace": [
                    "app/logical/pagination_extension.rb:141:in 'PaginationExtension#records'",
                    "app/logical/application_responder.rb:29:in 'ApplicationResponder#to_format'",
                    "app/controllers/application_controller.rb:82:in 'ApplicationController#respond_with'",
                    "app/controllers/pool_versions_controller.rb:13:in 'PoolVersionsController#index'",
                    "app/controllers/pool_versions_controller.rb:35:in 'PoolVersionsController#set_timeout'",
                    "app/logical/rack_server_timing.rb:19:in 'RackServerTiming#call'"
                ]
            }
            ```

        Yields:
            AsyncIterable[list[dict] | None]: 若获取成功，则返回对应的图集版本内容列表；若获取失败，则返回 None
        """
        if query is None:
            query = {}
        if limit > 1000:  # 事实上，超过该值时，返回的结果会被截断到该值
            limit = 1000
            logger.warning(
                f"Limit is set to {limit}, Because it exceeds the maximum allowed value of 1000."
            )

        url = "/pool_versions.json"
        headers = {
            "User-Agent": "python",  # wtf? why you let this UA pass and block my normal UA?
        }
        params = {
            "limit": limit,  # 每页返回的结果数量。对于 /posts.json 最大限制为 200，其他情况为 1000
            "page": 1,  # 查询页码
        }
        # 更新搜索参数
        params.update(query)

        # TODO: 添加检验用户账号等级逻辑，以便调整每次搜索的最大页数
        self.client.MAX_PAGE

        # 获取当前搜索参数下所有页码的图集版本列表
        if all_page:
            max_page = await self.index_page(  # 获取 html 分页器中的最大页码
                limit=limit,
                query=query,
            )
            logger.info(
                f"Maximum page number is equal to {max_page} for {limit = }, {query = }"
            )

            if max_page > self.client.MAX_PAGE:
                remain_page = max_page - self.client.MAX_PAGE
                max_page = self.client.MAX_PAGE  # 限制最大页码不超过每次搜索的最大页数
                logger.warning(
                    f"Maximum page number is set to {max_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=1,
                end_page=max_page,
                page_key="page",
            ):
                yield res

        # 获取在起始页码与结束页码范围内，指定标题的图集版本列表
        else:
            if start_page > self.client.MAX_PAGE:
                remain_page = start_page - self.client.MAX_PAGE
                start_page = self.client.MAX_PAGE
                logger.warning(
                    f"Start page is set to {start_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )
            if end_page > self.client.MAX_PAGE:
                remain_page = end_page - self.client.MAX_PAGE
                end_page = self.client.MAX_PAGE
                logger.warning(
                    f"End page is set to {end_page} for {limit = }, {query = }, because {self.client.MAX_PAGE + remain_page} exceeds {self.client.MAX_PAGE}"
                )

            async for res in self.client.concurrent_fetch_page(
                url,
                headers=headers,
                params=params,
                start_page=start_page,
                end_page=end_page,
                page_key="page",
            ):
                yield res
