DEV Community

Qovia
Qovia

Posted on

Track what Zhihu is saying about any topic in Python (search, answers, comments to CSV)

I build Qovia, the API used in this post. The structure (search a keyword, group the hits by question, pull the question stats and the top comments, store everything locally) works with any Zhihu data source; the CSV and pandas parts are plain Python.

Zhihu (知乎) is China's big Q&A site, closer to Quora plus a blogging platform than to a social feed. When a new phone, an AI model or a policy change lands, Zhihu is where people write long answers about whether it's any good, and where the comment threads under those answers show what ordinary readers think. If you do market research, product feedback or China-focused analysis, it's one of the most useful places to listen.

In this tutorial we'll write a small Python script that, for one keyword:

  1. checks whether the topic is on Zhihu's hot list right now,
  2. searches recent answers and articles about it and saves them to a CSV,
  3. groups the answers by question and pulls each question's answer, follower and view counts,
  4. collects the hottest comments under the most upvoted answers, with likes and IP location,
  5. summarizes the result with pandas: which questions drive the discussion, who is answering, and where commenters are.

Setup

You need Python 3.9+ and requests (and pandas for the last step):

pip install requests pandas
export QOVIA_KEY=sk-qovia-...   # key from the Qovia console
Enter fullscreen mode Exit fullscreen mode

Get a key from Qovia. Requests authenticate with Authorization: Bearer <key> (an x-api-key header also works). An overview of all Zhihu endpoints is on the Zhihu API page.

Step 1: one helper

Every response uses the same envelope: the result is in data, billing info is in meta (meta.charged_usd). Calls that come back empty are not charged. A 429 response carries a Retry-After header, so the helper waits and retries once:

import os
import time
import requests

BASE = "https://api.qovia.ai"
HEADERS = {"Authorization": f"Bearer {os.environ['QOVIA_KEY']}"}

spent = 0.0


def get(path, **params):
    global spent
    for attempt in range(2):
        r = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=60)
        if r.status_code == 429 and attempt == 0:
            time.sleep(int(r.headers.get("Retry-After", "10")))
            continue
        r.raise_for_status()
        body = r.json()
        spent += float(body.get("meta", {}).get("charged_usd") or 0)
        return body.get("data") or {}
Enter fullscreen mode Exit fullscreen mode

All the Zhihu endpoints return records in the same shape: text, author, metrics, and an extra object with the Zhihu-specific fields (source_id, content_type, title, url, published_at and so on). Once you've seen one, you've seen them all.

Step 2: is it on the hot list?

GET /v1/raw/zhihu/hot_list takes no parameters and returns about 30 questions in rank order. Each item's extra has rank, heat (the 「1848 万热度」 label already converted to a number, 18480000), heat_label, title and url; extra.source_id is the question id. metrics has answers and followers (views and comments are null on this list).

A quick check for our keyword:

KEYWORD = "DeepSeek"


def hot_matches(keyword):
    hot = get("/v1/raw/zhihu/hot_list")
    hits = []
    for q in hot.get("items", []):
        ex = q["extra"]
        if keyword.lower() in (q.get("text") or "").lower():
            hits.append(ex)
            print(f"#{ex['rank']} {ex['heat_label']}  {ex['title']}  {ex['url']}")
    return hits
Enter fullscreen mode Exit fullscreen mode

I match on text because it holds the question title followed by the start of its description, so it catches a few more hits than the title alone. Most keywords won't be on the hot list most of the time; that's fine, it's a cheap "is this blowing up right now?" signal before the real work.

Step 3: search answers and articles

GET /v1/raw/zhihu/article_search is the core of the tracker. It takes a keyword and optionally:

  • type: all (default), answer or article,
  • sort: general (relevance, default), most_liked or latest,
  • publish_time: all (default), day, week, month, quarter, half_year or year. Zhihu applies this filter loosely, so expect a few older results.

Each result has text, author (nickname, followers, url_token), metrics (likes, comments, saves) and extra (source_id, content_type, title, question_id, published_at, url). For an answer, extra.question_id is the question it belongs to; for an article it's null.

To get more than one page, pass the previous page's cursor and search_id back and stop when has_more is false. Adjacent pages can repeat a few records, so we deduplicate by id. Each page is one billed call, so cap it:

def search(keyword, publish_time="week", sort="general", max_pages=3):
    results, seen = [], set()
    params = {"keyword": keyword, "publish_time": publish_time, "sort": sort}
    for _ in range(max_pages):
        page = get("/v1/raw/zhihu/article_search", **params)
        for item in page.get("items", []):
            if item["id"] not in seen:
                seen.add(item["id"])
                results.append(item)
        if not page.get("has_more"):
            break
        params["cursor"] = page.get("cursor")
        params["search_id"] = page.get("search_id")
    return results
Enter fullscreen mode Exit fullscreen mode

Now flatten the results into rows and write them to a CSV:

import csv
from datetime import datetime, timezone
from pathlib import Path

