@acedatacloud/skills 2026.712.0 → 2026.712.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acedatacloud/skills",
3
- "version": "2026.712.0",
3
+ "version": "2026.712.2",
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",
@@ -1,151 +1,119 @@
1
1
  ---
2
2
  name: hashnode
3
- description: Publish, update and read blog posts on Hashnode via its GraphQL API. Use when the user mentions Hashnode, publishing a blog post to Hashnode, cross-posting an article to their Hashnode blog, saving a Hashnode draft, updating a published Hashnode post, or listing their Hashnode publications and posts.
3
+ description: Publish, draft and read blog posts on the user's Hashnode blog using their own login cookies (BYOC) — no Hashnode Pro plan required. Use when the user mentions Hashnode, publishing/cross-posting an article to their Hashnode blog, saving a Hashnode draft, or listing their Hashnode publications.
4
4
  when_to_use: |
5
- Trigger when the user wants to publish a Markdown article to their
6
- Hashnode blog, save it as a draft, update a previously published
7
- post, or list / inspect their own Hashnode publications and posts.
8
- The connector stores a Hashnode Personal Access Token with full
9
- account access — confirm before publishing publicly (you can save a
10
- draft first with the createDraft mutation).
5
+ Trigger when the user wants to publish a Markdown article to their Hashnode
6
+ blog, save it as a private draft, or list their Hashnode publications. This
7
+ drives the user's REAL blog, so publishing is gated behind an explicit
8
+ confirmation (save a draft first if unsure).
11
9
  connections: [hashnode]
12
- allowed_tools: [Bash]
10
+ allowed_tools: [Bash, publish_artifact]
13
11
  license: Apache-2.0
14
12
  metadata:
15
13
  author: acedatacloud
16
- version: "1.0"
14
+ version: "2.0"
17
15
  ---
18
16
 
19
- Call the **Hashnode Public GraphQL API** with `curl + jq`. The user's Personal
20
- Access Token is in `$HASHNODE_TOKEN`. Single endpoint: `https://gql.hashnode.com`
21
- (always `POST` a JSON body `{query, variables}`).
17
+ # hashnode publish to your Hashnode blog via your own cookies (no Pro)
22
18
 
23
- Every request needs these headers:
19
+ Hashnode moved its **public GraphQL API behind a paid Pro plan** (2026-05-13), so
20
+ a Personal Access Token can no longer publish for free. This skill instead drives
21
+ the **same first-party REST API the Hashnode web editor uses**
22
+ (`https://hashnode.com/api/...`), authenticated by the user's login **session
23
+ cookie** — which is free and needs no Pro plan.
24
24
 
25
- ```
26
- Authorization: $HASHNODE_TOKEN # raw tokenNO "Bearer " prefix
27
- Content-Type: application/json
28
- ```
29
-
30
- GraphQL always returns HTTP `200`; real failures live in the JSON `errors`
31
- array — always inspect it and show it verbatim. An `errors` entry mentioning
32
- `Unauthorized` / `not authenticated` means the token is invalid → the user must
33
- re-connect the Hashnode connector.
34
-
35
- Helper — send a query/mutation (`$1` = query string, `$2` = variables JSON):
36
-
37
- ```bash
38
- gql() {
39
- jq -n --arg q "$1" --argjson v "${2:-null}" '{query:$q, variables:$v}' \
40
- | curl -sS -X POST https://gql.hashnode.com \
41
- -H "Authorization: $HASHNODE_TOKEN" \
42
- -H "Content-Type: application/json" \
43
- -d @-
44
- }
45
- ```
25
+ The connector injects the cookie jar as `HASHNODE_COOKIES` (a JSON array; the
26
+ session cookie is `hashnode-session`). **Secret full account access. Never echo
27
+ or print it.**
46
28
 
47
- ## Always start by confirming the token and finding the publication id
29
+ > E2E-verified 2026-07-12 on a real connected blog: list publications, create +
30
+ > save draft (title / subtitle / cover / tags / Markdown), publish, and delete —
31
+ > all via cookie auth, no Pro.
48
32
 
49
- `publishPost` / `createDraft` need a `publicationId`. Fetch the account and its
50
- publications first (a user can have several blogs):
33
+ ## Setup anchor on our own script
51
34
 
