"""关键词搜索：query → content_id 列表（翻页 + 去重 + 截断）。

接口（实测 2026-06-26，host = settings.aiddit_crawler_base_url）：
    小红书 POST /crawler/xiao_hong_shu/keyword
      body{keyword, sort_type:"综合", publish_time:"", cursor:""}
      → {code:0, data:{has_more, next_cursor, data:[{id:<content_id>, note_card:{...}}]}}
    抖音   POST /crawler/dou_yin/keyword
      body{keyword, content_type:"视频", sort_type:"综合排序", publish_time:"不限",
           cursor:"0", account_id:"771431222"}   # ← 缺 account_id 会 code 10000
      → {code:0, data:{has_more, next_cursor, data:[{aweme_id:<content_id>, ...}]}}

各平台请求参数 / id 字段不同，集中在 PLATFORM_SEARCH 表里。
搜索只给预览；全文/全图/视频仍需 crawler.fetch_post_detail 逐 id 取详情（detail 只需 content_id）。
parse_search_response 纯函数可离线测；search_keyword 负责 HTTP + 翻页。
"""
from __future__ import annotations

from dataclasses import dataclass, field
import re
from typing import Any, Optional
from urllib.parse import urljoin

import httpx

from core.config import Settings
from core.text_limits import TITLE_FALLBACK_MAX_CHARS, clip_text
from acquisition.crawler import (
    CRAWLER_MAX_INTERVAL_SECONDS,
    CRAWLER_MIN_INTERVAL_SECONDS,
    RateLimiter,
)

# 每平台的搜索参数差异（path / 条目 id 字段 / 排序 / 发布时间 / 起始 cursor / account_id）
PLATFORM_SEARCH = {
    "xiaohongshu": {
        "path": "/crawler/xiao_hong_shu/keyword", "id_key": "id",
        "sort_type": "综合", "publish_time": "", "cursor0": "", "account_id": None,
    },
    "douyin": {
        "path": "/crawler/dou_yin/keyword", "id_key": "aweme_id",
        "sort_type": "综合排序", "publish_time": "不限", "cursor0": "0",
        # account_id 改由 settings.piaoquantv_douyin_account_id 提供（抖音走 piaoquantv 后端）；缺它搜索报错
        "account_id": None,
    },
}


class SearchError(RuntimeError):
    pass


@dataclass(frozen=True)
class SearchHit:
    content_id: str
    provider: str = ""
    raw: dict[str, Any] = field(default_factory=dict)
    request_payload: dict[str, Any] = field(default_factory=dict)


@dataclass(frozen=True)
class SearchPage:
    rows: list[Any]
    raw_count: int
    cursor: str = ""
    next_cursor: str = ""
    has_more: bool = False
    provider: str = ""
    request_payload: dict[str, Any] = field(default_factory=dict)
    raw_metadata: dict[str, Any] = field(default_factory=dict)


DOUYIN_PROVIDERS = ("piaoquantv", "aiddit")


def _douyin_cursor0(provider: str) -> str:
    return "" if provider == "aiddit" else "0"


def _douyin_base_url(settings: Settings, provider: str) -> str:
    if provider == "piaoquantv":
        return settings.piaoquantv_douyin_base_url
    if provider == "aiddit":
        return settings.aiddit_crawler_base_url
    raise SearchError(f"unsupported douyin provider: {provider}")


def _douyin_search_body(
    keyword: str,
    *,
    provider: str,
    cursor: str,
    settings: Settings,
    content_type: str | None,
    sort_type: str | None,
    publish_time: str | None,
    account_id: str | None,
) -> dict[str, Any]:
    if provider == "piaoquantv":
        body: dict[str, Any] = {
            "keyword": keyword,
            "sort_type": sort_type or "综合排序",
            "publish_time": publish_time or "不限",
            "cursor": cursor,
        }
        if content_type:
            body["content_type"] = content_type
        body["account_id"] = account_id or settings.piaoquantv_douyin_account_id
        body["cookie_batch"] = settings.piaoquantv_douyin_cookie_batch
        return body
    if provider == "aiddit":
        body = {
            "keyword": keyword,
            "cursor": cursor,
            "sort_type": sort_type or "最多点赞",
        }
        if content_type:
            body["content_type"] = content_type
        return body
    raise SearchError(f"unsupported douyin provider: {provider}")


