RedNoteHow-to

Search Xiaohongshu (RedNote) posts with Python and save them to CSV

A tested Python script that searches RedNote by keyword and writes each note's likes, saves, comments and date to CSV. 58 notes for 咖啡 in 3 calls.

5 min read

Short answer

  • RedNote's open platform lists no search API, so this script calls our RedNote search endpoint and pages through the results with a cursor.
  • Run for 咖啡 (coffee), sorted by popularity: 58 notes from 58 authors in 3 calls, 7 seconds, 45 credits.
  • Save the CSV as UTF-8 with a BOM (utf-8-sig) or Excel may show the Chinese titles as garbled text.
On this page

This script searches Xiaohongshu (RedNote, 小红书) by keyword and writes one CSV row per note: title, type, likes, saves, comments, shares, post date and author ID. We ran it on 2026-10-12 for 咖啡 (coffee), sorted by popularity, and got 58 notes in 7 seconds.

Which API to call

RedNote's own developer platform, open.xiaohongshu.com, is for shop tools such as ERP and order printing, and lists no search API (what it covers). We make ToolzerHub; the script calls our RedNote API instead, which returns public search results as JSON.

What you need

  • Python 3 and requests (pip install requests).
  • An API key from the dashboard. Each search call is 15 credits, so the free tier's 100 credits a month cover 6 calls, about 120 notes.

The script

python
import csv
import os
import sys
import time
from datetime import datetime, timezone

import requests

URL = "https://api.toolzerhub.com/v2/rednote/search/posts"
HEADERS = {"x-api-key": os.environ["TOOLZERHUB_API_KEY"]}
FIELDS = ["note_id", "title", "type", "likes", "saves", "comments", "shares",
          "posted", "author_id"]


def cell(value):
    # Spreadsheets run text that starts with = + - or @ as a formula; an apostrophe keeps it text.
    if isinstance(value, str) and value[:1] in ("=", "+", "-", "@", "\t", "\r"):
        return "'" + value
    return value


def get(params, tries=5):
    # 429 and 503 are retryable; the error body says how long to wait.
    for attempt in range(tries):
        r = requests.get(URL, headers=HEADERS, params=params, timeout=90)
        if r.status_code not in (429, 503) or attempt == tries - 1:
            r.raise_for_status()
            return r.json()["data"]
        wait = r.json().get("error", {}).get("details", {}).get("retryAfter", 5 * (attempt + 1))
        time.sleep(min(wait, 60))


def row(note):
    return {
        "note_id": note["id"],
        "title": note.get("title") or note.get("desc", "")[:80],
        "type": note.get("type"),
        "likes": note.get("liked_count"),
        "saves": note.get("collected_count"),
        "comments": note.get("comments_count"),
        "shares": note.get("shared_count"),
        "posted": datetime.fromtimestamp(note["timestamp"], timezone.utc).date().isoformat(),
        "author_id": (note.get("user") or {}).get("userid"),
    }


def search(query, sort, pages):
    cursor = None
    for page in range(1, pages + 1):
        params = {"query": query, "sort": sort, "page": page}
        if cursor:
            params["cursor"] = cursor
        data = get(params)
        notes = [item["note"] for item in data["items"] if item.get("model_type") == "note"]
        yield from notes
        cursor = data.get("next_cursor")
        if not notes or not cursor:
            break


def main(query, sort="relevance", pages=3, out="notes.csv"):
    seen = set()
    with open(out, "w", newline="", encoding="utf-8-sig") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for note in search(query, sort, int(pages)):
            if note["id"] not in seen:
                seen.add(note["id"])
                writer.writerow({k: cell(v) for k, v in row(note).items()})
    print(f"wrote {len(seen)} notes to {out}")


if __name__ == "__main__":
    main(*sys.argv[1:])

Each call returns 20 results and a next_cursor. The loop sends the cursor back with the next page number until there is no cursor or it has read the pages you asked for. It keeps only items whose model_type is note, and skips a note it has already written: our 3 calls returned 60 notes, 2 of them repeats of earlier ones.

cell() puts an apostrophe in front of any text that starts with =, +, - or @, so a spreadsheet shows it as text instead of running it as a formula.

If a note has no title, row() falls back to the first 80 characters of its body text, desc.

Run it

bash
export TOOLZERHUB_API_KEY=your_key
python rednote_search.py 咖啡 popular 3 notes.csv

Output on 2026-10-12: wrote 58 notes to notes.csv, in 7.1 seconds. The first three rows:

title type likes saves comments posted
🇪🇸当西班牙的餐后咖啡已经必不可少 video 212,491 6,557 1,346 2026-08-05
我爱的小众咖啡馆不再小众了 video 112,702 23,433 804 2026-05-04
存一些i人小幅度咖啡店拍照姿势>< video 66,366 47,725 216 2026-02-12

The third note, titled about coffee-shop photo poses, has 47,725 saves against 66,366 likes, a 72% ratio; the first has 3%. Rank rows by saves ÷ likes to find notes that people save more than the rest.

What the 58 notes looked like

  • 39 image notes (type is normal) and 19 video notes. The median video had 31,223 likes; the median image note had 6,022.
  • 58 different authors: no two notes in this search shared an account.
  • Post dates ran from 2022-03-12 to 2026-09-22; 40 of the 58 were from 2026.

These numbers describe one search on one day, sorted by popularity, so they are not a sample of RedNote as a whole.

Search parameters

  • sort: relevance (default), recent, popular, most_commented, most_saved or english.
  • Add "content_type": "video" (or image, live) to params to filter by format, and "time_range": "week" (or day, half_year) for recent notes only.
  • One account instead of a keyword: export a RedNote profile's notes.
  • English queries work: a separate search for "coffee" returned 20 notes, all with Chinese titles.

Open a note from the CSV

A plain xiaohongshu.com/explore/<note_id> link showed no note content when we opened it logged out, so the CSV keeps the ID instead of a URL. Pass note_id to /v2/rednote/posts/video (for type video) or /v2/rednote/posts/image (for normal) for the full note, or to /v2/rednote/posts/comments for its comments.

What it costs

Every RedNote v2 call is 15 credits. This run was 3 calls, 45 credits. A search page holds 20 notes, so 1,000 notes take 50 calls, 750 credits: $0.74 to $1.43 at our rates of $0.99 to $1.90 per 1,000 credits.

We are not affiliated with RedNote.

Sources

Get this data from your own code

The RedNote API returns it as JSON with one x-api-key header. 100 free credits monthly.