52
- ```bash
53
- gql 'query { me { id username publications(first: 10) { edges { node { id title url } } } } }' \
54
- | jq '{me: .data.me.username, publications: [.data.me.publications.edges[].node | {id, title, url}], errors: .errors}'
35
+ ```sh
36
+ # $SKILL_DIR can point at another skill loaded this turn anchor on our script.
37
+ H="$SKILL_DIR/scripts/hashnode.py"; [ -f "$H" ] || H=$(find /tmp -maxdepth 8 -path '*/skills/*/hashnode/scripts/hashnode.py' 2>/dev/null | head -1)
38
+ [ -f "$H" ] || { echo "hashnode script not found (SKILL_DIR=$SKILL_DIR)" >&2; exit 1; }
39
+ python3 "$H" publications # list the user's blogs (verifies the cookie)
55
40
  ```
56
41
 
57
- Pick the target blog's `id` (that is the `publicationId`). If the user has
58
- exactly one publication, use it; otherwise ask which blog to post to.
59
-
60
- ## Publish a post
61
-
62
- **Confirm with the user before publishing publicly.** If they are not sure, save
63
- a draft first (see below). `tags` is required — supply 1–5 tags, each as
64
- `{name, slug}` (slug lowercase, no spaces).
42
+ On an auth error the cookie is expired have the user reconnect at
43
+ <https://auth.acedata.cloud/user/connections>. Do **not** loop-retry.
65
44
 
66
- ```bash
67
- PUB_ID="PUBLICATION_ID" # from the me query above
68
- TITLE="My title"
69
- BODY_MD="$(cat article.md)" # full Markdown body
45
+ ## ⚠️ Non-Pro blogs are automoderated — write EDITORIAL, not ads
70
46
 
71
- VARS=$(jq -n --arg p "$PUB_ID" --arg t "$TITLE" --arg b "$BODY_MD" '{
72
- input: {
73
- publicationId: $p,
74
- title: $t,
75
- contentMarkdown: $b,
76
- tags: [{name:"AI", slug:"ai"}, {name:"Web Development", slug:"web-development"}]
77
- }
78
- }')
47
+ This is the single most important rule. A **non-Pro** publication is subject to
48
+ Hashnode **automoderation**: an overtly promotional post (hypey language,
49
+ "free credits!!", the *same* CTA link repeated 3–4 times) is **auto-removed
50
+ within seconds** — the publish call still returns success, but the live URL then
51
+ 404s. (Pro's one benefit here is "protection from automoderation removal".)
79
52
 
80
- gql 'mutation PublishPost($input: PublishPostInput!) {
81
- publishPost(input: $input) { post { id slug url title } }
82
- }' "$VARS" | jq '{post: .data.publishPost.post, errors: .errors}'
83
- ```
53
+ To stay live, write a genuinely useful **how-to / guide** that happens to feature
54
+ the product:
84
55
 
85
- Useful optional `input` fields:
56
+ - Educational framing and a concrete title (e.g. *"… (Beginner's Guide)"*).
57
+ - At most a couple of **tasteful** links, not the same CTA repeated.
58
+ - No "limited-time!! grab now!!" marketing voice.
86
59
 
87
- - `subtitle` post subtitle.
88
- - `slug`custom URL slug (otherwise derived from the title).
89
- - `canonicalUrl` / `originalArticleURL` — when cross-posting, point SEO back to
90
- the original source. Set this whenever the same article also lives elsewhere.
91
- - `coverImageOptions: { coverImageURL: "https://..." }` — cover image.
92
- - `publishedAt` — ISO-8601 timestamp to backdate; `disableComments` etc. live
93
- under `settings`.
60
+ `publish` re-fetches the live URL after publishing and returns a `warning` if the
61
+ post was moderated away if you see that, rewrite it editorially and republish.
94
62
 
95
- ## Save a draft (safe, non-public)
63
+ ## Read
96
64
 
97
- Use this when the user has not explicitly approved public publishing:
65
+ ```sh
66
+ python3 "$H" publications # {publications:[{id,title,url}]}
67
+ ```
98
68
 
99
- ```bash
100
- VARS=$(jq -n --arg p "$PUB_ID" --arg t "$TITLE" --arg b "$BODY_MD" '{
101
- input: { publicationId: $p, title: $t, contentMarkdown: $b }
102
- }')
69
+ ## Save a private draft (safe, non-public)
103
70
 