POST_FIELDS = ["checked_at", "keyword", "id", "content_type", "answer_id", "question_id",
               "title", "author", "author_url_token", "author_followers",
               "likes", "comments", "saves", "published_at", "url", "excerpt"]


def post_row(keyword, item):
    ex, m, a = item["extra"], item.get("metrics") or {}, item.get("author") or {}
    return {
        "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "keyword": keyword,
        "id": item["id"],
        "content_type": ex.get("content_type"),
        "answer_id": ex.get("source_id"),
        "question_id": ex.get("question_id"),
        "title": ex.get("title"),
        "author": a.get("nickname"),
        "author_url_token": a.get("url_token"),
        "author_followers": a.get("followers"),
        "likes": m.get("likes"),
        "comments": m.get("comments"),
        "saves": m.get("saves"),
        "published_at": ex.get("published_at"),
        "url": ex.get("url"),
        "excerpt": (item.get("text") or "")[:200].replace("\n", " "),
    }


def append_csv(path, fields, rows):
    path = Path(path)
    new_file = not path.exists()
    with path.open("a", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields)
        if new_file:
            w.writeheader()
        w.writerows(rows)
Enter fullscreen mode Exit fullscreen mode

The answer_id column is extra.source_id, the number in the answer's URL. For articles it's the article id instead; only answers have a question_id, and only answers can be passed to the comments endpoint later.

Step 4: which questions carry the discussion?

On Zhihu the unit of conversation is the question: one question can collect hundreds of answers. Grouping the search hits by question_id shows which questions your topic actually lives in. For those, GET /v1/raw/zhihu/question takes a question_id and returns the question's metrics: answers, followers, views and comments, plus extra.title, extra.published_at and extra.url. A question that doesn't exist answers 404 (not charged), which our helper raises as an HTTPError, so we skip those:

from collections import Counter

Q_FIELDS = ["checked_at", "keyword", "question_id", "title", "answers", "followers",
            "views", "comments", "hits_in_search", "published_at", "url"]


def question_rows(keyword, posts, top=10):
    counts = Counter(p["extra"]["question_id"] for p in posts if p["extra"].get("question_id"))
    rows = []
    for qid, hits in counts.most_common(top):
        try:
            q = get("/v1/raw/zhihu/question", question_id=qid)
        except requests.HTTPError:
            continue
        if not q:
            continue
        m, ex = q.get("metrics") or {}, q.get("extra") or {}
        rows.append({
            "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
            "keyword": keyword,
            "question_id": qid,
            "title": ex.get("title"),
            "answers": m.get("answers"),
            "followers": m.get("followers"),
            "views": m.get("views"),
            "comments": m.get("comments"),
            "hits_in_search": hits,
            "published_at": ex.get("published_at"),
            "url": ex.get("url"),
        })
    return rows
Enter fullscreen mode Exit fullscreen mode

