74 lines
2.4 KiB
Python
74 lines
2.4 KiB
Python
"""Parse note data from Xiaohongshu HTML pages (SSR __INITIAL_STATE__).
|
|
|
|
This module provides an alternative to the feed API for reading notes.
|
|
The HTML endpoint embeds note data in a server-rendered `window.__INITIAL_STATE__`
|
|
object, which does not require a valid xsec_token to access.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
|
|
from .exceptions import XhsApiError
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Regex to extract the __INITIAL_STATE__ JSON blob from the HTML.
|
|
_STATE_PATTERN = re.compile(r"window\.__INITIAL_STATE__=({.*?})\s*</script>", re.DOTALL)
|
|
|
|
|
|
def parse_initial_state(html: str) -> dict[str, Any]:
|
|
"""Extract and parse `window.__INITIAL_STATE__` from an XHS note page.
|
|
|
|
The server-rendered HTML contains a global state object with note data.
|
|
XHS uses bare `undefined` values in the JS object which are not valid JSON,
|
|
so we replace them before parsing.
|
|
"""
|
|
match = _STATE_PATTERN.search(html)
|
|
if not match:
|
|
raise XhsApiError("Could not parse __INITIAL_STATE__ from HTML")
|
|
|
|
raw = match.group(1)
|
|
|
|
# Replace bare `undefined` with empty string (not valid JSON)
|
|
cleaned = re.sub(r":\s*undefined", ':""', raw)
|
|
cleaned = re.sub(r",\s*undefined", ',""', cleaned)
|
|
|
|
try:
|
|
return json.loads(cleaned)
|
|
except json.JSONDecodeError as exc:
|
|
raise XhsApiError(f"Failed to parse __INITIAL_STATE__ JSON: {exc}") from None
|
|
|
|
|
|
def extract_note_from_state(
|
|
state: dict[str, Any],
|
|
note_id: str,
|
|
) -> dict[str, Any]:
|
|
"""Extract a single note dict from the parsed __INITIAL_STATE__.
|
|
|
|
The state structure is:
|
|
state.note.noteDetailMap[note_id].note -> full note object
|
|
"""
|
|
detail_map = state.get("note", {}).get("noteDetailMap", {})
|
|
if not detail_map:
|
|
raise XhsApiError("Note not found in HTML state: empty noteDetailMap")
|
|
|
|
# Try exact noteId first, then fall back to first entry
|
|
entry = detail_map.get(note_id)
|
|
if entry is None:
|
|
entry = next(iter(detail_map.values()), None)
|
|
|
|
if entry and isinstance(entry, dict) and "note" in entry:
|
|
return entry["note"]
|
|
|
|
raise XhsApiError("Note not found in HTML state")
|
|
|
|
|
|
def extract_note_from_html(html: str, note_id: str) -> dict[str, Any]:
|
|
"""High-level: parse HTML → extract note in one step."""
|
|
state = parse_initial_state(html)
|
|
return extract_note_from_state(state, note_id)
|