def _parse_search_items(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[dict[str, Any]], int, bool, str]:
    if not isinstance(response, dict):
        raise SearchError("bad_response: not a dict")
    code = response.get("code")
    if code not in (0, "0"):
        raise SearchError(f"business_error: code={code} msg={response.get('msg')}")
    data = response.get("data") or {}
    items = data.get("data") or []
    if not isinstance(items, list):
        raise SearchError("bad_response: data.data is not a list")
    id_key = (PLATFORM_SEARCH.get(platform) or {}).get("id_key", "id")
    valid = [it for it in items if isinstance(it, dict) and it.get(id_key)]
    return valid, len(items), bool(data.get("has_more")), str(data.get("next_cursor") or "")


def parse_search_response(response: Any, *, platform: str = "xiaohongshu") -> tuple[list[str], bool, str]:
    """信封 {code,msg,data:{has_more,next_cursor,data:[...]}} → (content_ids, has_more, next_cursor)。
    条目 id 字段按平台取（小红书 id / 抖音 aweme_id）。code!=0 抛 SearchError。"""
    id_key = (PLATFORM_SEARCH.get(platform) or {}).get("id_key", "id")
    items, _, has_more, next_cursor = _parse_search_items(response, platform=platform)
    ids = [it[id_key] for it in items]
    return ids, has_more, next_cursor