If you want more answers from one question than search gave you, GET /v1/raw/zhihu/answers takes a question_id, a sort (default, Zhihu's ranking, or latest) and a count up to 20. It only returns that first page (Zhihu's next page isn't reachable through the current source), but the records have the same shape as search results, so post_row() works on them unchanged:

def latest_answers(keyword, question_id, count=20):
    page = get("/v1/raw/zhihu/answers", question_id=question_id, sort="latest", count=count)
    return [post_row(keyword, a) for a in page.get("items", [])]
Enter fullscreen mode Exit fullscreen mode

Step 5: what are readers saying?

Answers are written by people with an opinion to sell; comments are where readers push back. GET /v1/raw/zhihu/comments takes an answer_id and returns first-level comments, 10 per page, hottest first by default (sort=latest for newest first). Each comment has cid, text, likes, reply_count, ip_location, created_at, is_hot and author. The response also has total, cursor and has_more.

We take the first page of hot comments under the most upvoted answers:

C_FIELDS = ["checked_at", "keyword", "answer_id", "question_title", "cid", "text",
            "likes", "reply_count", "ip_location", "is_hot", "created_at"]


def comment_rows(keyword, posts, top_answers=10):
    answers = [p for p in posts if p["extra"].get("content_type") == "answer"]
    answers.sort(key=lambda p: (p.get("metrics") or {}).get("likes") or 0, reverse=True)
    rows = []
    for p in answers[:top_answers]:
        aid = p["extra"]["source_id"]
        page = get("/v1/raw/zhihu/comments", answer_id=aid, sort="hot")
        for c in page.get("items", []):
            rows.append({
                "checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
                "keyword": keyword,
                "answer_id": aid,
                "question_title": p["extra"].get("title"),
                "cid": c["cid"],
                "text": (c.get("text") or "").replace("\n", " "),
                "likes": c.get("likes"),
                "reply_count": c.get("reply_count"),
                "ip_location": c.get("ip_location"),
                "is_hot": c.get("is_hot"),
                "created_at": c.get("created_at"),
            })
    return rows
Enter fullscreen mode Exit fullscreen mode

To go deeper on one answer, follow cursor until has_more is false, exactly like the search loop. And when a comment has a large reply_count, GET /v1/raw/zhihu/comment_replies with comment_id=<cid> returns the thread under it (up to 20 per page, sort is hot or oldest). Each reply has a parent_cid and a reply_to_cid, so you can rebuild who answered whom.

Step 6: put it together

def run(keyword=KEYWORD):
    hot_matches(keyword)
    posts = search(keyword, publish_time="week", max_pages=3)
    append_csv("zhihu_posts.csv", POST_FIELDS, [post_row(keyword, p) for p in posts])
    questions = question_rows(keyword, posts, top=10)
    append_csv("zhihu_questions.csv", Q_FIELDS, questions)
    comments = comment_rows(keyword, posts, top_answers=10)
    append_csv("zhihu_comments.csv", C_FIELDS, comments)
    print(f"{len(posts)} posts, {len(questions)} questions, {len(comments)} comments")
    print(f"spent this run: ${spent:.4f}")


if __name__ == "__main__":
    run()
Enter fullscreen mode Exit fullscreen mode

Run it once a day from cron and the CSVs become a daily log of the topic:

0 9 * * * cd /path/to/tracker && QOVIA_KEY=sk-qovia-... python3 tracker.py >> tracker.log 2>&1
Enter fullscreen mode Exit fullscreen mode

The output looks something like this (illustrative, not real data):

#7 412 万热度  <question title>  https://www.zhihu.com/question/...
58 posts, 10 questions, 96 comments
spent this run: $0.0240
Enter fullscreen mode Exit fullscreen mode

Step 7: summarize it

With the three CSVs in place, a few lines of pandas give you the shape of the discussion:

import pandas as pd

posts = pd.read_csv("zhihu_posts.csv", dtype={"question_id": str, "answer_id": str})
questions = pd.read_csv("zhihu_questions.csv", dtype={"question_id": str})
comments = pd.read_csv("zhihu_comments.csv", dtype={"answer_id": str})

latest_q = questions.sort_values("checked_at").drop_duplicates("question_id", keep="last")
print("Questions driving the topic:")
print(latest_q.sort_values("views", ascending=False)[["title", "answers", "followers", "views"]].head(10))

print("\nMost upvoted answers and articles:")
top = posts.drop_duplicates("id").sort_values("likes", ascending=False)
print(top[["content_type", "author", "likes", "comments", "saves", "title"]].head(10))

print("\nWhere commenters are (IP location):")
print(comments.drop_duplicates("cid")["ip_location"].value_counts().head(10))

print("\nMost liked comments:")
print(comments.drop_duplicates("cid").sort_values("likes", ascending=False)[["likes", "text"]].head(10))
Enter fullscreen mode Exit fullscreen mode

Because every run appends with a checked_at timestamp, you can also compare a question's views and answers from one day to the next to see whether the topic is heating up or fading.

If you want to know who the loudest voices are, GET /v1/raw/zhihu/user takes an author's url_token (already in zhihu_posts.csv) and returns followers, answers, articles, verified, verify_reason, is_org and employers. That's a quick way to separate verified professionals and organisation accounts from everyone else.

Cost

From the catalog: every Zhihu endpoint used here (hot_list, article_search, question, answers, comments, comment_replies, user) is $1 per 1,000 calls ($0.001 per call). Empty results and 404s are not charged.

As a rough example (arithmetic only): one run of the script above is 1 hot list call, up to 3 search pages, up to 10 question calls and up to 10 comment pages, so at most 24 calls, about $0.024. Running it daily for one keyword is under $1 a month. Print the spent counter on every run so you see the real number. Details are on the pricing page, and every endpoint's parameters are in the API reference.

The default rate limit is 60 requests per minute per account, which is plenty for this kind of job.

Using it from an AI assistant

The same endpoints are available through Qovia's MCP server, so you can ask Claude, Cursor or another MCP client things like "what is Zhihu saying about DeepSeek this week, and what do the top comments complain about?" without writing the loop yourself. Setup is in the MCP docs, and there's a Zhihu-specific MCP page that lists the Zhihu endpoints.

Limits

  • Public content only: answers, articles, questions, comments and public profiles. No private messages.
  • The Zhihu endpoints are marked beta in the catalog.
  • answers returns only the first page (up to 20) for a question; use search with sort=latest to keep finding new answers over time.
  • The publish_time filter on search is applied loosely by Zhihu, so filter on published_at yourself if you need an exact window.
  • GET /v1/raw/zhihu/answer (single answer detail) doesn't carry upvote, comment or save counts; take those from search or answers.
  • Comments are only available for answers (by answer_id), not for articles.
  • Numbers are a snapshot at the time of the call; compare snapshots across runs rather than treating one as final.

If you build on this, tell me in the comments what you'd want added.


This article was written with the help of AI.

Top comments (0)