@acedatacloud/skills 2026.621.3 → 2026.621.4
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.
- package/package.json +1 -1
- package/skills/cn-blog/SKILL.md +97 -0
- package/skills/cn-blog/scripts/blog.py +349 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.621.
|
|
3
|
+
"version": "2026.621.4",
|
|
4
4
|
"description": "Agent Skills for AceDataCloud AI services — music, image, video generation, LLM chat, web search. Compatible with Claude Code, GitHub Copilot, Gemini CLI, OpenAI Codex, and 30+ AI coding agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent-skills",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cn-blog
|
|
3
|
+
description: Read and publish on Chinese content platforms with the user's own login cookies (BYOC) — list their published articles with vote/comment stats, inspect one article, and publish a new article. Use when the user mentions 知乎 / Zhihu, "我的知乎文章", reading their article stats (点赞/评论), or publishing/发文 to Zhihu.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for anything on the user's Zhihu (知乎) account driven by their own
|
|
6
|
+
login cookie: show who they are, list their published articles with
|
|
7
|
+
vote-up / comment counts, look at one article's stats, or publish a new
|
|
8
|
+
article. This acts as the user's real account, so writes are gated behind
|
|
9
|
+
an explicit confirmation.
|
|
10
|
+
connections: [zhihu]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# cn-blog — Zhihu via your own cookies
|
|
19
|
+
|
|
20
|
+
Drives the user's **real** Zhihu account through the same web APIs the site's
|
|
21
|
+
own editor uses, authenticated by the login cookie they captured with the ACE
|
|
22
|
+
extension. No browser, no third-party deps — just `urllib`.
|
|
23
|
+
|
|
24
|
+
The connector injects the cookie jar as an env var:
|
|
25
|
+
|
|
26
|
+
- `ZHIHU_COOKIES` — a JSON array of `{name, value, domain, path, ...}` cookies.
|
|
27
|
+
**Secret — never echo or print it.** The CLI reads it for you.
|
|
28
|
+
|
|
29
|
+
## CLI
|
|
30
|
+
|
|
31
|
+
The skill ships [`scripts/blog.py`](scripts/blog.py) — self-contained, stdlib only.
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
BLOG=$SKILL_DIR/scripts/blog.py
|
|
35
|
+
|
|
36
|
+
# Read (run directly)
|
|
37
|
+
python3 $BLOG whoami # who is logged in
|
|
38
|
+
python3 $BLOG articles --limit 20 # my published articles + stats
|
|
39
|
+
python3 $BLOG article <article-id> # one article's details + stats
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Verify the connection first
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
python3 $BLOG whoami
|
|
46
|
+
# → {"id": "...", "name": "崔庆才丨静觅", "url_token": "cui-qing-cai", ...}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On a `401`/`403` the cookie is expired — tell the user to reconnect at
|
|
50
|
+
<https://auth.acedata.cloud/user/connections> (re-capture with the ACE
|
|
51
|
+
extension). Do **not** retry in a loop.
|
|
52
|
+
|
|
53
|
+
## Reading recipes
|
|
54
|
+
|
|
55
|
+
| Goal | Command |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Who am I | `python3 $BLOG whoami` |
|
|
58
|
+
| My latest articles + vote/comment counts | `python3 $BLOG articles --limit 20` |
|
|
59
|
+
| Next page | `python3 $BLOG articles --limit 20 --offset 20` |
|
|
60
|
+
| One article's stats | `python3 $BLOG article <id>` |
|
|
61
|
+
|
|
62
|
+
Stats come straight from Zhihu: `voteup_count` (赞同), `comment_count` (评论).
|
|
63
|
+
Zhihu does not expose per-article read counts on these endpoints.
|
|
64
|
+
|
|
65
|
+
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
66
|
+
|
|
67
|
+
`publish` writes to the user's real account. Without a trailing `--confirm` it
|
|
68
|
+
**dry-runs** (prints what it would do, changes nothing). `--confirm` is honored
|
|
69
|
+
**only as the last argument**, so a title/content containing "--confirm" can
|
|
70
|
+
never silently go live. Always show the dry-run to the user, get an explicit
|
|
71
|
+
"yes", then re-run with `--confirm` last.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
# Content is HTML. For Markdown, convert to HTML first.
|
|
75
|
+
python3 $BLOG publish --title "标题" --content-file article.html # dry-run
|
|
76
|
+
python3 $BLOG publish --title "标题" --content-file article.html --draft-only --confirm # save a private draft
|
|
77
|
+
python3 $BLOG publish --title "标题" --content-file article.html --confirm # PUBLIC, goes live
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- `--draft-only` stops after saving a private draft (safe — nothing public).
|
|
81
|
+
- Without `--draft-only`, the article is **published publicly** under the user's
|
|
82
|
+
name. Default to `--draft-only` unless the user clearly asked to go live.
|
|
83
|
+
- Images: only image URLs already reachable on the public web are kept as-is;
|
|
84
|
+
this CLI does not re-upload local images to Zhihu's CDN.
|
|
85
|
+
|
|
86
|
+
## Gotchas — surface before the user is surprised
|
|
87
|
+
|
|
88
|
+
- **This is the user's real Zhihu account.** Confirm before any publish; reading
|
|
89
|
+
exposes their own private drafts.
|
|
90
|
+
- **Cookie expiry**: Zhihu cookies are short-lived. A `401`/`403` means
|
|
91
|
+
reconnect at auth.acedata.cloud/user/connections — never loop-retry.
|
|
92
|
+
- **ToS**: cookie automation is against most platforms' terms. This only ever
|
|
93
|
+
acts on the user's own account with their own captured cookie; the user owns
|
|
94
|
+
that risk. Never use it to scrape other people's content at scale.
|
|
95
|
+
- **Never print `ZHIHU_COOKIES`** — it is full account access.
|
|
96
|
+
- **Scope today**: Zhihu only. 掘金 / CSDN connectors exist in the vault and are
|
|
97
|
+
planned next; this skill will grow a `--platform` switch for them.
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cn-blog — read & publish on Chinese content platforms with the user's own
|
|
4
|
+
login cookies (BYOC). Standard-library only (urllib), no third-party deps,
|
|
5
|
+
so it runs in the bare sandbox without an image change.
|
|
6
|
+
|
|
7
|
+
The connector injects the user's cookie jar as a JSON env var named
|
|
8
|
+
``<PLATFORM>_COOKIES`` (e.g. ``ZHIHU_COOKIES``) — a list of
|
|
9
|
+
``{name, value, domain, path, ...}`` dicts captured by the ACE extension.
|
|
10
|
+
|
|
11
|
+
Read commands run directly. ``publish`` is GATED: without a trailing
|
|
12
|
+
``--confirm`` it only dry-runs (prints what it would do, changes nothing).
|
|
13
|
+
``--confirm`` is honored ONLY as the last argument, so a title/content that
|
|
14
|
+
merely contains "--confirm" can never silently go live.
|
|
15
|
+
|
|
16
|
+
Quick examples:
|
|
17
|
+
python3 $SKILL_DIR/scripts/blog.py whoami
|
|
18
|
+
python3 $SKILL_DIR/scripts/blog.py articles --limit 20
|
|
19
|
+
python3 $SKILL_DIR/scripts/blog.py article <article-id>
|
|
20
|
+
python3 $SKILL_DIR/scripts/blog.py drafts
|
|
21
|
+
python3 $SKILL_DIR/scripts/blog.py publish --title "T" --content-file a.html --draft-only --confirm
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import gzip
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
|
|
35
|
+
UA = (
|
|
36
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
37
|
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# --confirm is only honored as the LAST token so a value that merely
|
|
41
|
+
# contains "--confirm" can never silently confirm a write.
|
|
42
|
+
_RAW = sys.argv[1:]
|
|
43
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
44
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def out(obj) -> None:
|
|
48
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def die(msg: str, code: int = 1) -> None:
|
|
52
|
+
out({"error": msg})
|
|
53
|
+
sys.exit(code)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ── Cookie jar (from env) ───────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
def load_cookies(platform: str) -> list[dict]:
|
|
59
|
+
env = f"{platform.upper()}_COOKIES"
|
|
60
|
+
raw = os.environ.get(env)
|
|
61
|
+
if not raw:
|
|
62
|
+
die(
|
|
63
|
+
f"{env} is not set — connect the {platform} account at "
|
|
64
|
+
f"https://auth.acedata.cloud/user/connections, then retry."
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
jar = json.loads(raw)
|
|
68
|
+
except json.JSONDecodeError as e:
|
|
69
|
+
die(f"{env} is not valid JSON: {e}")
|
|
70
|
+
if not isinstance(jar, list):
|
|
71
|
+
die(f"{env} must be a JSON list of cookies, got {type(jar).__name__}")
|
|
72
|
+
return jar
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _domain_matches(host: str, domain: str) -> bool:
|
|
76
|
+
# Browser-style domain match: a cookie scoped to ".zhihu.com" / "zhihu.com"
|
|
77
|
+
# is sent to zhihu.com and any subdomain; a host-only cookie matches exactly.
|
|
78
|
+
d = domain.lstrip(".").lower()
|
|
79
|
+
h = host.lower()
|
|
80
|
+
return not d or h == d or h.endswith("." + d)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cookie_header(jar: list[dict], url: str) -> str:
|
|
84
|
+
# Only send a cookie to a host inside its domain scope, so a jar is never
|
|
85
|
+
# replayed outside the platform it was captured for (defense in depth — all
|
|
86
|
+
# request URLs here are first-party, but this future-proofs multi-platform).
|
|
87
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
88
|
+
# A domainless cookie has no scope of its own; only send it to a host the
|
|
89
|
+
# jar's domain-scoped cookies already cover (i.e. this jar's own platform),
|
|
90
|
+
# never fail-open to an arbitrary host.
|
|
91
|
+
host_in_scope = any(
|
|
92
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
93
|
+
)
|
|
94
|
+
parts = []
|
|
95
|
+
for c in jar:
|
|
96
|
+
name, value = c.get("name"), c.get("value")
|
|
97
|
+
if not name or value is None:
|
|
98
|
+
continue
|
|
99
|
+
domain = c.get("domain")
|
|
100
|
+
if domain:
|
|
101
|
+
if not _domain_matches(host, str(domain)):
|
|
102
|
+
continue
|
|
103
|
+
elif not host_in_scope:
|
|
104
|
+
continue
|
|
105
|
+
parts.append(f"{name}={value}")
|
|
106
|
+
return "; ".join(parts)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ── HTTP (stdlib urllib) ────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
def request(method: str, url: str, jar: list[dict], *, headers=None, body=None):
|
|
112
|
+
hdrs = {
|
|
113
|
+
"User-Agent": UA,
|
|
114
|
+
"Accept": "application/json, text/plain, */*",
|
|
115
|
+
"Cookie": cookie_header(jar, url),
|
|
116
|
+
}
|
|
117
|
+
if headers:
|
|
118
|
+
hdrs.update(headers)
|
|
119
|
+
data = None
|
|
120
|
+
if body is not None:
|
|
121
|
+
data = json.dumps(body).encode("utf-8")
|
|
122
|
+
hdrs.setdefault("Content-Type", "application/json")
|
|
123
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
124
|
+
try:
|
|
125
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
126
|
+
raw = resp.read()
|
|
127
|
+
if resp.headers.get("Content-Encoding") == "gzip":
|
|
128
|
+
raw = gzip.decompress(raw)
|
|
129
|
+
return resp.status, raw.decode("utf-8", "replace")
|
|
130
|
+
except urllib.error.HTTPError as e:
|
|
131
|
+
raw = e.read()
|
|
132
|
+
try:
|
|
133
|
+
if e.headers.get("Content-Encoding") == "gzip":
|
|
134
|
+
raw = gzip.decompress(raw)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
return e.code, raw.decode("utf-8", "replace")
|
|
138
|
+
except urllib.error.URLError as e:
|
|
139
|
+
die(f"network error reaching {url}: {e.reason}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_json(url: str, jar: list[dict], **kw):
|
|
143
|
+
status, text = request("GET", url, jar, **kw)
|
|
144
|
+
if status == 401 or status == 403:
|
|
145
|
+
die(
|
|
146
|
+
f"auth failed ({status}) on {url} — the cookie is likely expired. "
|
|
147
|
+
f"Reconnect at https://auth.acedata.cloud/user/connections."
|
|
148
|
+
)
|
|
149
|
+
try:
|
|
150
|
+
return status, json.loads(text)
|
|
151
|
+
except json.JSONDecodeError:
|
|
152
|
+
die(f"non-JSON response ({status}) from {url}: {text[:300]}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ── Zhihu ───────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
ZH = {
|
|
158
|
+
"me": "https://www.zhihu.com/api/v4/me",
|
|
159
|
+
"articles": "https://www.zhihu.com/api/v4/members/{token}/articles",
|
|
160
|
+
"article": "https://www.zhihu.com/api/v4/articles/{id}",
|
|
161
|
+
"create_draft": "https://zhuanlan.zhihu.com/api/articles/drafts",
|
|
162
|
+
}
|
|
163
|
+
ZH_FETCH = {"x-requested-with": "fetch"}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def zh_me(jar):
|
|
167
|
+
_, data = get_json(ZH["me"], jar, headers=ZH_FETCH)
|
|
168
|
+
if not data.get("id"):
|
|
169
|
+
die(f"could not read Zhihu profile (cookie expired?): {str(data)[:300]}")
|
|
170
|
+
return data
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_whoami(jar, _args):
|
|
174
|
+
me = zh_me(jar)
|
|
175
|
+
out({
|
|
176
|
+
"id": str(me.get("id", "")),
|
|
177
|
+
"name": me.get("name"),
|
|
178
|
+
"url_token": me.get("url_token"),
|
|
179
|
+
"headline": me.get("headline"),
|
|
180
|
+
"articles_count": me.get("articles_count"),
|
|
181
|
+
"voteup_count": me.get("voteup_count"),
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# Zhihu omits stats unless asked via `include`. This pulls the counts the
|
|
186
|
+
# user actually cares about onto each article in the list/detail responses.
|
|
187
|
+
ZH_ARTICLE_INCLUDE = "data[*].comment_count,voteup_count,created,updated,title,url"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _https(url):
|
|
191
|
+
if isinstance(url, str) and url.startswith("http://"):
|
|
192
|
+
return "https://" + url[len("http://"):]
|
|
193
|
+
return url
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fmt_article(a: dict) -> dict:
|
|
197
|
+
aid = a.get("id")
|
|
198
|
+
# Prefer the canonical public reader URL built from the id — the API's own
|
|
199
|
+
# `url` field is inconsistent (zhuanlan in the list, api.zhihu.com in detail).
|
|
200
|
+
return {
|
|
201
|
+
"id": str(aid) if aid is not None else None,
|
|
202
|
+
"title": a.get("title"),
|
|
203
|
+
"url": (f"https://zhuanlan.zhihu.com/p/{aid}" if aid else _https(a.get("url"))),
|
|
204
|
+
"voteup_count": a.get("voteup_count"),
|
|
205
|
+
"comment_count": a.get("comment_count"),
|
|
206
|
+
"created": a.get("created"),
|
|
207
|
+
"updated": a.get("updated"),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_articles(jar, args):
|
|
212
|
+
me = zh_me(jar)
|
|
213
|
+
token = me.get("url_token")
|
|
214
|
+
if not token:
|
|
215
|
+
die("Zhihu profile has no url_token; cannot list articles.")
|
|
216
|
+
url = ZH["articles"].format(token=token)
|
|
217
|
+
q = urllib.parse.urlencode({
|
|
218
|
+
"include": ZH_ARTICLE_INCLUDE,
|
|
219
|
+
"limit": args.limit,
|
|
220
|
+
"offset": args.offset,
|
|
221
|
+
})
|
|
222
|
+
_, data = get_json(f"{url}?{q}", jar, headers=ZH_FETCH)
|
|
223
|
+
items = data.get("data", []) if isinstance(data, dict) else []
|
|
224
|
+
out({
|
|
225
|
+
"total": (data.get("paging") or {}).get("totals") if isinstance(data, dict) else None,
|
|
226
|
+
"count": len(items),
|
|
227
|
+
"articles": [_fmt_article(a) for a in items],
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def cmd_article(jar, args):
|
|
232
|
+
base = ZH["article"].format(id=args.id)
|
|
233
|
+
q = urllib.parse.urlencode({"include": "comment_count,voteup_count"})
|
|
234
|
+
_, a = get_json(f"{base}?{q}", jar, headers=ZH_FETCH)
|
|
235
|
+
if not isinstance(a, dict) or not a.get("id"):
|
|
236
|
+
die(f"article {args.id} not found or not accessible: {str(a)[:300]}")
|
|
237
|
+
res = _fmt_article(a)
|
|
238
|
+
res["content_excerpt"] = (a.get("excerpt") or "")[:200]
|
|
239
|
+
out(res)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def cmd_publish(jar, args):
|
|
243
|
+
if not args.title:
|
|
244
|
+
die("--title is required")
|
|
245
|
+
if not args.content_file and args.content is None:
|
|
246
|
+
die("provide --content-file <path> or --content <html>")
|
|
247
|
+
content = args.content
|
|
248
|
+
if args.content_file:
|
|
249
|
+
try:
|
|
250
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
251
|
+
content = f.read()
|
|
252
|
+
except OSError as e:
|
|
253
|
+
die(f"cannot read --content-file: {e}")
|
|
254
|
+
|
|
255
|
+
if not CONFIRM:
|
|
256
|
+
out({
|
|
257
|
+
"dry_run": True,
|
|
258
|
+
"command": "publish",
|
|
259
|
+
"platform": "zhihu",
|
|
260
|
+
"title": args.title,
|
|
261
|
+
"draft_only": args.draft_only,
|
|
262
|
+
"content_bytes": len(content or ""),
|
|
263
|
+
"note": "re-run with --confirm as the LAST argument to actually write. "
|
|
264
|
+
"Without --draft-only this publishes a PUBLIC article on the user's real account.",
|
|
265
|
+
})
|
|
266
|
+
return
|
|
267
|
+
|
|
268
|
+
# 1. create empty draft
|
|
269
|
+
status, text = request(
|
|
270
|
+
"POST", ZH["create_draft"], jar, headers=ZH_FETCH,
|
|
271
|
+
body={"title": args.title, "content": "", "delta_time": 0},
|
|
272
|
+
)
|
|
273
|
+
try:
|
|
274
|
+
created = json.loads(text)
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
die(f"create-draft returned non-JSON ({status}): {text[:300]}")
|
|
277
|
+
draft_id = created.get("id")
|
|
278
|
+
if not draft_id:
|
|
279
|
+
die(f"create-draft failed ({status}): {str(created)[:300]}")
|
|
280
|
+
|
|
281
|
+
# 2. set draft content
|
|
282
|
+
status, text = request(
|
|
283
|
+
"PATCH", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/draft", jar,
|
|
284
|
+
headers=ZH_FETCH, body={"title": args.title, "content": content},
|
|
285
|
+
)
|
|
286
|
+
if status >= 400:
|
|
287
|
+
die(f"update-draft failed ({status}) for {draft_id}: {text[:300]}")
|
|
288
|
+
|
|
289
|
+
if args.draft_only:
|
|
290
|
+
out({
|
|
291
|
+
"ok": True,
|
|
292
|
+
"draft_only": True,
|
|
293
|
+
"draft_id": str(draft_id),
|
|
294
|
+
"edit_url": f"https://zhuanlan.zhihu.com/write?draftId={draft_id}",
|
|
295
|
+
})
|
|
296
|
+
return
|
|
297
|
+
|
|
298
|
+
# 3. publish (go live)
|
|
299
|
+
status, text = request(
|
|
300
|
+
"PUT", f"https://zhuanlan.zhihu.com/api/articles/{draft_id}/publish", jar,
|
|
301
|
+
headers=ZH_FETCH, body={},
|
|
302
|
+
)
|
|
303
|
+
if status >= 400:
|
|
304
|
+
die(f"publish failed ({status}) for {draft_id}; it remains a draft: {text[:300]}")
|
|
305
|
+
out({
|
|
306
|
+
"ok": True,
|
|
307
|
+
"published": True,
|
|
308
|
+
"article_id": str(draft_id),
|
|
309
|
+
"url": f"https://zhuanlan.zhihu.com/p/{draft_id}",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
COMMANDS = {
|
|
314
|
+
"whoami": cmd_whoami,
|
|
315
|
+
"articles": cmd_articles,
|
|
316
|
+
"article": cmd_article,
|
|
317
|
+
"publish": cmd_publish,
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def main() -> None:
|
|
322
|
+
p = argparse.ArgumentParser(prog="blog.py", description="cn-blog cookie CLI")
|
|
323
|
+
p.add_argument("--platform", default="zhihu", choices=["zhihu"],
|
|
324
|
+
help="content platform (only zhihu is implemented today)")
|
|
325
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
326
|
+
|
|
327
|
+
sub.add_parser("whoami", help="show the logged-in account")
|
|
328
|
+
|
|
329
|
+
sp = sub.add_parser("articles", help="list the user's published articles + stats")
|
|
330
|
+
sp.add_argument("--limit", type=int, default=20)
|
|
331
|
+
sp.add_argument("--offset", type=int, default=0)
|
|
332
|
+
|
|
333
|
+
sp = sub.add_parser("article", help="one article's details + stats")
|
|
334
|
+
sp.add_argument("id")
|
|
335
|
+
|
|
336
|
+
sp = sub.add_parser("publish", help="create/publish an article (GATED by trailing --confirm)")
|
|
337
|
+
sp.add_argument("--title")
|
|
338
|
+
sp.add_argument("--content", help="HTML content inline")
|
|
339
|
+
sp.add_argument("--content-file", help="path to an HTML file")
|
|
340
|
+
sp.add_argument("--draft-only", action="store_true",
|
|
341
|
+
help="create a draft only; do NOT go public")
|
|
342
|
+
|
|
343
|
+
args = p.parse_args(ARGV)
|
|
344
|
+
jar = load_cookies(args.platform)
|
|
345
|
+
COMMANDS[args.command](jar, args)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
if __name__ == "__main__":
|
|
349
|
+
main()
|