@acedatacloud/skills 2026.712.1 → 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 +1 -1
- package/skills/hashnode/SKILL.md +79 -131
- package/skills/hashnode/scripts/hashnode.py +364 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.712.
|
|
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",
|
package/skills/hashnode/SKILL.md
CHANGED
|
@@ -1,171 +1,119 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: hashnode
|
|
3
|
-
description: Publish,
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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: "
|
|
14
|
+
version: "2.0"
|
|
17
15
|
---
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
Token is in `$HASHNODE_TOKEN`. Single endpoint: `https://gql-beta.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
|
-
|
|
24
|
-
|
|
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.
|
|
25
24
|
|
|
26
|
-
|
|
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.**
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
GraphQL always returns HTTP `200`; real failures live in the JSON `errors`
|
|
34
|
-
array — always inspect it and show it verbatim. Two error cases matter:
|
|
35
|
-
|
|
36
|
-
- `Unauthorized` / `not authenticated` → the token is invalid → the user must
|
|
37
|
-
re-connect the Hashnode connector.
|
|
38
|
-
- `extensions.code == "FORBIDDEN"` with a message like *"Publication does not have
|
|
39
|
-
an active Pro plan"* → this is **not** a token problem. As of 2026-05-13 Hashnode
|
|
40
|
-
moved its API behind a paid plan: **every write mutation** (`publishPost`,
|
|
41
|
-
`createDraft`, `updatePost`) **and publication-scoped reads require the target
|
|
42
|
-
publication to be on [Hashnode Pro](https://hashnode.com/pro)**. Do NOT tell the
|
|
43
|
-
user to reconnect and do NOT retry in a loop — tell them the publication owner
|
|
44
|
-
must upgrade to Hashnode Pro (blog dashboard → Billing → Upgrade to Pro), after
|
|
45
|
-
which the API works immediately. Public reads (posts/feeds/users/tags) stay free.
|
|
46
|
-
|
|
47
|
-
Helper — send a query/mutation (`$1` = query string, `$2` = variables JSON):
|
|
48
|
-
|
|
49
|
-
```bash
|
|
50
|
-
gql() {
|
|
51
|
-
jq -n --arg q "$1" --argjson v "${2:-null}" '{query:$q, variables:$v}' \
|
|
52
|
-
| curl -sS -X POST https://gql-beta.hashnode.com \
|
|
53
|
-
-H "Authorization: Bearer $HASHNODE_TOKEN" \
|
|
54
|
-
-H "Content-Type: application/json" \
|
|
55
|
-
-d @-
|
|
56
|
-
}
|
|
57
|
-
```
|
|
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.
|
|
58
32
|
|
|
59
|
-
##
|
|
33
|
+
## Setup — anchor on our own script
|
|
60
34
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
| 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)
|
|
67
40
|
```
|
|
68
41
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
## Publish a post
|
|
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.
|
|
73
44
|
|
|
74
|
-
|
|
75
|
-
a draft first (see below). `tags` is required — supply 1–5 tags, each as
|
|
76
|
-
`{name, slug}` (slug lowercase, no spaces).
|
|
45
|
+
## ⚠️ Non-Pro blogs are automoderated — write EDITORIAL, not ads
|
|
77
46
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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".)
|
|
82
52
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
publicationId: $p,
|
|
86
|
-
title: $t,
|
|
87
|
-
contentMarkdown: $b,
|
|
88
|
-
tags: [{name:"AI", slug:"ai"}, {name:"Web Development", slug:"web-development"}]
|
|
89
|
-
}
|
|
90
|
-
}')
|
|
53
|
+
To stay live, write a genuinely useful **how-to / guide** that happens to feature
|
|
54
|
+
the product:
|
|
91
55
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
```
|
|
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.
|
|
96
59
|
|
|
97
|
-
|
|
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.
|
|
98
62
|
|
|
99
|
-
|
|
100
|
-
- `slug` — custom URL slug (otherwise derived from the title).
|
|
101
|
-
- `canonicalUrl` / `originalArticleURL` — when cross-posting, point SEO back to
|
|
102
|
-
the original source. Set this whenever the same article also lives elsewhere.
|
|
103
|
-
- `coverImageOptions: { coverImageURL: "https://..." }` — cover image.
|
|
104
|
-
- `publishedAt` — ISO-8601 timestamp to backdate; `disableComments` etc. live
|
|
105
|
-
under `settings`.
|
|
63
|
+
## Read
|
|
106
64
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
65
|
+
```sh
|
|
66
|
+
python3 "$H" publications # {publications:[{id,title,url}]}
|
|
67
|
+
```
|
|
110
68
|
|
|
111
|
-
|
|
112
|
-
VARS=$(jq -n --arg p "$PUB_ID" --arg t "$TITLE" --arg b "$BODY_MD" '{
|
|
113
|
-
input: { publicationId: $p, title: $t, contentMarkdown: $b }
|
|
114
|
-
}')
|
|
69
|
+
## Save a private draft (safe, non-public)
|
|
115
70
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.
|
|
119
79
|
```
|
|
120
80
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`
|
|
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.
|
|
124
87
|
|
|
125
|
-
|
|
126
|
-
VARS=$(jq -n --arg id "POST_ID" --arg b "$(cat article.md)" '{
|
|
127
|
-
input: { id: $id, contentMarkdown: $b }
|
|
128
|
-
}')
|
|
88
|
+
## Publish — GATED (dry-run unless trailing `--confirm`)
|
|
129
89
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
|
133
94
|
```
|
|
134
95
|
|
|
135
|
-
|
|
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.
|
|
136
99
|
|
|
137
|
-
|
|
138
|
-
gql 'query { me { posts(pageSize: 20, page: 1) { nodes { id title slug url views reactionCount responseCount publishedAt } } } }' \
|
|
139
|
-
| jq '[.data.me.posts.nodes[] | {id, title, url, views, reactions: .reactionCount, responses: .responseCount}]'
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
Single post with full content + stats:
|
|
100
|
+
## Delete a draft or post — GATED
|
|
143
101
|
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
|
|
102
|
+
```sh
|
|
103
|
+
python3 "$H" delete --id <draftOrPostId> # dry-run
|
|
104
|
+
python3 "$H" delete --id <draftOrPostId> --confirm # delete
|
|
147
105
|
```
|
|
148
106
|
|
|
149
107
|
## Gotchas
|
|
150
108
|
|
|
151
|
-
- **
|
|
152
|
-
|
|
153
|
-
-
|
|
154
|
-
|
|
155
|
-
- **
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
blog, not something the skill or a reconnect can fix — surface it and stop.
|
|
160
|
-
- **`publicationId` is mandatory** for publishing/drafting — never guess it, read
|
|
161
|
-
it from the `me` query.
|
|
162
|
-
- **`tags` is required on `publishPost`** — supply at least one `{name, slug}`;
|
|
163
|
-
slug must be lowercase with hyphens (e.g. `machine-learning`).
|
|
164
|
-
- **`errors` on HTTP 200** — GraphQL reports failures in the `errors` array, not
|
|
165
|
-
via status codes; always surface them.
|
|
166
|
-
- **Idempotency** — re-running `publishPost` creates a *new* post each time; to
|
|
167
|
-
change an existing one use `updatePost` with its `id`.
|
|
168
|
-
|
|
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.
|
|
169
117
|
|
|
170
118
|
## Record the output
|
|
171
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()
|