zhihu-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zhihu_cli/__init__.py +19 -0
- zhihu_cli/commands/__init__.py +25 -0
- zhihu_cli/commands/_helpers.py +212 -0
- zhihu_cli/commands/agora.py +636 -0
- zhihu_cli/commands/ai.py +334 -0
- zhihu_cli/commands/auth.py +291 -0
- zhihu_cli/commands/browse.py +1003 -0
- zhihu_cli/commands/chat.py +315 -0
- zhihu_cli/commands/config.py +283 -0
- zhihu_cli/commands/consult.py +394 -0
- zhihu_cli/commands/convert.py +143 -0
- zhihu_cli/commands/daemon.py +101 -0
- zhihu_cli/commands/download.py +424 -0
- zhihu_cli/commands/draft.py +313 -0
- zhihu_cli/commands/interact.py +517 -0
- zhihu_cli/commands/interact_comment_interactive.py +617 -0
- zhihu_cli/commands/listen.py +63 -0
- zhihu_cli/commands/people.py +369 -0
- zhihu_cli/commands/profile.py +126 -0
- zhihu_cli/commands/publish.py +108 -0
- zhihu_cli/commands/scrape.py +145 -0
- zhihu_cli/commands/search.py +118 -0
- zhihu_cli/commands/stats.py +81 -0
- zhihu_cli/commands/tools.py +660 -0
- zhihu_cli/content/__init__.py +22 -0
- zhihu_cli/content/download_contents.py +744 -0
- zhihu_cli/content/handlers/__init__.py +104 -0
- zhihu_cli/content/handlers/_session_core.py +271 -0
- zhihu_cli/content/handlers/agora.py +337 -0
- zhihu_cli/content/handlers/ai_direct.py +426 -0
- zhihu_cli/content/handlers/article.py +79 -0
- zhihu_cli/content/handlers/auth_login.py +312 -0
- zhihu_cli/content/handlers/cache_manager.py +332 -0
- zhihu_cli/content/handlers/captcha.py +216 -0
- zhihu_cli/content/handlers/chat.py +917 -0
- zhihu_cli/content/handlers/chat_commands.py +91 -0
- zhihu_cli/content/handlers/chat_commands_builtin.py +271 -0
- zhihu_cli/content/handlers/collection.py +141 -0
- zhihu_cli/content/handlers/comments.py +325 -0
- zhihu_cli/content/handlers/consult.py +316 -0
- zhihu_cli/content/handlers/draft.py +252 -0
- zhihu_cli/content/handlers/feed.py +198 -0
- zhihu_cli/content/handlers/following.py +225 -0
- zhihu_cli/content/handlers/hot.py +52 -0
- zhihu_cli/content/handlers/imchat.py +175 -0
- zhihu_cli/content/handlers/notifications.py +54 -0
- zhihu_cli/content/handlers/people.py +398 -0
- zhihu_cli/content/handlers/pin.py +59 -0
- zhihu_cli/content/handlers/publishing.py +266 -0
- zhihu_cli/content/handlers/question.py +231 -0
- zhihu_cli/content/handlers/question_log.py +200 -0
- zhihu_cli/content/handlers/read_history.py +53 -0
- zhihu_cli/content/handlers/report.py +94 -0
- zhihu_cli/content/handlers/request_tool.py +142 -0
- zhihu_cli/content/handlers/requests.py +215 -0
- zhihu_cli/content/handlers/search.py +212 -0
- zhihu_cli/content/handlers/segment_comments.py +91 -0
- zhihu_cli/content/handlers/stats.py +132 -0
- zhihu_cli/content/handlers/upload_image.py +152 -0
- zhihu_cli/content/handlers/upvoter.py +71 -0
- zhihu_cli/content/handlers/waterfall.py +118 -0
- zhihu_cli/content/handlers/yanxuan.py +388 -0
- zhihu_cli/content/handlers/zvideo.py +214 -0
- zhihu_cli/content/universal_converter.py +130 -0
- zhihu_cli/content/utils/__init__.py +6 -0
- zhihu_cli/content/utils/html2markdown.py +505 -0
- zhihu_cli/content/utils/markdown2html.py +41 -0
- zhihu_cli/content/utils/wait.py +33 -0
- zhihu_cli/content/utils/zse.py +618 -0
- zhihu_cli/creator_tools/__init__.py +0 -0
- zhihu_cli/creator_tools/_smoothing.py +39 -0
- zhihu_cli/creator_tools/analyze_monthly_income.py +156 -0
- zhihu_cli/creator_tools/derivative_analysis.py +77 -0
- zhihu_cli/creator_tools/growth_level.py +114 -0
- zhihu_cli/creator_tools/parse_content_datas.py +286 -0
- zhihu_cli/creator_tools/parse_daily_metrics.py +131 -0
- zhihu_cli/creator_tools/parse_follower_detail.py +82 -0
- zhihu_cli/creator_tools/parse_follower_profile.py +129 -0
- zhihu_cli/creator_tools/parse_income_range.py +205 -0
- zhihu_cli/creator_tools/parse_score_detail.py +159 -0
- zhihu_cli/creator_tools/parse_zhihu_incomes.py +125 -0
- zhihu_cli/creator_tools/plot_content_metrics.py +520 -0
- zhihu_cli/creator_tools/plot_follower_detail.py +150 -0
- zhihu_cli/creator_tools/plot_zhihu_incomes.py +83 -0
- zhihu_cli/creator_tools/plot_zhihu_incomes_advanced.py +103 -0
- zhihu_cli/creator_tools/salt.py +252 -0
- zhihu_cli/creator_tools/stats_content_metrics.py +771 -0
- zhihu_cli/creator_tools/weekday_income_analysis.py +73 -0
- zhihu_cli/daemon/__init__.py +27 -0
- zhihu_cli/daemon/lifecycle.py +273 -0
- zhihu_cli/daemon/protocol.py +248 -0
- zhihu_cli/daemon/proxy.py +637 -0
- zhihu_cli/daemon/server.py +526 -0
- zhihu_cli/extensions/__init__.py +46 -0
- zhihu_cli/extensions/crank/__init__.py +21 -0
- zhihu_cli/extensions/crank/archiver.py +589 -0
- zhihu_cli/extensions/crank/classifier/__init__.py +14 -0
- zhihu_cli/extensions/crank/classifier/config.py +88 -0
- zhihu_cli/extensions/crank/classifier/data_loader.py +50 -0
- zhihu_cli/extensions/crank/classifier/dataset.py +190 -0
- zhihu_cli/extensions/crank/classifier/discovery.py +145 -0
- zhihu_cli/extensions/crank/classifier/model.py +175 -0
- zhihu_cli/extensions/crank/classifier/text_cleaner.py +81 -0
- zhihu_cli/extensions/crank/classifier/train.py +226 -0
- zhihu_cli/extensions/crank/monitor.py +1063 -0
- zhihu_cli/main.py +67 -0
- zhihu_cli/nlp_tools/__init__.py +17 -0
- zhihu_cli/nlp_tools/cluster_visualizer.py +275 -0
- zhihu_cli/nlp_tools/cooccurrence_network.py +236 -0
- zhihu_cli/nlp_tools/count_answer_words.py +138 -0
- zhihu_cli/nlp_tools/graph.py +750 -0
- zhihu_cli/nlp_tools/stop_words.txt +83 -0
- zhihu_cli/nlp_tools/wordcloud_generator.py +139 -0
- zhihu_cli/output.py +266 -0
- zhihu_cli/prompts/__init__.py +44 -0
- zhihu_cli/prompts/agora_vote.txt +12 -0
- zhihu_cli/prompts/commit_message.txt +1 -0
- zhihu_cli/prompts/series_naming.txt +1 -0
- zhihu_toolkit-0.1.0.dist-info/METADATA +225 -0
- zhihu_toolkit-0.1.0.dist-info/RECORD +124 -0
- zhihu_toolkit-0.1.0.dist-info/WHEEL +5 -0
- zhihu_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- zhihu_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- zhihu_toolkit-0.1.0.dist-info/top_level.txt +1 -0
zhihu_cli/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""zhihu-cli: Zhihu scraping, automation, and analysis toolkit."""
|
|
2
|
+
|
|
3
|
+
from zhihu_cli.content import (
|
|
4
|
+
ContentDownloader,
|
|
5
|
+
PageToMarkdown,
|
|
6
|
+
cache_manager,
|
|
7
|
+
extract_config_from_curl,
|
|
8
|
+
extract_metadata_from_html,
|
|
9
|
+
sanitize_filename,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ContentDownloader",
|
|
14
|
+
"PageToMarkdown",
|
|
15
|
+
"cache_manager",
|
|
16
|
+
"extract_config_from_curl",
|
|
17
|
+
"extract_metadata_from_html",
|
|
18
|
+
"sanitize_filename",
|
|
19
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""CLI command group modules — each module registers its commands on the main Click group."""
|
|
2
|
+
|
|
3
|
+
from zhihu_cli.commands._helpers import (
|
|
4
|
+
_display_following_items,
|
|
5
|
+
_extract_url_token,
|
|
6
|
+
_parse_item_url,
|
|
7
|
+
_read_content,
|
|
8
|
+
_resolve_answer_id,
|
|
9
|
+
_resolve_url_token,
|
|
10
|
+
_save_json_output,
|
|
11
|
+
_save_markdown,
|
|
12
|
+
_sort_items,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"_display_following_items",
|
|
17
|
+
"_extract_url_token",
|
|
18
|
+
"_parse_item_url",
|
|
19
|
+
"_read_content",
|
|
20
|
+
"_resolve_answer_id",
|
|
21
|
+
"_resolve_url_token",
|
|
22
|
+
"_save_json_output",
|
|
23
|
+
"_save_markdown",
|
|
24
|
+
"_sort_items",
|
|
25
|
+
]
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Shared helper functions used across command group modules."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
from zhihu_cli.content.download_contents import sanitize_filename
|
|
13
|
+
from zhihu_cli.content.handlers import get_type_and_id
|
|
14
|
+
from zhihu_cli.output import (
|
|
15
|
+
blank,
|
|
16
|
+
echo,
|
|
17
|
+
f_bold,
|
|
18
|
+
f_dim,
|
|
19
|
+
f_green,
|
|
20
|
+
f_label,
|
|
21
|
+
f_meta,
|
|
22
|
+
f_name,
|
|
23
|
+
f_num,
|
|
24
|
+
f_tag,
|
|
25
|
+
f_url,
|
|
26
|
+
item_index,
|
|
27
|
+
success,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ── URL helpers ────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_item_url(url: str) -> tuple[str, str]:
|
|
34
|
+
"""Parse a Zhihu URL into (item_type, item_id). Raises click.BadParameter on failure."""
|
|
35
|
+
item_type, item_id = get_type_and_id(url)
|
|
36
|
+
if not item_type or not item_id:
|
|
37
|
+
raise click.BadParameter(f"Cannot parse Zhihu URL: {url}")
|
|
38
|
+
return item_type, item_id
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve_answer_id(item_id: str) -> str:
|
|
42
|
+
"""Extract answer_id from composite 'question_id/answer_id' format."""
|
|
43
|
+
if "/" in item_id:
|
|
44
|
+
return item_id.split("/")[1]
|
|
45
|
+
return item_id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _extract_url_token(token_or_url: str) -> str:
|
|
49
|
+
"""Extract a Zhihu url_token from a full profile URL or return as-is."""
|
|
50
|
+
m = re.search(r"zhihu\.com/people/([^/?]+)", token_or_url)
|
|
51
|
+
if m:
|
|
52
|
+
return m.group(1)
|
|
53
|
+
return token_or_url.rstrip("/").split("/")[-1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_url_token(url_token: str | None) -> str:
|
|
57
|
+
"""Resolve the url_token: use provided value or auto-detect from /api/v4/me."""
|
|
58
|
+
from zhihu_cli.content.handlers.people import get_my_url_token
|
|
59
|
+
|
|
60
|
+
if url_token:
|
|
61
|
+
return _extract_url_token(url_token)
|
|
62
|
+
token = get_my_url_token()
|
|
63
|
+
if not token:
|
|
64
|
+
raise click.UsageError(
|
|
65
|
+
"Cannot detect your url_token. Please authenticate first (zhihu auth login) "
|
|
66
|
+
"or provide --url-token explicitly."
|
|
67
|
+
)
|
|
68
|
+
return token
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── File I/O helpers ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _save_markdown(metadata: dict, markdown: str, output_dir: str, prefix: str = "") -> str:
|
|
75
|
+
"""Save markdown content to output_dir. Returns the file path."""
|
|
76
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
77
|
+
title = sanitize_filename(metadata.get("title", "untitled"))
|
|
78
|
+
|
|
79
|
+
# author may be a string or a dict (e.g. from scrape_article)
|
|
80
|
+
author_raw = metadata.get("author", "unknown")
|
|
81
|
+
if isinstance(author_raw, dict):
|
|
82
|
+
author = sanitize_filename(author_raw.get("name", "unknown"))
|
|
83
|
+
else:
|
|
84
|
+
author = sanitize_filename(author_raw)
|
|
85
|
+
|
|
86
|
+
created = sanitize_filename(metadata.get("created_time", "unknown"))
|
|
87
|
+
filename = f"{prefix}{title}_{author}_{created}.md"[:200]
|
|
88
|
+
filepath = os.path.join(output_dir, filename)
|
|
89
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
90
|
+
f.write(markdown)
|
|
91
|
+
return filepath
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_content(file: str | None) -> str:
|
|
95
|
+
"""Read content from a file (use '-' for stdin)."""
|
|
96
|
+
if file is None or file == "-":
|
|
97
|
+
return sys.stdin.read()
|
|
98
|
+
return Path(file).read_text(encoding="utf-8")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _save_json_output(data: Any, output_path: str, label: str = "items") -> None:
|
|
102
|
+
"""Save data to a JSON file with a success message. No-op if *output_path* is empty."""
|
|
103
|
+
if not output_path:
|
|
104
|
+
return
|
|
105
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
107
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
108
|
+
length = len(data) if isinstance(data, (list, dict)) else 0
|
|
109
|
+
success(f"Saved {length} {label} to {output_path}")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Display helpers ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _display_following_items(items: list[dict], totals: int | None = None) -> None:
|
|
116
|
+
"""Display a list of following/followed items in terminal mode."""
|
|
117
|
+
for i, item in enumerate(items, 1):
|
|
118
|
+
ttype = item.get("type", "?")
|
|
119
|
+
|
|
120
|
+
if ttype == "user":
|
|
121
|
+
name = item.get("name", "")
|
|
122
|
+
headline = item.get("headline", "")
|
|
123
|
+
is_followed = item.get("is_followed", False)
|
|
124
|
+
is_following = item.get("is_following", False)
|
|
125
|
+
mutual = f" {f_green('[互关]')}" if (is_followed and is_following) else ""
|
|
126
|
+
f_cnt = item.get("follower_count", 0)
|
|
127
|
+
a_cnt = item.get("answer_count", 0)
|
|
128
|
+
art_cnt = item.get("articles_count", 0)
|
|
129
|
+
stats = f"{f_label('followers:')} {f_num(f_cnt)} {f_label('answers:')} {f_num(a_cnt)} {f_label('articles:')} {f_num(art_cnt)}"
|
|
130
|
+
echo(f" {item_index(i)} {f_bold(name)}{mutual}")
|
|
131
|
+
if headline:
|
|
132
|
+
echo(f" {f_dim(headline[:120])}")
|
|
133
|
+
echo(f" {f_dim(stats)}")
|
|
134
|
+
echo(f" {f_url(item.get('url', ''))}")
|
|
135
|
+
|
|
136
|
+
elif ttype == "topic":
|
|
137
|
+
name = item.get("name", "")
|
|
138
|
+
intro = item.get("introduction", "") or item.get("excerpt", "")
|
|
139
|
+
f_cnt = item.get("followers_count", 0)
|
|
140
|
+
q_cnt = item.get("questions_count", 0)
|
|
141
|
+
stats = f"{f_label('followers:')} {f_num(f_cnt)} {f_label('questions:')} {f_num(q_cnt)}"
|
|
142
|
+
echo(f" {item_index(i)} {f_bold(name)} {f_tag('topic')}")
|
|
143
|
+
if intro:
|
|
144
|
+
echo(f" {f_dim(intro[:120])}")
|
|
145
|
+
echo(f" {f_dim(stats)}")
|
|
146
|
+
echo(f" {f_url(item.get('url', ''))}")
|
|
147
|
+
|
|
148
|
+
elif ttype == "question":
|
|
149
|
+
title = item.get("title", "") or item.get("excerpt", "") or "(no title)"
|
|
150
|
+
a_cnt = item.get("answer_count", 0)
|
|
151
|
+
f_cnt = item.get("follower_count", 0)
|
|
152
|
+
ctime = item.get("created_time", "")
|
|
153
|
+
stats = f"{f_label('answers:')} {f_num(a_cnt)} {f_label('followers:')} {f_num(f_cnt)} {f_label('created:')} {f_meta(ctime)}"
|
|
154
|
+
echo(f" {item_index(i)} {f_bold(title[:120])}")
|
|
155
|
+
echo(f" {f_dim(stats)}")
|
|
156
|
+
echo(f" {f_url(item.get('url', ''))}")
|
|
157
|
+
|
|
158
|
+
elif ttype == "column":
|
|
159
|
+
title = item.get("title", "") or "(no title)"
|
|
160
|
+
desc = item.get("description", "") or item.get("excerpt", "")
|
|
161
|
+
creator = item.get("creator", "")
|
|
162
|
+
f_cnt = item.get("followers_count", 0)
|
|
163
|
+
art_cnt = item.get("articles_count", 0)
|
|
164
|
+
stats = f"{f_label('followers:')} {f_num(f_cnt)} {f_label('articles:')} {f_num(art_cnt)}"
|
|
165
|
+
echo(f" {item_index(i)} {f_bold(title)} {f_tag('column')}")
|
|
166
|
+
if creator:
|
|
167
|
+
echo(f" {f_name(creator)}")
|
|
168
|
+
if desc:
|
|
169
|
+
echo(f" {f_dim(desc[:120])}")
|
|
170
|
+
echo(f" {f_dim(stats)}")
|
|
171
|
+
echo(f" {f_url(item.get('url', ''))}")
|
|
172
|
+
|
|
173
|
+
elif ttype == "collection":
|
|
174
|
+
title = item.get("title", "") or "(no title)"
|
|
175
|
+
desc = item.get("description", "")
|
|
176
|
+
creator_name = item.get("creator_name", "")
|
|
177
|
+
a_cnt = item.get("answer_count", 0)
|
|
178
|
+
f_cnt = item.get("follower_count", 0)
|
|
179
|
+
stats = f"{f_label('items:')} {f_num(a_cnt)} {f_label('followers:')} {f_num(f_cnt)}"
|
|
180
|
+
echo(f" {item_index(i)} {f_bold(title)} {f_tag('collection')}")
|
|
181
|
+
if creator_name:
|
|
182
|
+
echo(f" {f_label('by')} {f_name(creator_name)}")
|
|
183
|
+
if desc:
|
|
184
|
+
echo(f" {f_dim(desc[:120])}")
|
|
185
|
+
echo(f" {f_dim(stats)}")
|
|
186
|
+
echo(f" {f_url(item.get('url', ''))}")
|
|
187
|
+
|
|
188
|
+
blank()
|
|
189
|
+
|
|
190
|
+
if items:
|
|
191
|
+
total_str = f"/{totals}" if totals else ""
|
|
192
|
+
echo(f" {f_dim(f'── {len(items)}{total_str} items')}")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _sort_items(items: list[dict], field: str, *, reverse: bool = True) -> list[dict]:
|
|
196
|
+
"""Sort a list of item dicts by *field* locally (descending by default).
|
|
197
|
+
|
|
198
|
+
Used by browse subcommands to re-sort fetched data without additional API calls.
|
|
199
|
+
|
|
200
|
+
:param items: List of item dicts to sort.
|
|
201
|
+
:param field: Dict key to sort by. ``"default"`` or empty string returns
|
|
202
|
+
items in original order.
|
|
203
|
+
:param reverse: Sort descending (highest first). Default ``True``.
|
|
204
|
+
:returns: A new sorted list (original is not mutated).
|
|
205
|
+
"""
|
|
206
|
+
if not items or field in ("default", ""):
|
|
207
|
+
return list(items)
|
|
208
|
+
# Auto-detect numeric vs string sort from the first item
|
|
209
|
+
sample = items[0].get(field) if items else None
|
|
210
|
+
if isinstance(sample, (int, float)):
|
|
211
|
+
return sorted(items, key=lambda x: x.get(field) or 0, reverse=reverse)
|
|
212
|
+
return sorted(items, key=lambda x: x.get(field) or "", reverse=reverse)
|