{"slug": "monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python", "title": "Monitor what Xiaohongshu (RedNote) users say about a brand in Python", "summary": "A developer built Qovia, an API for pulling Xiaohongshu (RedNote) brand mentions into a Python pipeline that searches notes, collects comments, appends results to CSV and summarizes them with pandas. The tutorial covers authenticated requests with a bearer key, per-call billing metadata, 429 retry handling, and search parameters such as sort order, note type and time window for recurring brand monitoring.", "body_md": "I build Qovia, the API used in this post. The pipeline itself (search, enrich, collect comments, append to CSV, summarize) works with any Xiaohongshu data source; the CSV and pandas parts are plain Python.\n\nXiaohongshu (小红书, called RedNote outside China) is where Chinese consumers post reviews, shopping hauls, \"is it worth it?\" comparisons and complaints. For a consumer brand that sells in China, or one thinking about it, it's one of the most direct places to hear what customers actually say, in their own words, with photos. The comment sections are often more honest than the notes themselves: that's where people ask \"where did you buy it?\", \"mine broke after a week\" or \"is this an ad?\".\n\nIn this tutorial we'll build a small brand monitor in Python. For one brand keyword it will:\n\nYou need Python 3.9+, `requests` and `pandas`:\n\n```\npip install requests pandas\nexport QOVIA_KEY=sk-qovia-...   # key from the Qovia console\n```\n\nGet a key from [Qovia](https://qovia.ai/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python). Requests authenticate with `Authorization: Bearer <key>` (an `x-api-key` header also works). The [Xiaohongshu API page](https://qovia.ai/xiaohongshu-api/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python) lists all the Xiaohongshu endpoints with a real request and response.\n\nEvery response has the same envelope: the result is in `data`, and `meta` reports the charge (`meta.billed`, `meta.charged_usd`, `meta.empty`). Empty results come back as HTTP 200 with `meta.empty: true` and are not charged. Errors are `{\"error\": {\"code\", \"message\", ...}}` and are never charged. A `429` carries a `Retry-After` header, so the helper waits and retries once:\n\n``` python\nimport os\nimport time\nimport requests\n\nBASE = \"https://api.qovia.ai\"\nHEADERS = {\"Authorization\": f\"Bearer {os.environ['QOVIA_KEY']}\"}\n\nspent = 0.0\n\ndef get(path, **params):\n    global spent\n    params = {k: v for k, v in params.items() if v is not None}\n    for attempt in range(2):\n        r = requests.get(f\"{BASE}{path}\", headers=HEADERS, params=params, timeout=60)\n        if r.status_code == 429 and attempt == 0:\n            time.sleep(int(r.headers.get(\"Retry-After\", \"10\")))\n            continue\n        r.raise_for_status()\n        body = r.json()\n        spent += float((body.get(\"meta\") or {}).get(\"charged_usd\") or 0)\n        return body.get(\"data\") or {}\n```\n\nUnknown parameters are rejected with `400 invalid_params`, which is why the helper drops `None` values instead of sending them.\n\n`GET /v1/raw/xiaohongshu/search` takes a `keyword` (1 to 50 characters) and returns 20 notes per page. The useful options for monitoring:\n\n`sort`: `general` (relevance, the default), `latest`, `most_liked`, `most_commented` or `most_saved`,` note_type`: `all` (default), `video` or `image`,` note_time`: `day`, `week`, `month` or `year`,` page`: 1 to 50; stop when `has_more` is false.\nFor a recurring monitor, `sort=latest` with `note_time=week` is a good default: you see what's new rather than the same viral notes every day.\n\nEach note has `text` (an excerpt), `author` (`nickname`, `user_id`), `metrics` (`likes`, `comments`, `saves`, `shares`) and an `extra` object with `source_id` (the note ID), `url`, `title`, `note_type`, `published_at`, `cover_url`, `images`, and for video notes `video_url` and `video_dur_s`.\n\n```\nBRAND = \"瑞幸\"   # Luckin Coffee; use any brand or product name\n\ndef search_notes(keyword, sort=\"latest\", note_time=\"week\", max_pages=2):\n    notes, seen = [], set()\n    for page_no in range(1, max_pages + 1):\n        page = get(\"/v1/raw/xiaohongshu/search\", keyword=keyword, sort=sort,\n                   note_time=note_time, page=page_no)\n        for n in page.get(\"items\", []):\n            nid = n[\"extra\"][\"source_id\"]\n            if nid not in seen:\n                seen.add(nid)\n                notes.append(n)\n        if not page.get(\"has_more\"):\n            break\n    return notes\n```\n\nEach page is one billed call, so `max_pages` is also your cost cap. Deduplicating on the note ID is cheap insurance in case the feed shifts between pages.\n\nNow flatten the notes into CSV rows. We'll use one writer for all three files:\n\n``` python\nimport csv\nfrom datetime import datetime, timezone\nfrom pathlib import Path\n\nNOTE_FIELDS = [\"checked_at\", \"keyword\", \"note_id\", \"title\", \"note_type\", \"author\",\n               \"author_id\", \"likes\", \"comments\", \"saves\", \"shares\", \"published_at\",\n               \"url\", \"excerpt\"]\n\ndef now():\n    return datetime.now(timezone.utc).isoformat(timespec=\"seconds\")\n\ndef note_row(keyword, n):\n    ex, m, a = n.get(\"extra\") or {}, n.get(\"metrics\") or {}, n.get(\"author\") or {}\n    return {\n        \"checked_at\": now(),\n        \"keyword\": keyword,\n        \"note_id\": ex.get(\"source_id\"),\n        \"title\": ex.get(\"title\"),\n        \"note_type\": ex.get(\"note_type\"),\n        \"author\": a.get(\"nickname\"),\n        \"author_id\": a.get(\"user_id\"),\n        \"likes\": m.get(\"likes\"),\n        \"comments\": m.get(\"comments\"),\n        \"saves\": m.get(\"saves\"),\n        \"shares\": m.get(\"shares\"),\n        \"published_at\": ex.get(\"published_at\"),\n        \"url\": ex.get(\"url\"),\n        \"excerpt\": (n.get(\"text\") or \"\")[:200].replace(\"\\n\", \" \"),\n    }\n\ndef append_csv(path, fields, rows):\n    path = Path(path)\n    new_file = not path.exists()\n    with path.open(\"a\", newline=\"\", encoding=\"utf-8\") as f:\n        w = csv.DictWriter(f, fieldnames=fields)\n        if new_file:\n            w.writeheader()\n        w.writerows(rows)\n```\n\nSearch results are enough for counting mentions, but for the notes people actually engage with you want the detail record. `GET /v1/raw/xiaohongshu/note` takes a `note_id` (the 24 hex characters in `xiaohongshu.com/explore/<ID>`, which is `extra.source_id` from search) and returns the same record shape with exact like, comment, save and share counts, plus `extra.topics` (the note's hashtags) and `extra.ip_location`. A deleted or missing note answers empty and is not charged.\n\nWe rank the search hits by engagement and fetch details for the top few:\n\n``` python\nDETAIL_FIELDS = NOTE_FIELDS + [\"ip_location\", \"topics\"]\n\ndef engagement(n):\n    m = n.get(\"metrics\") or {}\n    return sum(m.get(k) or 0 for k in (\"likes\", \"comments\", \"saves\", \"shares\"))\n\ndef note_details(keyword, notes, top=10):\n    rows = []\n    for n in sorted(notes, key=engagement, reverse=True)[:top]:\n        d = get(\"/v1/raw/xiaohongshu/note\", note_id=n[\"extra\"][\"source_id\"])\n        if not d:\n            continue  # deleted since the search, not charged\n        row = note_row(keyword, d)\n        row[\"ip_location\"] = d[\"extra\"].get(\"ip_location\")\n        row[\"topics\"] = \"|\".join(d[\"extra\"].get(\"topics\") or [])\n        rows.append(row)\n    return rows\n```\n\n`topics` is a list, so it's joined with `|` to fit in one CSV cell; pandas can split it again later.\n\n`GET /v1/raw/xiaohongshu/comments` takes a `note_id` and returns first-level comments with a cursor. There are two orders, and they return different fields:\n\n`sort=default`: Xiaohongshu's own order (pinned and popular first), about 5 per page, with like counts and the commenter's user ID.`sort=latest`: newest first, 10 to 15 per page, without like counts and with only the commenter's Xiaohongshu ID (`red_id`).\nFor brand monitoring the default order is usually the better start, since the most liked comments tend to be the ones everybody else agrees with. Each comment has `cid`, `text`, `likes`, `reply_count`, `ip_location`, `created_at`, `images` and `author`; the page has `cursor`, `has_more` and `total`. Pass `cursor` back unchanged for the next page:\n\n```\nCOMMENT_FIELDS = [\"checked_at\", \"keyword\", \"note_id\", \"note_title\", \"cid\", \"text\",\n                  \"likes\", \"reply_count\", \"ip_location\", \"created_at\"]\n\ndef comment_rows(keyword, detail_rows, pages_per_note=2):\n    rows = []\n    for note in detail_rows:\n        cursor = None\n        for _ in range(pages_per_note):\n            page = get(\"/v1/raw/xiaohongshu/comments\", note_id=note[\"note_id\"],\n                       sort=\"default\", cursor=cursor)\n            for c in page.get(\"items\", []):\n                rows.append({\n                    \"checked_at\": now(),\n                    \"keyword\": keyword,\n                    \"note_id\": note[\"note_id\"],\n                    \"note_title\": note[\"title\"],\n                    \"cid\": c[\"cid\"],\n                    \"text\": (c.get(\"text\") or \"\").replace(\"\\n\", \" \"),\n                    \"likes\": c.get(\"likes\"),\n                    \"reply_count\": c.get(\"reply_count\"),\n                    \"ip_location\": c.get(\"ip_location\"),\n                    \"created_at\": c.get(\"created_at\"),\n                })\n            if not page.get(\"has_more\"):\n                break\n            cursor = page.get(\"cursor\")\n    return rows\n```\n\nIf a comment has a large `reply_count`, `GET /v1/raw/xiaohongshu/comment_replies` with the `note_id` and `comment_id=<cid>` returns the thread under it (oldest first, about 5 per page, same cursor rules). Each reply has `parent_cid` and `reply_to_cid`, so you can rebuild the conversation. I left it out of the main loop to keep the call count predictable.\n\n``` python\ndef run(keyword=BRAND):\n    notes = search_notes(keyword, sort=\"latest\", note_time=\"week\", max_pages=2)\n    append_csv(\"xhs_notes.csv\", NOTE_FIELDS, [note_row(keyword, n) for n in notes])\n    details = note_details(keyword, notes, top=10)\n    append_csv(\"xhs_note_details.csv\", DETAIL_FIELDS, details)\n    comments = comment_rows(keyword, details, pages_per_note=2)\n    append_csv(\"xhs_comments.csv\", COMMENT_FIELDS, comments)\n    print(f\"{len(notes)} notes, {len(details)} details, {len(comments)} comments\")\n    print(f\"spent this run: ${spent:.4f}\")\n\nif __name__ == \"__main__\":\n    run()\n```\n\nRun it once a day from cron and the CSVs become a running log of the brand on Xiaohongshu:\n\n```\n0 9 * * * cd /path/to/monitor && QOVIA_KEY=sk-qovia-... python3 xhs_monitor.py >> monitor.log 2>&1\n```\n\nOutput looks something like this (illustrative, not real data):\n\n```\n40 notes, 10 details, 96 comments\nspent this run: $0.1600\npython\nimport pandas as pd\n\nnotes = pd.read_csv(\"xhs_notes.csv\", dtype={\"note_id\": str})\ndetails = pd.read_csv(\"xhs_note_details.csv\", dtype={\"note_id\": str})\ncomments = pd.read_csv(\"xhs_comments.csv\", dtype={\"note_id\": str, \"cid\": str})\n\nnotes = notes.drop_duplicates(\"note_id\", keep=\"last\")\nnotes[\"day\"] = pd.to_datetime(notes[\"published_at\"], utc=True).dt.tz_convert(\"Asia/Shanghai\").dt.date\nprint(\"New notes per day:\")\nprint(notes.groupby(\"day\").size().tail(14))\n\nlatest = details.sort_values(\"checked_at\").drop_duplicates(\"note_id\", keep=\"last\")\nprint(\"\\nMost engaged notes:\")\nprint(latest.sort_values(\"likes\", ascending=False)[[\"likes\", \"comments\", \"saves\", \"title\", \"url\"]].head(10))\n\nprint(\"\\nTop hashtags on those notes:\")\nprint(latest[\"topics\"].dropna().str.split(\"|\").explode().value_counts().head(10))\n\nc = comments.drop_duplicates(\"cid\", keep=\"last\")\nprint(\"\\nWhere commenters are (IP location):\")\nprint(c[\"ip_location\"].value_counts().head(10))\n\nprint(\"\\nMost liked comments:\")\nprint(c.sort_values(\"likes\", ascending=False)[[\"likes\", \"text\"]].head(10))\n\n# A crude signal, not sentiment analysis: count comments containing a few words.\nWATCH = {\"好喝\": \"tastes good\", \"难喝\": \"tastes bad\", \"贵\": \"expensive\",\n         \"排队\": \"queue\", \"退款\": \"refund\", \"广告\": \"ad?\"}\nprint(\"\\nComments mentioning:\")\nfor word, label in WATCH.items():\n    print(f\"  {word} ({label}): {c['text'].fillna('').str.contains(word, regex=False).sum()}\")\n```\n\nThe word list is the part you'll want to tune for your brand: product names, competitor names, and the complaint words that matter in your category. Because every run appends with a `checked_at` timestamp, you can also compare a note's `likes` and `comments` across days to see which posts are still growing.\n\nIf you'd rather ask questions than maintain a script, the same endpoints are available through Qovia's MCP server at `https://api.qovia.ai/mcp` (Streamable HTTP, same API key in an `Authorization: Bearer` header, same billing as the HTTP API). In Claude Code:\n\n```\nclaude mcp add --transport http qovia https://api.qovia.ai/mcp \\\n  --header \"Authorization: Bearer $QOVIA_KEY\"\n```\n\nThen ask something like \"search Xiaohongshu for 瑞幸 notes from this week, open the five most liked, and summarize what the comments complain about.\" Configs for Cursor, VS Code and Claude Desktop are in the [MCP docs](https://qovia.ai/docs/mcp/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python), and the [Xiaohongshu MCP page](https://qovia.ai/mcp/xiaohongshu/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python) lists the Xiaohongshu tools. The assistant is good for one-off digging; the script is better when you want the same numbers every day.\n\nEvery Xiaohongshu endpoint used here (`search`, `note`, `comments`, `comment_replies`) is a fixed $0.005 per successful call ($5 per 1,000). Empty results and errors are not charged.\n\nAs a rough example (arithmetic only): one run above is up to 2 search pages, 10 note details and up to 20 comment pages, so at most 32 calls, about $0.16. Daily for one brand, that's under $5 a month. The `spent` counter prints the real number each run. New accounts get 50 trial calls, counted only when a call returns data; after that it's a prepaid balance with no monthly fee. Details are on the [pricing page](https://qovia.ai/pricing/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python), and every parameter is in the [API docs](https://qovia.ai/docs/?utm_source=devto&utm_medium=article&utm_campaign=xiaohongshu-brand-monitor-python).\n\nThe default rate limit is 60 requests per minute per account, more than enough for a job like this.\n\n`text` in search results is an excerpt.`pages_per_note`.\nIf you build on this, tell me in the comments which brand signals you'd want added.\n\n*This article was written with the help of AI.*", "url": "https://wpnews.pro/news/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python", "canonical_source": "https://dev.to/qovia/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python-348", "published_at": "2026-10-30 23:15:35+00:00", "updated_at": "2026-10-30 23:19:31.579233+00:00", "lang": "en", "topics": ["ai-tools", "developer-tools"], "entities": ["Qovia", "Xiaohongshu", "RedNote", "Luckin Coffee", "Python", "pandas"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python", "markdown": "https://wpnews.pro/news/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python.md", "text": "https://wpnews.pro/news/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python.txt", "jsonld": "https://wpnews.pro/news/monitor-what-xiaohongshu-rednote-users-say-about-a-brand-in-python.jsonld"}}