"""Python port of scraper.rs (Rednote / Xiaohongshu scraper).""" import json import re import httpx UA_DESKTOP = ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36" ) UA_MOBILE = ( "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 " "(KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1" ) URL_RE = re.compile(r"(?i)^https?://(.*?)?(xhslink\.com|xiaohongshu\.com)/[^\s]+$") # ---------------------------------------------------------------- helpers def is_valid_rednote_url(url: str) -> bool: return bool(URL_RE.match(url or "")) def capture_single(html: str, pattern: str) -> str: m = re.search(pattern, html) return m.group(1).strip() if m else "" def html_unescape(t: str) -> str: return ( t.replace("&", "&") .replace(""", '"') .replace("'", "'") .replace("<", "<") .replace(">", ">") ) def meta_all(html: str, key: str) -> list: """Lấy content của , chấp nhận cả 2 thứ tự thuộc tính.""" k = re.escape(key) p1 = rf"""(?is)]*?(?:name|property)\s*=\s*["']{k}["'][^>]*?content\s*=\s*["'](.*?)["']""" p2 = rf"""(?is)]*?content\s*=\s*["'](.*?)["'][^>]*?(?:name|property)\s*=\s*["']{k}["']""" out = [] for p in (p1, p2): for m in re.finditer(p, html): v = html_unescape(m.group(1).strip()) if v and v not in out: out.append(v) return out def meta_first(html: str, key: str) -> str: r = meta_all(html, key) return r[0] if r else "" def g(obj, *path): """Safe nested getter for dict/list.""" cur = obj for p in path: if isinstance(cur, dict): cur = cur.get(p) elif isinstance(cur, list) and isinstance(p, int) and 0 <= p < len(cur): cur = cur[p] else: return None return cur def s(v) -> str: if isinstance(v, str): return v.strip() if isinstance(v, (int, float)) and not isinstance(v, bool): return str(v) return "" def fix_url(u: str) -> str: u = u.strip().replace("\\u002F", "/").replace("&", "&") if u.startswith("http://"): return "https://" + u[len("http://"):] if u.startswith("//"): return "https:" + u return u def extract_initial_state(html: str): """Tách và parse window.__INITIAL_STATE__={...} (JS object, có thể chứa `undefined`).""" marker = "window.__INITIAL_STATE__" idx = html.find(marker) if idx < 0: return None rest = html[idx + len(marker):].lstrip() if not rest.startswith("="): return None rest = rest[1:].lstrip() end = rest.find("") body = rest if end < 0 else rest[:end] body = re.sub(r"([:\[,])\s*undefined\b", r"\1null", body) try: val, _ = json.JSONDecoder().raw_decode(body.strip()) return val except ValueError: return None def find_note(state): # Web: note.noteDetailMap[id].note m = g(state, "note", "noteDetailMap") if isinstance(m, dict): first_id = s(g(state, "note", "firstNoteId")) pick = g(m, first_id, "note") if not (isinstance(pick, dict) and pick): pick = None for e in m.values(): n = g(e, "note") if isinstance(n, dict) and n: pick = n break if pick: return pick # Mobile: noteData.data.noteData n = g(state, "noteData", "data", "noteData") if isinstance(n, dict): return n return None def collect_downloads(note) -> list: out = [] def push(q, u): u = fix_url(u) if u and not any(d["url"] == u for d in out): out.append({"quality": q, "url": u}) origin = s(g(note, "video", "consumer", "originVideoKey")) if origin: push("Original", f"https://sns-video-bd.xhscdn.com/{origin}") stream = g(note, "video", "media", "stream") if isinstance(stream, dict): for codec, lst in stream.items(): if not isinstance(lst, list): continue for item in lst: h = s(g(item, "height")) label = f"{h}p {codec.upper()}" if h else codec.upper() push(label, s(g(item, "masterUrl"))) backups = g(item, "backupUrls") if isinstance(backups, list): for i, u in enumerate(backups): push(f"{label} (backup {i + 1})", s(u)) return out def collect_images(note) -> list: out = [] arr = g(note, "imageList") if isinstance(arr, list): for img in arr: u = s(g(img, "urlDefault")) if not u: u = s(g(img, "infoList", 0, "url")) if not u: u = s(g(img, "url")) u = fix_url(u) if u and u not in out: out.append(u) return out # ---------------------------------------------------------------- parsing def parse_page(html: str, final_url: str) -> dict: state = extract_initial_state(html) note = find_note(state) if state is not None else None if state is None: print(f"[scraper] __INITIAL_STATE__ not found (url: {final_url})") elif note is None: print(f"[scraper] note object not found in state (url: {final_url})") title_tag = html_unescape(capture_single(html, r"(?is)