"""分类页/搜索页 __INITIAL_STATE__ → GenreData 分类数据来自 state.data.genreTree.parent_category。它不是完整分类树,而是一条 「从根一路展开到目标分类」的链:每层只保留通往目标的那一个子节点,目标分类自身 则挂着它的全部直接子分类。 不带分类查询时,根节点直接挂着 39 个顶层分类。 """ from __future__ import annotations from typing import Any from app.scraping.core import site from app.shared.errors import ScrapeParseError from app.scraping.models.scrape import GenreData, GenreNode from app.scraping.utils.coerce import as_dict, as_int, as_list, as_str # 站点用 id=0 表示分类树的虚拟根,它不是一个真实分类 ROOT_GENRE_ID = 0 def _genre_url(genre_id: str) -> str: return f"{site.CATEGORY_BASE_URL}{genre_id}/" if genre_id else "" def _to_node(raw: dict[str, Any], *, with_count: bool) -> GenreNode: genre_id = str(as_int(raw.get("id"))) count = raw.get("count") return GenreNode( genre_id=genre_id, name=as_str(raw.get("name")), item_count=as_int(count) if with_count and count is not None else None, shortcut=as_str(raw.get("shortcut")), is_leaf=bool(raw.get("leaf")), url=_genre_url(genre_id), ) def _find_path(node: dict[str, Any], genre_id: str) -> list[dict[str, Any]] | None: """在分类链中定位目标分类,返回从根到它的节点路径(含自身)""" if str(as_int(node.get("id"))) == genre_id: return [node] for child in as_list(node.get("children")): if not isinstance(child, dict): continue found = _find_path(child, genre_id) if found is not None: return [node, *found] return None def parse_genres(state: dict[str, Any], *, genre_id: str | None) -> GenreData: """解析分类树 Args: genre_id: 目标分类;None 表示取顶层分类列表 Raises: ScrapeParseError: 页面里没有分类树,或目标分类不在返回的链上 """ data = as_dict(as_dict(state.get("state")).get("data")) root = as_dict(as_dict(data.get("genreTree")).get("parent_category")) if not root: raise ScrapeParseError("页面中缺少 genreTree.parent_category 节点") if genre_id is None: children = [ _to_node(child, with_count=False) for child in as_list(root.get("children")) if isinstance(child, dict) ] if not children: raise ScrapeParseError("未能取到顶层分类列表") return GenreData(children=children) path = _find_path(root, genre_id) if path is None: raise ScrapeParseError(f"分类树中未找到分类 {genre_id}") target = path[-1] ancestors = [ _to_node(node, with_count=False) for node in path[:-1] if as_int(node.get("id")) != ROOT_GENRE_ID ] children = [ _to_node(child, with_count=True) for child in as_list(target.get("children")) if isinstance(child, dict) ] genre_info = as_dict(data.get("genreInfo")) resolved_id = str(as_int(target.get("id"))) return GenreData( genre_id=resolved_id, name=as_str(target.get("name")), full_name=as_str(genre_info.get("fullGenreName")), description=as_str(genre_info.get("description")), is_leaf=bool(target.get("leaf")), url=_genre_url(resolved_id), ancestors=ancestors, children=children, )