@acedatacloud/skills 2026.703.8 → 2026.703.10
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/mastodon/SKILL.md +103 -0
- package/skills/substack/SKILL.md +95 -0
- package/skills/substack/scripts/substack.py +465 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.703.
|
|
3
|
+
"version": "2026.703.10",
|
|
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,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mastodon
|
|
3
|
+
description: Publish, delete and read your own posts (toots) on any Mastodon instance via the Mastodon REST API. Use when the user wants to post a toot to their Mastodon / fediverse account, cross-post an article as a short dev-focused post, delete a toot, or list their own recent posts with engagement stats (boosts, favourites, replies).
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger when the user wants to publish a status/toot to their Mastodon
|
|
6
|
+
account, delete one, or review their own recent posts and engagement.
|
|
7
|
+
Mastodon is federated: the connector stores the instance base URL plus a
|
|
8
|
+
personal access token, so every call targets the user's own instance.
|
|
9
|
+
Confirm visibility (public/unlisted) before posting publicly.
|
|
10
|
+
connections: [mastodon]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
Call the **Mastodon REST API** with `curl + jq`. Two connector credentials are
|
|
19
|
+
injected: `$MASTODON_BASE_URL` (the instance, e.g. `https://mastodon.social`)
|
|
20
|
+
and `$MASTODON_ACCESS_TOKEN`. Every request sends the header
|
|
21
|
+
`Authorization: Bearer $MASTODON_ACCESS_TOKEN`.
|
|
22
|
+
|
|
23
|
+
Errors come back as JSON `{"error":"<message>"}` — show it verbatim. `401`
|
|
24
|
+
(`"The access token is invalid"`) means the token is wrong/revoked → the user
|
|
25
|
+
must re-connect the Mastodon connector. Posting needs the token to have the
|
|
26
|
+
`write` (or `write:statuses`) scope.
|
|
27
|
+
|
|
28
|
+
**Always confirm the token + account first** (also gives the account `id` you
|
|
29
|
+
need to list your own toots):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
curl -sS "$MASTODON_BASE_URL/api/v1/accounts/verify_credentials" \
|
|
33
|
+
-H "Authorization: Bearer $MASTODON_ACCESS_TOKEN" \
|
|
34
|
+
| jq '{id, username, acct, display_name, followers: .followers_count, statuses: .statuses_count}'
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Post a toot
|
|
38
|
+
|
|
39
|
+
**Confirm with the user before posting publicly.** Default `visibility` to
|
|
40
|
+
`unlisted` unless they say post publicly; use `public` only on request.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
STATUS_TEXT="Hello fediverse 👋 #introductions"
|
|
44
|
+
curl -sS -X POST "$MASTODON_BASE_URL/api/v1/statuses" \
|
|
45
|
+
-H "Authorization: Bearer $MASTODON_ACCESS_TOKEN" \
|
|
46
|
+
-H "Idempotency-Key: $(uuidgen)" \
|
|
47
|
+
--data-urlencode "status=$STATUS_TEXT" \
|
|
48
|
+
--data-urlencode "visibility=unlisted" \
|
|
49
|
+
--data-urlencode "language=en" \
|
|
50
|
+
| jq '{id, url, visibility, created_at}'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- `visibility` is one of `public`, `unlisted`, `private`, `direct`.
|
|
54
|
+
- Optional params: `spoiler_text` (content warning), `in_reply_to_id` (reply),
|
|
55
|
+
`sensitive=true`, `language` (ISO 639-1).
|
|
56
|
+
- `Idempotency-Key` (any unique string; `uuidgen` here) prevents duplicate
|
|
57
|
+
posts if the request is retried within ~1h. Use `--data-urlencode` so
|
|
58
|
+
hashtags, emoji and newlines in the text are encoded correctly.
|
|
59
|
+
- Default post length is 500 chars (instance-configurable); longer text →
|
|
60
|
+
`422 {"error":"Validation failed: Text ..."}`.
|
|
61
|
+
|
|
62
|
+
## List my recent toots + engagement
|
|
63
|
+
|
|
64
|
+
Use the `id` from `verify_credentials`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
ACCT_ID="14715" # from verify_credentials
|
|
68
|
+
curl -sS "$MASTODON_BASE_URL/api/v1/accounts/$ACCT_ID/statuses?limit=20&exclude_replies=true&exclude_reblogs=true" \
|
|
69
|
+
-H "Authorization: Bearer $MASTODON_ACCESS_TOKEN" \
|
|
70
|
+
| jq '.[] | {id, url, boosts: .reblogs_count, favs: .favourites_count, replies: .replies_count, created_at}'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`limit` max 40. Other filters: `only_media`, `pinned`, `tagged=<hashtag>`.
|
|
74
|
+
|
|
75
|
+
## Delete a toot
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
curl -sS -X DELETE "$MASTODON_BASE_URL/api/v1/statuses/STATUS_ID" \
|
|
79
|
+
-H "Authorization: Bearer $MASTODON_ACCESS_TOKEN" | jq '{id, deleted: true}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Deleting returns the status with its source `text` so you can delete-and-redraft.
|
|
83
|
+
`404 {"error":"Record not found"}` = not yours or already gone.
|
|
84
|
+
|
|
85
|
+
## Attaching media (optional)
|
|
86
|
+
|
|
87
|
+
Upload each image/video via `POST $MASTODON_BASE_URL/api/v2/media`
|
|
88
|
+
(`multipart/form-data`, field `file`) to get a media id, then pass the ids as
|
|
89
|
+
`media_ids[]` when posting the status. See the docs for the full media contract:
|
|
90
|
+
https://docs.joinmastodon.org/methods/media/
|
|
91
|
+
|
|
92
|
+
## Gotchas
|
|
93
|
+
|
|
94
|
+
- **Federated:** the API only ever targets `$MASTODON_BASE_URL` (the user's own
|
|
95
|
+
instance). There is no global endpoint — a token from instance A won't work
|
|
96
|
+
on instance B.
|
|
97
|
+
- **Scopes:** reading needs `read` (or `read:accounts`/`read:statuses`);
|
|
98
|
+
posting/deleting needs `write` (or `write:statuses`). A `403`
|
|
99
|
+
(`"This action is outside the authorized scopes"`) means the token lacks a scope.
|
|
100
|
+
- **Rate limits:** Mastodon rate-limits per token; space out bulk posts or you'll
|
|
101
|
+
get `429`.
|
|
102
|
+
- `verify_credentials` returns HTML in fields like `note`; the plaintext source
|
|
103
|
+
lives under the `source` object.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: substack
|
|
3
|
+
description: Read and publish on Substack (substack.com) with the user's own login cookies (BYOC) — show who they are and their primary publication, list their published posts with stats, and publish a new post / newsletter. Use when the user mentions Substack, "my Substack", their newsletter, or publishing a post to Substack.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger for anything on the user's Substack account driven by their own login
|
|
6
|
+
cookie: show who they are and their primary publication, list their published
|
|
7
|
+
posts, or publish a new post / newsletter. This acts as the user's real
|
|
8
|
+
account, so writes are gated behind an explicit confirmation and never email
|
|
9
|
+
subscribers unless asked.
|
|
10
|
+
connections: [substack]
|
|
11
|
+
allowed_tools: [Bash]
|
|
12
|
+
license: Apache-2.0
|
|
13
|
+
metadata:
|
|
14
|
+
author: acedatacloud
|
|
15
|
+
version: "1.0"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# substack — read & publish on Substack via your own cookies
|
|
19
|
+
|
|
20
|
+
Drives the user's **real** Substack account through the same internal web API
|
|
21
|
+
the site uses (Substack has no official public write API), authenticated by the
|
|
22
|
+
login cookie they captured with the ACE extension. No browser, no third-party
|
|
23
|
+
deps — just `urllib`. The publish flow mirrors the community `python-substack`
|
|
24
|
+
library: **create draft → prepublish → publish**.
|
|
25
|
+
|
|
26
|
+
The connector injects the cookie jar as an env var:
|
|
27
|
+
|
|
28
|
+
- `SUBSTACK_COOKIES` — a JSON array of cookies (`substack.sid`, `substack.lli`,
|
|
29
|
+
…). **Secret — never echo or print it.** Substack authenticates writes with
|
|
30
|
+
the session cookie alone (no CSRF token header needed).
|
|
31
|
+
|
|
32
|
+
## CLI
|
|
33
|
+
|
|
34
|
+
The skill ships [`scripts/substack.py`](scripts/substack.py) — self-contained, stdlib only.
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
SUB=$SKILL_DIR/scripts/substack.py
|
|
38
|
+
python3 $SUB whoami # who is logged in + primary publication
|
|
39
|
+
python3 $SUB articles --limit 20 # my published posts + stats
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Verify the connection first
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
python3 $SUB whoami
|
|
46
|
+
# → {"user_id": ..., "name": "...", "handle": "...", "publication": "...", "publication_url": "https://<sub>.substack.com"}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
On an auth error the cookie is expired — have the user reconnect at
|
|
50
|
+
<https://auth.acedata.cloud/user/connections>. Do **not** loop-retry.
|
|
51
|
+
|
|
52
|
+
## Publishing — GATED (dry-run unless trailing `--confirm`)
|
|
53
|
+
|
|
54
|
+
`publish` writes to the user's real account. Content is **Markdown**, converted
|
|
55
|
+
to Substack's ProseMirror blocks: `#`..`######`→heading, ```` ``` ````→code
|
|
56
|
+
block, `>`→blockquote, `-`/`*`/`1.`→lists, `---`→horizontal rule, everything else
|
|
57
|
+
→paragraphs. Inline **bold**, *italic*, `code`, ~~strike~~, and `[links](url)`
|
|
58
|
+
render as real Substack formatting; bare `https://…` URLs are auto-linked.
|
|
59
|
+
|
|
60
|
+
Without a trailing `--confirm` it dry-runs. `--confirm` is honored **only as the
|
|
61
|
+
last argument**. Always show the dry-run, get an explicit "yes", then re-run.
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
# dry-run (shows the plan, writes nothing)
|
|
65
|
+
python3 $SUB publish --title "Title" --content-file post.md
|
|
66
|
+
|
|
67
|
+
# private draft (visible only in the user's dashboard)
|
|
68
|
+
python3 $SUB publish --title "Title" --content-file post.md --draft-only --confirm
|
|
69
|
+
|
|
70
|
+
# go LIVE on the web (does NOT email subscribers)
|
|
71
|
+
python3 $SUB publish --title "Title" --content-file post.md --confirm
|
|
72
|
+
|
|
73
|
+
# go LIVE and email subscribers (use only when the user explicitly asks)
|
|
74
|
+
python3 $SUB publish --title "Title" --content-file post.md --send-email --confirm
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- `--draft-only` stops at a draft — **default to this** unless the user asked to
|
|
78
|
+
go live. Returns an `edit_url` to review in the Substack editor.
|
|
79
|
+
- Publishing **does NOT email subscribers** unless `--send-email` is passed.
|
|
80
|
+
Emailing a newsletter is irreversible and hits every subscriber's inbox —
|
|
81
|
+
never add `--send-email` without an explicit request.
|
|
82
|
+
- `--subtitle` sets the post subtitle; `--audience` is one of
|
|
83
|
+
`everyone` (default, free/public), `only_paid`, `founding`, `only_free`.
|
|
84
|
+
|
|
85
|
+
## Gotchas
|
|
86
|
+
|
|
87
|
+
- **This is the user's real Substack account.** Confirm before any publish, and
|
|
88
|
+
keep `--draft-only` as the safe default.
|
|
89
|
+
- **`--send-email` blasts the whole subscriber list** and cannot be undone — opt
|
|
90
|
+
in only on explicit request.
|
|
91
|
+
- Substack sits behind Cloudflare; an occasional 403/429 on a read is transient —
|
|
92
|
+
the CLI auto-retries reads once. A *persistent* 401/403 means the cookie is
|
|
93
|
+
genuinely expired (reconnect); the CLI never retries a write.
|
|
94
|
+
- **Never print `SUBSTACK_COOKIES`** — it is full account access.
|
|
95
|
+
- **ToS**: acts only on the user's own account with their own captured cookie.
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
substack — read & publish on Substack (substack.com) with the user's own login
|
|
4
|
+
cookies (BYOC). Standard-library only (urllib), no third-party deps.
|
|
5
|
+
|
|
6
|
+
Substack has no official public write API, so this drives the same internal
|
|
7
|
+
endpoints the website uses, authenticated by the session cookies
|
|
8
|
+
(``substack.sid`` / ``substack.lli`` on ``.substack.com``). The publish flow
|
|
9
|
+
mirrors the community ``python-substack`` library: create draft → prepublish →
|
|
10
|
+
publish. Draft content is a ProseMirror ``doc`` (JSON string in ``draft_body``).
|
|
11
|
+
|
|
12
|
+
The connector injects the cookie jar as a JSON env var ``SUBSTACK_COOKIES``.
|
|
13
|
+
|
|
14
|
+
Read commands run directly. ``publish`` is GATED by a trailing ``--confirm``
|
|
15
|
+
(honored only as the last arg). ``--draft-only`` stops at a private draft.
|
|
16
|
+
Publishing does NOT email subscribers unless ``--send-email`` is passed.
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
python3 substack.py whoami
|
|
20
|
+
python3 substack.py articles --limit 20
|
|
21
|
+
python3 substack.py publish --title T --content-file a.md --draft-only --confirm
|
|
22
|
+
python3 substack.py publish --title T --content-file a.md --confirm
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import sys
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.parse
|
|
35
|
+
import urllib.request
|
|
36
|
+
|
|
37
|
+
UA = (
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
|
39
|
+
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
|
40
|
+
)
|
|
41
|
+
PLATFORM = "substack"
|
|
42
|
+
ROOT = "https://substack.com/api/v1"
|
|
43
|
+
|
|
44
|
+
_RAW = sys.argv[1:]
|
|
45
|
+
CONFIRM = bool(_RAW) and _RAW[-1] == "--confirm"
|
|
46
|
+
ARGV = _RAW[:-1] if CONFIRM else list(_RAW)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def out(obj) -> None:
|
|
50
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2, default=str))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def die(msg: str, code: int = 1) -> None:
|
|
54
|
+
out({"error": msg})
|
|
55
|
+
sys.exit(code)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Cookie jar ──────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
def load_cookies() -> list:
|
|
61
|
+
env = f"{PLATFORM.upper()}_COOKIES"
|
|
62
|
+
raw = os.environ.get(env)
|
|
63
|
+
if not raw:
|
|
64
|
+
die(f"{env} is not set — connect Substack at "
|
|
65
|
+
f"https://auth.acedata.cloud/user/connections, then retry.")
|
|
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
|
+
d = domain.lstrip(".").lower()
|
|
77
|
+
h = host.lower()
|
|
78
|
+
return not d or h == d or h.endswith("." + d)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cookie_header(jar: list, url: str) -> str:
|
|
82
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
83
|
+
host_in_scope = any(
|
|
84
|
+
c.get("domain") and _domain_matches(host, str(c["domain"])) for c in jar
|
|
85
|
+
)
|
|
86
|
+
parts = []
|
|
87
|
+
for c in jar:
|
|
88
|
+
name, value = c.get("name"), c.get("value")
|
|
89
|
+
if not name or value is None:
|
|
90
|
+
continue
|
|
91
|
+
domain = c.get("domain")
|
|
92
|
+
if domain:
|
|
93
|
+
if not _domain_matches(host, str(domain)):
|
|
94
|
+
continue
|
|
95
|
+
elif not host_in_scope:
|
|
96
|
+
continue
|
|
97
|
+
parts.append(f"{name}={value}")
|
|
98
|
+
return "; ".join(parts)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── HTTP ────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
def request(method, url, jar, *, body=None):
|
|
104
|
+
host = urllib.parse.urlsplit(url)
|
|
105
|
+
origin = f"{host.scheme}://{host.hostname}"
|
|
106
|
+
hdrs = {
|
|
107
|
+
"User-Agent": UA,
|
|
108
|
+
"Accept": "application/json, text/plain, */*",
|
|
109
|
+
"Origin": origin,
|
|
110
|
+
"Referer": origin + "/",
|
|
111
|
+
"X-Requested-With": "XMLHttpRequest",
|
|
112
|
+
}
|
|
113
|
+
data = None
|
|
114
|
+
if body is not None:
|
|
115
|
+
data = json.dumps(body).encode("utf-8")
|
|
116
|
+
hdrs["Content-Type"] = "application/json"
|
|
117
|
+
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
|
|
118
|
+
# Unredirected → the cookie is not re-sent if the API 30x-redirects to a
|
|
119
|
+
# different host (e.g. a login page), so the jar never leaks off-site.
|
|
120
|
+
req.add_unredirected_header("Cookie", cookie_header(jar, url))
|
|
121
|
+
try:
|
|
122
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
123
|
+
return resp.status, resp.read().decode("utf-8", "replace")
|
|
124
|
+
except urllib.error.HTTPError as e:
|
|
125
|
+
return e.code, e.read().decode("utf-8", "replace")
|
|
126
|
+
except urllib.error.URLError as e:
|
|
127
|
+
die(f"network error reaching {url}: {e.reason}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def api(method, url, jar, *, body=None, _retried=False):
|
|
131
|
+
status, text = request(method, url, jar, body=body)
|
|
132
|
+
# Substack sits behind Cloudflare, which intermittently 403s/429s an
|
|
133
|
+
# otherwise valid session; one retry after a short pause clears the
|
|
134
|
+
# transient block. Only retry idempotent GETs — never replay a write.
|
|
135
|
+
if status in (403, 429) and method == "GET" and not _retried:
|
|
136
|
+
time.sleep(1.5)
|
|
137
|
+
return api(method, url, jar, body=body, _retried=True)
|
|
138
|
+
if status in (401, 403):
|
|
139
|
+
die(f"auth failed ({status}) on {url} — cookie likely expired. "
|
|
140
|
+
f"Reconnect at https://auth.acedata.cloud/user/connections.")
|
|
141
|
+
try:
|
|
142
|
+
d = json.loads(text) if text.strip() else {}
|
|
143
|
+
except json.JSONDecodeError:
|
|
144
|
+
die(f"non-JSON response ({status}) from {url}: {text[:300]}")
|
|
145
|
+
if status >= 400:
|
|
146
|
+
msg = d.get("error") if isinstance(d, dict) else None
|
|
147
|
+
die(f"Substack API error ({status}) on {url}: {msg or text[:200]}")
|
|
148
|
+
return d
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── identity / publication ──────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def _publication_web(pub: dict) -> str:
|
|
154
|
+
custom = pub.get("custom_domain")
|
|
155
|
+
if custom and not pub.get("custom_domain_optional"):
|
|
156
|
+
return f"https://{custom}"
|
|
157
|
+
return f"https://{pub.get('subdomain')}.substack.com"
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve(jar) -> dict:
|
|
161
|
+
"""Return {user_id, name, handle, publication_url(web), publication_api, publication_name}."""
|
|
162
|
+
prof = api("GET", f"{ROOT}/user/profile/self", jar)
|
|
163
|
+
if not isinstance(prof, dict) or not prof.get("id"):
|
|
164
|
+
die(f"could not read Substack profile (cookie expired?): {str(prof)[:200]}")
|
|
165
|
+
pub = None
|
|
166
|
+
for pu in prof.get("publicationUsers") or []:
|
|
167
|
+
if pu.get("is_primary") and pu.get("publication"):
|
|
168
|
+
pub = pu["publication"]
|
|
169
|
+
break
|
|
170
|
+
if pub is None:
|
|
171
|
+
for pu in prof.get("publicationUsers") or []:
|
|
172
|
+
if pu.get("publication"):
|
|
173
|
+
pub = pu["publication"]
|
|
174
|
+
break
|
|
175
|
+
if pub is None:
|
|
176
|
+
die("no Substack publication on this account — create one at "
|
|
177
|
+
"https://substack.com, then reconnect.")
|
|
178
|
+
web = _publication_web(pub)
|
|
179
|
+
return {
|
|
180
|
+
"user_id": prof.get("id"),
|
|
181
|
+
"name": prof.get("name"),
|
|
182
|
+
"handle": prof.get("handle"),
|
|
183
|
+
"publication_url": web,
|
|
184
|
+
"publication_api": web + "/api/v1",
|
|
185
|
+
"publication_name": pub.get("name"),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_whoami(jar, _args):
|
|
190
|
+
me = resolve(jar)
|
|
191
|
+
out({
|
|
192
|
+
"user_id": me["user_id"],
|
|
193
|
+
"name": me["name"],
|
|
194
|
+
"handle": me["handle"],
|
|
195
|
+
"publication": me["publication_name"],
|
|
196
|
+
"publication_url": me["publication_url"],
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def cmd_articles(jar, args):
|
|
201
|
+
me = resolve(jar)
|
|
202
|
+
d = api("GET", f"{me['publication_api']}/post_management/published"
|
|
203
|
+
f"?offset=0&limit={args.limit}&order_by=post_date&order_direction=desc", jar)
|
|
204
|
+
posts = d.get("posts") if isinstance(d, dict) else d
|
|
205
|
+
posts = posts or []
|
|
206
|
+
web = me["publication_url"]
|
|
207
|
+
out({"count": len(posts), "articles": [{
|
|
208
|
+
"id": p.get("id"),
|
|
209
|
+
"title": p.get("title"),
|
|
210
|
+
"url": f"{web}/p/{p.get('slug')}" if p.get("slug") else None,
|
|
211
|
+
"audience": p.get("audience"),
|
|
212
|
+
"post_date": p.get("post_date"),
|
|
213
|
+
"reactions": p.get("reaction_count") or (p.get("reactions") or {}).get("❤"),
|
|
214
|
+
"comments": p.get("comment_count"),
|
|
215
|
+
} for p in posts]})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ── markdown → ProseMirror doc ──────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
# Inline: 1/2 link, 3 bold+italic, 4 bold, 5 code, 6 italic, 7 strike, 8 bare url
|
|
221
|
+
_INLINE = re.compile(
|
|
222
|
+
r"\[([^\]]+)\]\((https?://[^)\s]+)\)"
|
|
223
|
+
r"|\*\*\*([^*]+)\*\*\*"
|
|
224
|
+
r"|\*\*([^*]+)\*\*"
|
|
225
|
+
r"|`([^`]+)`"
|
|
226
|
+
r"|(?<![\w*])\*([^*\n]+)\*(?![\w*])"
|
|
227
|
+
r"|~~([^~]+)~~"
|
|
228
|
+
r"|(https?://[^\s<>()\[\]]+)"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _text_node(text, marks=None):
|
|
233
|
+
n = {"type": "text", "text": text}
|
|
234
|
+
if marks:
|
|
235
|
+
n["marks"] = marks
|
|
236
|
+
return n
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _inline_nodes(text: str) -> list:
|
|
240
|
+
nodes: list = []
|
|
241
|
+
pos = 0
|
|
242
|
+
for m in _INLINE.finditer(text):
|
|
243
|
+
if m.start() > pos:
|
|
244
|
+
nodes.append(_text_node(text[pos:m.start()]))
|
|
245
|
+
if m.group(1) is not None: # link
|
|
246
|
+
nodes.append(_text_node(m.group(1),
|
|
247
|
+
[{"type": "link", "attrs": {"href": m.group(2)}}]))
|
|
248
|
+
elif m.group(3) is not None: # bold+italic
|
|
249
|
+
nodes.append(_text_node(m.group(3), [{"type": "strong"}, {"type": "em"}]))
|
|
250
|
+
elif m.group(4) is not None: # bold
|
|
251
|
+
nodes.append(_text_node(m.group(4), [{"type": "strong"}]))
|
|
252
|
+
elif m.group(5) is not None: # code
|
|
253
|
+
nodes.append(_text_node(m.group(5), [{"type": "code"}]))
|
|
254
|
+
elif m.group(6) is not None: # italic
|
|
255
|
+
nodes.append(_text_node(m.group(6), [{"type": "em"}]))
|
|
256
|
+
elif m.group(7) is not None: # strike
|
|
257
|
+
nodes.append(_text_node(m.group(7), [{"type": "strikethrough"}]))
|
|
258
|
+
elif m.group(8) is not None: # bare url
|
|
259
|
+
url = m.group(8)
|
|
260
|
+
nodes.append(_text_node(url, [{"type": "link", "attrs": {"href": url}}]))
|
|
261
|
+
pos = m.end()
|
|
262
|
+
if pos < len(text):
|
|
263
|
+
nodes.append(_text_node(text[pos:]))
|
|
264
|
+
return nodes or [_text_node("")]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _para(text: str) -> dict:
|
|
268
|
+
return {"type": "paragraph", "content": _inline_nodes(text)}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def markdown_to_doc(md: str) -> dict:
|
|
272
|
+
"""Convert Markdown → a Substack ProseMirror ``doc`` node.
|
|
273
|
+
|
|
274
|
+
Supports: ATX headings (#..######), fenced code (```), blockquotes (>),
|
|
275
|
+
bullet/ordered lists, horizontal rules (---/***/___), and paragraphs with
|
|
276
|
+
inline bold/italic/code/strike/links.
|
|
277
|
+
"""
|
|
278
|
+
lines = md.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
279
|
+
content: list = []
|
|
280
|
+
i, n = 0, len(lines)
|
|
281
|
+
while i < n:
|
|
282
|
+
line = lines[i]
|
|
283
|
+
stripped = line.strip()
|
|
284
|
+
|
|
285
|
+
if not stripped:
|
|
286
|
+
i += 1
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# fenced code block
|
|
290
|
+
if stripped.startswith("```"):
|
|
291
|
+
lang = stripped[3:].strip()
|
|
292
|
+
buf = []
|
|
293
|
+
i += 1
|
|
294
|
+
while i < n and not lines[i].strip().startswith("```"):
|
|
295
|
+
buf.append(lines[i])
|
|
296
|
+
i += 1
|
|
297
|
+
i += 1 # closing fence
|
|
298
|
+
node = {"type": "code_block",
|
|
299
|
+
"content": [{"type": "text", "text": "\n".join(buf)}] if buf else []}
|
|
300
|
+
if lang:
|
|
301
|
+
node["attrs"] = {"language": lang}
|
|
302
|
+
content.append(node)
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
# heading
|
|
306
|
+
mh = re.match(r"^(#{1,6})\s+(.*)$", stripped)
|
|
307
|
+
if mh:
|
|
308
|
+
content.append({"type": "heading",
|
|
309
|
+
"attrs": {"level": len(mh.group(1))},
|
|
310
|
+
"content": _inline_nodes(mh.group(2).strip())})
|
|
311
|
+
i += 1
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
# horizontal rule
|
|
315
|
+
if re.match(r"^(-{3,}|\*{3,}|_{3,})$", stripped):
|
|
316
|
+
content.append({"type": "horizontal_rule"})
|
|
317
|
+
i += 1
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# blockquote (consecutive > lines)
|
|
321
|
+
if stripped.startswith(">"):
|
|
322
|
+
buf = []
|
|
323
|
+
while i < n and lines[i].strip().startswith(">"):
|
|
324
|
+
buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
|
|
325
|
+
i += 1
|
|
326
|
+
content.append({"type": "blockquote",
|
|
327
|
+
"content": [_para(" ".join(x.strip() for x in buf if x.strip()))]})
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
# bullet / ordered list (consecutive matching items)
|
|
331
|
+
mli = re.match(r"^\s*([-*+]|\d+\.)\s+(.*)$", line)
|
|
332
|
+
if mli:
|
|
333
|
+
ordered = bool(re.match(r"^\s*\d+\.", line))
|
|
334
|
+
items = []
|
|
335
|
+
while i < n:
|
|
336
|
+
m2 = re.match(r"^\s*([-*+]|\d+\.)\s+(.*)$", lines[i])
|
|
337
|
+
if not m2:
|
|
338
|
+
break
|
|
339
|
+
is_ord = bool(re.match(r"^\s*\d+\.", lines[i]))
|
|
340
|
+
if is_ord != ordered:
|
|
341
|
+
break
|
|
342
|
+
items.append({"type": "list_item", "content": [_para(m2.group(2).strip())]})
|
|
343
|
+
i += 1
|
|
344
|
+
content.append({"type": "ordered_list" if ordered else "bullet_list",
|
|
345
|
+
"content": items})
|
|
346
|
+
continue
|
|
347
|
+
|
|
348
|
+
# paragraph (gather until blank / block start)
|
|
349
|
+
buf = [stripped]
|
|
350
|
+
i += 1
|
|
351
|
+
while i < n:
|
|
352
|
+
nxt = lines[i].strip()
|
|
353
|
+
if (not nxt or nxt.startswith(("#", ">", "```"))
|
|
354
|
+
or re.match(r"^(-{3,}|\*{3,}|_{3,})$", nxt)
|
|
355
|
+
or re.match(r"^\s*([-*+]|\d+\.)\s+", lines[i])):
|
|
356
|
+
break
|
|
357
|
+
buf.append(nxt)
|
|
358
|
+
i += 1
|
|
359
|
+
content.append(_para(" ".join(buf)))
|
|
360
|
+
|
|
361
|
+
if not content:
|
|
362
|
+
content = [_para("")]
|
|
363
|
+
return {"type": "doc", "content": content}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ── publish (GATED) ─────────────────────────────────────────────────
|
|
367
|
+
|
|
368
|
+
def _read_content(args) -> str:
|
|
369
|
+
if args.content_file:
|
|
370
|
+
try:
|
|
371
|
+
with open(args.content_file, encoding="utf-8") as f:
|
|
372
|
+
return f.read()
|
|
373
|
+
except OSError as e:
|
|
374
|
+
die(f"cannot read --content-file: {e}")
|
|
375
|
+
if args.content:
|
|
376
|
+
return args.content
|
|
377
|
+
die("provide --content or --content-file")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def cmd_publish(jar, args):
|
|
381
|
+
me = resolve(jar)
|
|
382
|
+
md = _read_content(args)
|
|
383
|
+
doc = markdown_to_doc(md)
|
|
384
|
+
audience = args.audience
|
|
385
|
+
pub_api = me["publication_api"]
|
|
386
|
+
|
|
387
|
+
plan = {
|
|
388
|
+
"action": "publish",
|
|
389
|
+
"dry_run": not CONFIRM,
|
|
390
|
+
"draft_only": args.draft_only,
|
|
391
|
+
"send_email": args.send_email,
|
|
392
|
+
"title": args.title,
|
|
393
|
+
"subtitle": args.subtitle,
|
|
394
|
+
"audience": audience,
|
|
395
|
+
"publication": me["publication_name"],
|
|
396
|
+
"publication_url": me["publication_url"],
|
|
397
|
+
"blocks": len(doc["content"]),
|
|
398
|
+
}
|
|
399
|
+
if not CONFIRM:
|
|
400
|
+
plan["note"] = ("DRY-RUN — re-run with --confirm as the LAST argument to "
|
|
401
|
+
"create the draft. Add --draft-only to stop at a private "
|
|
402
|
+
"draft; omit it to go live. --send-email emails subscribers.")
|
|
403
|
+
out(plan)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
body = {
|
|
407
|
+
"draft_title": args.title,
|
|
408
|
+
"draft_subtitle": args.subtitle or "",
|
|
409
|
+
"draft_body": json.dumps(doc),
|
|
410
|
+
"draft_bylines": [{"id": int(me["user_id"]), "is_guest": False}],
|
|
411
|
+
"audience": audience,
|
|
412
|
+
"write_comment_permissions": audience,
|
|
413
|
+
"section_chosen": False,
|
|
414
|
+
}
|
|
415
|
+
draft = api("POST", f"{pub_api}/drafts", jar, body=body)
|
|
416
|
+
draft_id = draft.get("id") if isinstance(draft, dict) else None
|
|
417
|
+
if not draft_id:
|
|
418
|
+
die(f"draft create returned no id: {str(draft)[:200]}")
|
|
419
|
+
|
|
420
|
+
if args.draft_only:
|
|
421
|
+
out({**plan, "status": "draft_created", "draft_id": draft_id,
|
|
422
|
+
"edit_url": f"{me['publication_url']}/publish/post/{draft_id}"})
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
# prepublish validation, then publish
|
|
426
|
+
api("GET", f"{pub_api}/drafts/{draft_id}/prepublish", jar)
|
|
427
|
+
pub = api("POST", f"{pub_api}/drafts/{draft_id}/publish", jar,
|
|
428
|
+
body={"send": bool(args.send_email), "share_automatically": False})
|
|
429
|
+
slug = (pub or {}).get("slug") if isinstance(pub, dict) else None
|
|
430
|
+
out({**plan, "status": "published", "draft_id": draft_id,
|
|
431
|
+
"post_id": (pub or {}).get("id") if isinstance(pub, dict) else None,
|
|
432
|
+
"url": f"{me['publication_url']}/p/{slug}" if slug else me["publication_url"],
|
|
433
|
+
"emailed_subscribers": bool(args.send_email)})
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ── CLI ─────────────────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
def main():
|
|
439
|
+
p = argparse.ArgumentParser(description="Substack BYOC CLI")
|
|
440
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
441
|
+
|
|
442
|
+
sub.add_parser("whoami", help="who is logged in + primary publication")
|
|
443
|
+
|
|
444
|
+
pa = sub.add_parser("articles", help="my published posts + stats")
|
|
445
|
+
pa.add_argument("--limit", type=int, default=20)
|
|
446
|
+
|
|
447
|
+
pp = sub.add_parser("publish", help="publish a post (GATED by --confirm)")
|
|
448
|
+
pp.add_argument("--title", required=True)
|
|
449
|
+
pp.add_argument("--subtitle", default="")
|
|
450
|
+
pp.add_argument("--content", help="inline markdown body")
|
|
451
|
+
pp.add_argument("--content-file", help="path to a markdown file")
|
|
452
|
+
pp.add_argument("--audience", default="everyone",
|
|
453
|
+
choices=["everyone", "only_paid", "founding", "only_free"])
|
|
454
|
+
pp.add_argument("--draft-only", action="store_true",
|
|
455
|
+
help="stop at a private draft (recommended default)")
|
|
456
|
+
pp.add_argument("--send-email", action="store_true",
|
|
457
|
+
help="also email subscribers (off by default)")
|
|
458
|
+
|
|
459
|
+
args = p.parse_args(ARGV)
|
|
460
|
+
jar = load_cookies()
|
|
461
|
+
{"whoami": cmd_whoami, "articles": cmd_articles, "publish": cmd_publish}[args.cmd](jar, args)
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
if __name__ == "__main__":
|
|
465
|
+
main()
|