def search_douyin_page(
    keyword: str,
    *,
    provider: str = "piaoquantv",
    cursors: dict[str, str] | None = None,
    content_type: str | None = "视频",
    sort_type: Optional[str] = None,
    publish_time: Optional[str] = None,
    account_id: Optional[str] = None,
    limit: int = 10,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> SearchPage:
    """Fetch one Douyin page, using piaoquantv first and AIDDIT as fallback."""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    cursors = dict(cursors or {p: _douyin_cursor0(p) for p in DOUYIN_PROVIDERS})
    provider_order = [provider] + [p for p in DOUYIN_PROVIDERS if p != provider]
    errors: list[str] = []
    try:
        for attempt_provider in provider_order:
            cursor = cursors.get(attempt_provider, _douyin_cursor0(attempt_provider))
            body = _douyin_search_body(
                keyword,
                provider=attempt_provider,
                cursor=cursor,
                settings=settings,
                content_type=content_type,
                sort_type=sort_type,
                publish_time=publish_time,
                account_id=account_id,
            )
            rate_limiter.wait("douyin")
            url = urljoin(_douyin_base_url(settings, attempt_provider), PLATFORM_SEARCH["douyin"]["path"])
            try:
                resp = client.post(
                    url,
                    json=body,
                    headers={"Content-Type": "application/json"},
                    timeout=settings.crawler_timeout,
                )
                resp.raise_for_status()
                data = resp.json()
                items, raw_count, has_more, next_cursor = _parse_search_items(data, platform="douyin")
            except httpx.HTTPError as exc:
                errors.append(f"{attempt_provider}: http_error: {exc}")
                continue
            except ValueError:
                errors.append(f"{attempt_provider}: bad_json")
                continue
            except SearchError as exc:
                errors.append(f"{attempt_provider}: {exc}")
                continue
            if not items:
                errors.append(f"{attempt_provider}: empty_aweme_id")
                continue

            id_key = PLATFORM_SEARCH["douyin"]["id_key"]
            hits = [
                SearchHit(
                    content_id=str(item[id_key]),
                    provider=attempt_provider,
                    raw=item,
                    request_payload=dict(body),
                )
                for item in items[:limit]
            ]
            next_cursors = dict(cursors)
            if has_more and next_cursor and next_cursor != cursor:
                next_cursors[attempt_provider] = next_cursor
            return SearchPage(
                rows=hits,
                raw_count=raw_count,
                cursor=cursor,
                next_cursor=next_cursor,
                has_more=has_more,
                provider=attempt_provider,
                request_payload=dict(body),
                raw_metadata={"cursors": next_cursors, "errors": errors},
            )
    finally:
        if owns_client:
            client.close()
    raise SearchError("; ".join(errors) or "douyin_search_failed")


def search_douyin_hits(
    keyword: str,
    *,
    content_type: str | None = "视频",
    sort_type: Optional[str] = None,
    publish_time: Optional[str] = None,
    account_id: Optional[str] = None,
    limit: int = 5,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
    max_pages: int = 10,
) -> list[SearchHit]:
    """Douyin search with piaoquantv primary and AIDDIT fallback per cursor."""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    out: list[SearchHit] = []
    seen: set[str] = set()
    provider = "piaoquantv"
    cursors = {p: _douyin_cursor0(p) for p in DOUYIN_PROVIDERS}
    try:
        for _ in range(max_pages):
            page = search_douyin_page(
                keyword,
                provider=provider,
                cursors=cursors,
                content_type=content_type,
                sort_type=sort_type,
                publish_time=publish_time,
                account_id=account_id,
                limit=limit,
                settings=settings,
                http_client=client,
                rate_limiter=rate_limiter,
                env_file=env_file,
            )
            page_hits = list(page.rows)
            provider = page.provider
            current_cursor = cursors.get(provider, _douyin_cursor0(provider))
            for hit in page_hits:
                if hit.content_id not in seen:
                    seen.add(hit.content_id)
                    out.append(hit)
                    if len(out) >= limit:
                        return out
            if not page.has_more or not page.next_cursor or page.next_cursor == current_cursor:
                break
            cursors = dict(page.raw_metadata.get("cursors") or cursors)
    finally:
        if owns_client:
            client.close()
    return out[:limit]


def search_keyword(
    keyword: str,
    *,
    platform: str = "xiaohongshu",
    content_type: str | None = "图文",
    sort_type: Optional[str] = None,       # None → 平台默认（小红书:综合 / 抖音:综合排序）
    publish_time: Optional[str] = None,     # None → 平台默认（抖音:不限）
    account_id: Optional[str] = None,       # None → 平台默认（抖音:771431222；小红书无）
    limit: int = 5,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
    max_pages: int = 10,
) -> list[str]:
    """搜索 → content_id 列表（翻页直到够 limit 或 has_more=False；按 id 去重、截断 limit）。
    平台专属参数（path/sort_type/publish_time/起始cursor/account_id）取自 PLATFORM_SEARCH，
    显式入参可覆盖。复用 crawler.RateLimiter（'{platform}_keyword' bucket）。失败抛 SearchError。"""
    cfg = PLATFORM_SEARCH.get(platform)
    if not cfg:
        raise SearchError(f"unsupported platform: {platform}")
    settings = settings or Settings.from_env(env_file)
    is_douyin = platform == "douyin"
    if account_id is None:
        account_id = settings.piaoquantv_douyin_account_id if is_douyin else cfg["account_id"]
    if is_douyin:
        return [
            hit.content_id
            for hit in search_douyin_hits(
                keyword,
                content_type=content_type,
                sort_type=sort_type,
                publish_time=publish_time,
                account_id=account_id,
                limit=limit,
                settings=settings,
                http_client=http_client,
                rate_limiter=rate_limiter,
                env_file=env_file,
                max_pages=max_pages,
            )
        ]
    sort_type = cfg["sort_type"] if sort_type is None else sort_type
    publish_time = cfg["publish_time"] if publish_time is None else publish_time
    base_url = settings.piaoquantv_douyin_base_url if is_douyin else settings.aiddit_crawler_base_url
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )

    owns_client = http_client is None
    client = http_client or httpx.Client()
    out: list[str] = []
    seen: set[str] = set()
    cursor = cfg["cursor0"]
    try:
        for _ in range(max_pages):
            rate_limiter.wait(f"{platform}_keyword")
            url = urljoin(base_url, cfg["path"])
            body = {"keyword": keyword,
                    "sort_type": sort_type, "publish_time": publish_time, "cursor": cursor}
            if content_type:
                body["content_type"] = content_type
            if account_id:
                body["account_id"] = account_id   # 抖音必带；小红书不带
            if is_douyin:
                body["cookie_batch"] = settings.piaoquantv_douyin_cookie_batch   # piaoquantv 抖音必带
            try:
                resp = client.post(url, json=body,
                                   headers={"Content-Type": "application/json"},
                                   timeout=settings.crawler_timeout)
                resp.raise_for_status()
                data = resp.json()
            except httpx.HTTPError as exc:
                raise SearchError(f"http_error: {exc}") from exc
            except ValueError as exc:
                raise SearchError("bad_json") from exc
            ids, has_more, next_cursor = parse_search_response(data, platform=platform)
            for cid in ids:
                if cid not in seen:
                    seen.add(cid)
                    out.append(cid)
                    if len(out) >= limit:
                        return out
            if not has_more or not next_cursor or next_cursor == cursor:
                break
            cursor = next_cursor
    finally:
        if owns_client:
            client.close()
    return out[:limit]


# ---------- 微信公众号搜索（回的是带 url/封面的文章，不是纯 id，故单列）----------
_EM = re.compile(r"<[^>]+>")


