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 

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 {} 

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 

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 

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) 

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 

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", [])] 

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 

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",Source:https://dev.to/qovia/track-what-zhihu-is-saying-about-any-topic-in-python-search-answers-comments-to-csv-3k1h

Related posts