104
- gql 'mutation CreateDraft($input: CreateDraftInput!) {
105
- createDraft(input: $input) { draft { id slug title } }
106
- }' "$VARS" | jq '{draft: .data.createDraft.draft, errors: .errors}'
71
+ ```sh
72
+ python3 "$H" draft \
73
+ --title "My Title" \
74
+ --subtitle "Optional subtitle" \
75
+ --content-file article.md \
76
+ --cover "https://cdn.example.com/cover.jpg" \
77
+ --tags ai,apis
78
+ # → {draft_id, editor_url}. Nothing is public yet.
107
79
  ```
108
80
 
109
- ## Update a published post
110
-
111
- `updatePost` needs the post `id` (from `publishPost`, or the list query below):
81
+ - `--content-file` is the Markdown body (use a file for long posts; `--content`
82
+ for a short inline string). Inline **images** and **links** are just Markdown.
83
+ - `--cover` sets the header/cover image (also used as the OG image).
84
+ - `--tags` are comma-separated slugs (`ai,apis,web-development`); they're resolved
85
+ to real Hashnode tag ids automatically (up to 5).
86
+ - `--publication <id>` is only needed if the account has more than one blog.
112
87
 
113
- ```bash
114
- VARS=$(jq -n --arg id "POST_ID" --arg b "$(cat article.md)" '{
115
- input: { id: $id, contentMarkdown: $b }
116
- }')
88
+ ## Publish — GATED (dry-run unless trailing `--confirm`)
117
89
 
118
- gql 'mutation UpdatePost($input: UpdatePostInput!) {
119
- updatePost(input: $input) { post { id url title } }
120
- }' "$VARS" | jq '{post: .data.updatePost.post, errors: .errors}'
90
+ ```sh
91
+ python3 "$H" publish --title "My Title" --content-file article.md --cover "https://…" --tags ai,apis # dry-run
92
+ python3 "$H" publish --title "My Title" --content-file article.md --cover "https://…" --tags ai,apis --confirm # LIVE
93
+ # → {ok, url} (or {ok:false, warning:"…automoderation removed…"} — rewrite editorially)
121
94
  ```
122
95
 
123
- ## List / inspect my posts
96
+ A confirmed `publish` is **immediately public** on the user's real blog. Always
97
+ show the final title + body, get an explicit "yes", then run with `--confirm` as
98
+ the **last** argument.
124
99
 
125
- ```bash
126
- gql 'query { me { posts(pageSize: 20, page: 1) { nodes { id title slug url views reactionCount responseCount publishedAt } } } }' \
127
- | jq '[.data.me.posts.nodes[] | {id, title, url, views, reactions: .reactionCount, responses: .responseCount}]'
128
- ```
129
-
130
- Single post with full content + stats:
100
+ ## Delete a draft or post — GATED
131
101
 
132
- ```bash
133
- gql 'query GetPost($id: ObjectId!) { post(id: $id) { title url views reactionCount responseCount content { markdown } } }' \
134
- "$(jq -n --arg id "POST_ID" '{id:$id}')" | jq '.data.post | {title, url, views, reactions: .reactionCount}'
102
+ ```sh
103
+ python3 "$H" delete --id <draftOrPostId> # dry-run
104
+ python3 "$H" delete --id <draftOrPostId> --confirm # delete
135
105
  ```
136
106
 
137
107
  ## Gotchas
138
108
 