def parse_weixin(response: Any, *, limit: int = 5) -> list[dict]:
    """微信搜索回包 {code,data:{data:[{title,url,cover_url,nick_name,time}]}} → 文章列表（title 去 <em> 标记）。"""
    if not isinstance(response, dict):
        raise SearchError("bad_response: not a dict")
    if response.get("code") not in (0, "0"):
        raise SearchError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
    items = ((response.get("data") or {}).get("data")) or []
    out: list[dict] = []
    for it in items:
        if not isinstance(it, dict) or not it.get("url"):
            continue
        out.append({"title": _EM.sub("", it.get("title", "")).strip(),
                    "url": it.get("url", ""), "cover_url": it.get("cover_url") or "",
                    "nick_name": it.get("nick_name") or "", "time": it.get("time") or ""})
        if len(out) >= limit:
            break
    return out


def search_weixin(
    keyword: str,
    *,
    limit: int = 5,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> list[dict]:
    """微信公众号搜索：query → 文章列表（带 url/封面，无需 detail）。失败抛 SearchError。"""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    try:
        rate_limiter.wait("weixin_keyword")
        url = urljoin(settings.aiddit_crawler_base_url, "/crawler/wei_xin/keyword")
        try:
            resp = client.post(url, json={"keyword": keyword, "cursor": "0"},
                               headers={"Content-Type": "application/json"},
                               timeout=settings.crawler_timeout)
            resp.raise_for_status()
            data = resp.json()
        except httpx.HTTPError as exc:
            raise SearchError(f"http_error: {exc}") from exc
        except ValueError as exc:
            raise SearchError("bad_json") from exc
        return parse_weixin(data, limit=limit)
    finally:
        if owns_client:
            client.close()


def search_weixin_page(
    keyword: str,
    *,
    cursor: str = "0",
    limit: int = 10,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> SearchPage:
    """微信公众号搜索单页：query + cursor → SearchPage。"""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    body = {"keyword": keyword, "cursor": cursor}
    try:
        rate_limiter.wait("weixin_keyword")
        url = urljoin(settings.aiddit_crawler_base_url, "/crawler/wei_xin/keyword")
        try:
            resp = client.post(url, json=body,
                               headers={"Content-Type": "application/json"},
                               timeout=settings.crawler_timeout)
            resp.raise_for_status()
            data = resp.json()
        except httpx.HTTPError as exc:
            raise SearchError(f"http_error: {exc}") from exc
        except ValueError as exc:
            raise SearchError("bad_json") from exc
        if not isinstance(data, dict):
            raise SearchError("bad_response: not a dict")
        if data.get("code") not in (0, "0"):
            raise SearchError(f"business_error: code={data.get('code')} msg={data.get('msg')}")
        envelope = data.get("data") or {}
        raw_rows = envelope.get("data") or []
        if not isinstance(raw_rows, list):
            raise SearchError("bad_response: data.data is not a list")
        return SearchPage(
            rows=parse_weixin(data, limit=limit),
            raw_count=len(raw_rows),
            cursor=cursor,
            next_cursor=str(envelope.get("next_cursor") or ""),
            has_more=bool(envelope.get("has_more")),
            provider="aiddit",
            request_payload=body,
        )
    finally:
        if owns_client:
            client.close()


def fetch_weixin_detail(
    url: str,
    *,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> tuple[str, list[str]]:
    """微信公众号文章详情：content_link → (body_text, image_urls)。实测不需 token。失败抛 SearchError。"""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    try:
        rate_limiter.wait("weixin_detail")
        api = urljoin(settings.aiddit_crawler_base_url, "/crawler/wei_xin/detail")
        try:
            resp = client.post(api, json={"content_link": url},
                               headers={"Content-Type": "application/json"},
                               timeout=settings.crawler_timeout)
            resp.raise_for_status()
            data = resp.json()
        except httpx.HTTPError as exc:
            raise SearchError(f"http_error: {exc}") from exc
        except ValueError as exc:
            raise SearchError("bad_json") from exc
        if data.get("code") not in (0, "0"):
            raise SearchError(f"business_error: code={data.get('code')} msg={data.get('msg')}")
        inner = ((data.get("data") or {}).get("data")) or {}
        imgs = []
        for im in inner.get("image_url_list") or []:
            u = im.get("image_url") if isinstance(im, dict) else im
            if u:
                imgs.append(u)
        return inner.get("body_text") or "", imgs
    finally:
        if owns_client:
            client.close()


# ---------- 小红书搜索（封面/标题/作者已在搜索回包的 note_card 里，无需 detail）----------
def parse_xiaohongshu(response: Any, *, limit: int = 5) -> list[dict]:
    """小红书搜索回包 {code,data:{data:[{id, note_card:{image_list,display_title,desc,user,type}}]}}
    → 帖子列表，直接取封面(image_list[0].image_url)+标题+作者，省去逐条 detail。无视频直链。"""
    if not isinstance(response, dict):
        raise SearchError("bad_response: not a dict")
    if response.get("code") not in (0, "0"):
        raise SearchError(f"business_error: code={response.get('code')} msg={response.get('msg')}")
    items = ((response.get("data") or {}).get("data")) or []
    out: list[dict] = []
    for it in items:
        if not isinstance(it, dict):
            continue
        cid = it.get("id")
        nc = it.get("note_card") or {}
        img_list = nc.get("image_list") or []
        cover = (img_list[0] or {}).get("image_url") if img_list else ""
        if not cid or not cover:
            continue
        title = (nc.get("display_title") or "").strip()
        if not title:
            title = clip_text((nc.get("desc") or "").strip().split("\n")[0], TITLE_FALLBACK_MAX_CHARS)
        out.append({"id": cid, "title": title, "cover_url": cover,
                    "nick_name": (nc.get("user") or {}).get("nickname") or "",
                    "type": nc.get("type") or "",
                    "url": f"https://www.xiaohongshu.com/explore/{cid}"})
        if len(out) >= limit:
            break
    return out


def search_xiaohongshu(
    keyword: str,
    *,
    content_type: str | None = None,
    limit: int = 5,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> list[dict]:
    """小红书搜索：query → 帖子列表（带 url/封面/标题，无需 detail；视频帖无直链）。失败抛 SearchError。"""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    try:
        rate_limiter.wait("xiaohongshu_keyword")
        url = urljoin(settings.aiddit_crawler_base_url, "/crawler/xiao_hong_shu/keyword")
        try:
            body = {"keyword": keyword, "sort_type": "综合", "publish_time": "", "cursor": ""}
            if content_type:
                body["content_type"] = content_type
            resp = client.post(url, json=body,
                               headers={"Content-Type": "application/json"},
                               timeout=settings.crawler_timeout)
            resp.raise_for_status()
            data = resp.json()
        except httpx.HTTPError as exc:
            raise SearchError(f"http_error: {exc}") from exc
        except ValueError as exc:
            raise SearchError("bad_json") from exc
        return parse_xiaohongshu(data, limit=limit)
    finally:
        if owns_client:
            client.close()


def search_xiaohongshu_page(
    keyword: str,
    *,
    cursor: str = "",
    content_type: str | None = None,
    limit: int = 10,
    settings: Optional[Settings] = None,
    http_client: Any = None,
    rate_limiter: Optional[RateLimiter] = None,
    env_file: str = ".env",
) -> SearchPage:
    """小红书搜索单页：query + cursor → SearchPage。"""
    settings = settings or Settings.from_env(env_file)
    rate_limiter = rate_limiter or RateLimiter(
        min_interval_seconds=CRAWLER_MIN_INTERVAL_SECONDS,
        max_interval_seconds=CRAWLER_MAX_INTERVAL_SECONDS,
    )
    owns_client = http_client is None
    client = http_client or httpx.Client()
    body = {"keyword": keyword, "sort_type": "综合", "publish_time": "", "cursor": cursor}
    if content_type:
        body["content_type"] = content_type
    try:
        rate_limiter.wait("xiaohongshu_keyword")
        url = urljoin(settings.aiddit_crawler_base_url, "/crawler/xiao_hong_shu/keyword")
        try:
            resp = client.post(url, json=body,
                               headers={"Content-Type": "application/json"},
                               timeout=settings.crawler_timeout)
            resp.raise_for_status()
            data = resp.json()
        except httpx.HTTPError as exc:
            raise SearchError(f"http_error: {exc}") from exc
        except ValueError as exc:
            raise SearchError("bad_json") from exc
        if not isinstance(data, dict):
            raise SearchError("bad_response: not a dict")
        if data.get("code") not in (0, "0"):
            raise SearchError(f"business_error: code={data.get('code')} msg={data.get('msg')}")
        envelope = data.get("data") or {}
        raw_rows = envelope.get("data") or []
        if not isinstance(raw_rows, list):
            raise SearchError("bad_response: data.data is not a list")
        return SearchPage(
            rows=parse_xiaohongshu(data, limit=limit),
            raw_count=len(raw_rows),
            cursor=cursor,
            next_cursor=str(envelope.get("next_cursor") or ""),
            has_more=bool(envelope.get("has_more")),
            provider="aiddit",
            request_payload=body,
        )
    finally:
        if owns_client:
            client.close()