139
- - **No `Bearer` prefix** the `Authorization` header carries the raw token.
140
- - **`publicationId` is mandatory** for publishing/drafting never guess it, read
141
- it from the `me` query.
142
- - **`tags` is required on `publishPost`** — supply at least one `{name, slug}`;
143
- slug must be lowercase with hyphens (e.g. `machine-learning`).
144
- - **`errors` on HTTP 200** GraphQL reports failures in the `errors` array, not
145
- via status codes; always surface them.
146
- - **Idempotency** re-running `publishPost` creates a *new* post each time; to
147
- change an existing one use `updatePost` with its `id`.
148
-
109
+ - **No Pro, no PAT.** This uses the cookie-authed `hashnode.com/api/*` editor API,
110
+ not `gql-beta.hashnode.com` (which requires a paid Pro plan).
111
+ - **Automoderation** (above) is the main failure mode on free blogs — keep it
112
+ editorial.
113
+ - **This is the user's real blog.** Confirm before any publish/delete.
114
+ - **Never print `HASHNODE_COOKIES`**it is full account access.
115
+ - **First-party internal API** it can change when Hashnode updates the editor;
116
+ if a call starts returning HTML/404 unexpectedly, report it as upstream drift.
149
117
 
150
118
  ## Record the output
151
119
 
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ hashnode — read & publish on Hashnode with the user's own login cookies (BYOC).
4
+ Standard-library only (urllib), no third-party deps.
5
+
6
+ Hashnode moved its **public GraphQL API behind a paid Pro plan** (2026-05-13), so
7
+ this skill does NOT use ``gql-beta.hashnode.com`` / a Personal Access Token.
8
+ Instead it drives the **same first-party REST endpoints the web editor uses**
9
+ (``https://hashnode.com/api/...``), authenticated by the login session cookie —
10
+ which is free and needs no Pro plan.
11
+
12
+ The connector injects the cookie jar as a JSON env var ``HASHNODE_COOKIES`` (a
13
+ JSON list of cookie dicts; the session cookie is ``hashnode-session``). It is
14
+ full account access — NEVER echo or print it.
15
+
16
+ ⚠️ Non-Pro publications are subject to Hashnode **automoderation**: overtly
17
+ promotional posts (hypey language, repeated identical CTA links, "free credits!!")
18
+ are auto-removed within seconds of publishing, so the post 404s even though the
19
+ publish call returned success. Write **editorial / how-to** content (a helpful
20
+ guide that happens to feature the product, with at most a couple of tasteful
21
+ links) and it stays live. ``publish`` re-fetches the live URL and warns if the
22
+ post was moderated away.
23
+
24
+ Read commands run directly. ``publish`` / ``delete`` are GATED by a trailing
25
+ ``--confirm`` (honored only as the LAST arg). ``draft`` saves a private draft.
26
+
27
+ Examples:
28
+ python3 hashnode.py publications
29
+ python3 hashnode.py draft --title T --content-file a.md --cover https://x/y.jpg --tags ai,apis
30
+ python3 hashnode.py publish --title T --content-file a.md --cover https://x/y.jpg --tags ai,apis --confirm
31
+ python3 hashnode.py delete --id <draftOrPostId> --confirm
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import gzip
38
+ import json
39
+ import os
40
+ import sys
41
+ import time
42
+ import urllib.error
43
+ import urllib.parse
44
+ import urllib.request
45
+
46
+ UA = (
47
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
48
+ "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
49
+ )
50
+ BASE = "https://hashnode.com"
51
+
52
+ _RAW = sys.argv[1:]
53
+ # --confirm is honored ONLY as the last token, so body text containing
54
+ # "--confirm" can never silently trigger a write.
55
+ CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
56
+ ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
57
+
58
+ # State-changing commands — dry-run unless the invocation ends with --confirm.
59
+ GATED = {"publish", "delete"}
60
+
61
+
62
+ def out(obj) -> None:
63
+ print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
64
+
65
+
66
+ def die(msg: str, code: int = 1) -> None:
67
+ out({"error": msg})
68
+ sys.exit(code)
69
+
70
+
71
+ # ── Cookie jar ──────────────────────────────────────────────────────
72
+
73
+ def load_cookies() -> list:
74
+ raw = os.environ.get("HASHNODE_COOKIES")
75
+ if not raw:
76
+ die("HASHNODE_COOKIES is not set — connect Hashnode at "
77
+ "https://auth.acedata.cloud/user/connections, then retry.")
78
+ try:
79
+ jar = json.loads(raw)
80
+ except json.JSONDecodeError as e:
81
+ die(f"HASHNODE_COOKIES is not valid JSON: {e}")
82
+ if not isinstance(jar, list):
83
+ die(f"HASHNODE_COOKIES must be a JSON list of cookies, got {type(jar).__name__}")
84
+ if not any(c.get("name") == "hashnode-session" for c in jar):
85
+ die("HASHNODE_COOKIES is missing the 'hashnode-session' cookie — re-capture "
86
+ "on hashnode.com with the ACE extension, then reconnect at "
87
+ "https://auth.acedata.cloud/user/connections.")
88
+ return jar
89
+
90
+
91
+ def _domain_matches(host: str, domain: str) -> bool:
92
+ d = domain.lstrip(".").lower()
93
+ h = host.lower()
94
+ return not d or h == d or h.endswith("." + d)
95
+
96
+
97
+ def cookie_header(jar: list, url: str) -> str:
98
+ host = urllib.parse.urlsplit(url).hostname or ""
99
+ host_in_scope = any(
100
+ c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
101
+ )
102
+ parts = []
103
+ for c in jar:
104
+ name, value = c.get("name"), c.get("value")
105
+ if not name or value is None:
106
+ continue
107
+ domain = c.get("domain")
108
+ if domain:
109
+ if not _domain_matches(host, str(domain)):
110
+ continue
111
+ elif not host_in_scope:
112
+ # A domain-less cookie only rides along when at least one scoped
113
+ # cookie already matched this host (never leak it to a stray host).
114
+ continue
115
+ parts.append(f"{name}={value}")
116
+ return "; ".join(parts)
117
+
118
+
119
+ # ── HTTP ────────────────────────────────────────────────────────────
120
+
121
+ def request(method, path, jar, *, body=None):
122
+ url = BASE + path
123
+ hdrs = {
124
+ "User-Agent": UA,
125
+ "Accept": "application/json",
126
+ "Origin": BASE,
127
+ "Referer": BASE + "/",
128
+ "X-Requested-With": "XMLHttpRequest",
129
+ }
130
+ data = None
131
+ if body is not None:
132
+ data = json.dumps(body).encode("utf-8")
133
+ hdrs["Content-Type"] = "application/json"
134
+ req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
135
+ # Unredirected → the cookie is not re-sent if the API 30x-redirects to a
136
+ # different host, so the jar never leaks off-site.
137
+ req.add_unredirected_header("Cookie", cookie_header(jar, url))
138
+ try:
139
+ with urllib.request.urlopen(req, timeout=30) as resp:
140
+ raw = resp.read()
141
+ if resp.headers.get("Content-Encoding") == "gzip":
142
+ raw = gzip.decompress(raw)
143
+ return resp.status, raw.decode("utf-8", "replace")
144
+ except urllib.error.HTTPError as e:
145
+ raw = e.read()
146
+ try:
147
+ if e.headers.get("Content-Encoding") == "gzip":
148
+ raw = gzip.decompress(raw)
149
+ except Exception:
150
+ pass
151
+ return e.code, raw.decode("utf-8", "replace")
152
+ except urllib.error.URLError as e:
153
+ die(f"network error reaching {url}: {e.reason}")
154
+
155
+
156
+ def api(method, path, jar, *, body=None, _retried=False):
157
+ status, text = request(method, path, jar, body=body)
158
+ # Hashnode sits behind Cloudflare, which intermittently 403/429s an
159
+ # otherwise valid session; retry idempotent GETs once. Never replay a
160
+ # write (POST/PUT/DELETE) — it could duplicate a publish.
161
+ if status in (403, 429) and method == "GET" and not _retried:
162
+ time.sleep(1.5)
163
+ return api(method, path, jar, body=body, _retried=True)
164
+ if status in (401, 403):
165
+ die(f"auth failed ({status}) on {path} — cookie likely expired. "
166
+ f"Reconnect at https://auth.acedata.cloud/user/connections.")
167
+ # A hashnode.com/api path that 404s with an HTML body means the route/id is
168
+ # wrong; a JSON {success:false} is a real API error.
169
+ stripped = text.lstrip()
170
+ if stripped.startswith("<"):
171
+ die(f"unexpected non-JSON ({status}) from {path} — the route or id is "
172
+ f"likely wrong.")
173
+ try:
174
+ d = json.loads(text)
175
+ except json.JSONDecodeError:
176
+ die(f"non-JSON response ({status}) from {path}: {text[:300]}")
177
+ if isinstance(d, dict) and d.get("success") is False:
178
+ die(f"Hashnode API error ({status}) on {path}: {d.get('error')}")
179
+ return d
180
+
181
+
182
+ # ── helpers ─────────────────────────────────────────────────────────
183
+
184
+ def resolve_publication(jar, wanted: str | None) -> dict:
185
+ d = api("GET", "/api/publications", jar)
186
+ pubs = d.get("publications") or []
187
+ if not pubs:
188
+ die("no publications on this account — create a blog on hashnode.com first.")
189
+ if wanted:
190
+ for p in pubs:
191
+ if wanted in (p.get("_id"), p.get("title"), p.get("displayTitle")):
192
+ return p
193
+ die(f"publication {wanted!r} not found; have: "
194
+ + ", ".join(f'{p.get("title")}({p.get("_id")})' for p in pubs))
195
+ if len(pubs) > 1:
196
+ die("multiple publications — pass --publication <id>. Have: "
197
+ + ", ".join(f'{p.get("title")}({p.get("_id")})' for p in pubs))
198
+ return pubs[0]
199
+
200
+
201
+ def resolve_tags(jar, slugs_csv: str | None) -> list:
202
+ if not slugs_csv:
203
+ return []
204
+ tags = []
205
+ for slug in [s.strip() for s in slugs_csv.split(",") if s.strip()]:
206
+ d = api("GET", f"/api/tags/search?q={urllib.parse.quote(slug)}", jar)
207
+ cands = d.get("tags") or []
208
+ pick = next((t for t in cands if t.get("slug") == slug), None) \
209
+ or (max(cands, key=lambda t: t.get("numPosts", 0)) if cands else None)
210
+ if pick:
211
+ tags.append({"_id": pick["_id"], "name": pick["name"], "slug": pick["slug"]})
212
+ return tags[:5]
213
+
214
+
215
+ def read_content(args) -> str:
216
+ if args.content_file:
217
+ if not os.path.isfile(args.content_file):
218
+ die(f"content file not found: {args.content_file}")
219
+ with open(args.content_file, encoding="utf-8") as fh:
220
+ return fh.read()
221
+ if args.content:
222
+ return args.content
223
+ die("provide --content-file or --content")
224
+
225
+
226
+ def build_save(args, pub_id, jar) -> dict:
227
+ body = {
228
+ "title": (args.title or "").strip(),
229
+ "contentMarkdown": read_content(args),
230
+ "publicationId": pub_id,
231
+ }
232
+ if not body["title"]:
233
+ die("provide --title")
234
+ if args.subtitle:
235
+ body["subtitle"] = args.subtitle
236
+ if args.cover:
237
+ body["coverImage"] = args.cover
238
+ body["ogImage"] = args.cover
239
+ if args.slug:
240
+ body["slug"] = args.slug
241
+ tags = resolve_tags(jar, args.tags)
242
+ if tags:
243
+ body["tags"] = tags
244
+ return body
245
+
246
+
247
+ def create_and_save(args, jar) -> tuple[str, dict]:
248
+ pub = resolve_publication(jar, args.publication)
249
+ d = api("POST", "/api/drafts", jar, body={"publicationId": pub["_id"]})
250
+ did = d.get("draftId")
251
+ if not did:
252
+ die(f"draft creation returned no draftId: {d}")
253
+ save = build_save(args, pub["_id"], jar)
254
+ api("PUT", f"/api/drafts/{did}", jar, body=save)
255
+ return did, pub
256
+
257
+
258
+ # ── commands ────────────────────────────────────────────────────────
259
+
260
+ def cmd_publications(jar, _args):
261
+ d = api("GET", "/api/publications", jar)
262
+ out({"publications": [
263
+ {"id": p.get("_id"), "title": p.get("title"),
264
+ "url": p.get("url") or p.get("domain")} for p in d.get("publications") or []]})
265
+
266
+
267
+ def cmd_draft(jar, args):
268
+ did, pub = create_and_save(args, jar)
269
+ out({"ok": True, "draft_id": did, "publication": pub.get("title"),
270
+ "editor_url": f"https://hashnode.com/draft/{did}",
271
+ "note": "Private draft saved. Review it, then run `publish ... --confirm` to go live."})
272
+
273
+
274
+ def cmd_publish(jar, args):
275
+ did, pub = create_and_save(args, jar)
276
+ d = api("POST", f"/api/drafts/{did}/publish", jar, body={})
277
+ post = d.get("post") or {}
278
+ url = post.get("url")
279
+ result = {"ok": True, "posted": True, "id": post.get("id"),
280
+ "title": post.get("title"), "url": url, "publication": pub.get("title")}
281
+ # Verify the post is actually live — non-Pro blogs auto-moderate promo posts.
282
+ if url:
283
+ time.sleep(4)
284
+ try:
285
+ code = _url_status(url)
286
+ if code == 404:
287
+ result["ok"] = False
288
+ result["warning"] = (
289
+ "The publish call succeeded but the live URL returns 404 — "
290
+ "Hashnode automoderation removed the post (common for overtly "
291
+ "promotional content on a non-Pro blog). Rewrite it in an "
292
+ "editorial / how-to style with fewer repeated CTA links and "
293
+ "republish.")
294
+ except Exception:
295
+ pass
296
+ out(result)
297
+
298
+
299
+ def _url_status(url: str) -> int:
300
+ req = urllib.request.Request(url, headers={"User-Agent": UA}, method="GET")
301
+ try:
302
+ with urllib.request.urlopen(req, timeout=20) as r:
303
+ return r.status
304
+ except urllib.error.HTTPError as e:
305
+ return e.code
306
+ except Exception:
307
+ return 0
308
+
309
+
310
+ def cmd_delete(jar, args):
311
+ d = api("DELETE", f"/api/drafts/{args.id}", jar)
312
+ out({"ok": True, "deleted": True, "id": d.get("deletedDraftId") or args.id})
313
+
314
+
315
+ COMMANDS = {
316
+ "publications": cmd_publications,
317
+ "draft": cmd_draft,
318
+ "publish": cmd_publish,
319
+ "delete": cmd_delete,
320
+ }
321
+
322
+
323
+ def gated_dry_run(args) -> None:
324
+ out({"dry_run": True, "command": args.command,
325
+ "title": getattr(args, "title", None), "id": getattr(args, "id", None),
326
+ "note": "Re-run with --confirm as the LAST argument to actually run this "
327
+ "on the user's REAL Hashnode blog."})
328
+
329
+
330
+ def build_parser() -> argparse.ArgumentParser:
331
+ p = argparse.ArgumentParser(description="Hashnode via login cookies (no Pro).")
332
+ sub = p.add_subparsers(dest="command", required=True)
333
+
334
+ sub.add_parser("publications", help="list my blogs (publications)")
335
+
336
+ def add_write_args(sp):
337
+ sp.add_argument("--title", required=True)
338
+ sp.add_argument("--subtitle")
339
+ sp.add_argument("--content-file", dest="content_file", help="path to a Markdown file")
340
+ sp.add_argument("--content", help="inline Markdown (use --content-file for长文)")
341
+ sp.add_argument("--cover", help="cover image URL (also used as OG image)")
342
+ sp.add_argument("--tags", help="comma-separated tag slugs, e.g. ai,apis")
343
+ sp.add_argument("--slug", help="custom URL slug (optional)")
344
+ sp.add_argument("--publication", help="publication id (needed only if >1 blog)")
345
+
346
+ add_write_args(sub.add_parser("draft", help="save a PRIVATE draft (safe, non-public)"))
347
+ add_write_args(sub.add_parser("publish", help="publish a post (GATED by trailing --confirm)"))
348
+
349
+ sp = sub.add_parser("delete", help="delete a draft or post (GATED by trailing --confirm)")
350
+ sp.add_argument("--id", required=True)
351
+ return p
352
+
353
+
354
+ def main() -> None:
355
+ args = build_parser().parse_args(ARGV)
356
+ if args.command in GATED and not CONFIRM:
357
+ gated_dry_run(args)
358
+ return
359
+ jar = load_cookies()
360
+ COMMANDS[args.command](jar, args)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()