@acedatacloud/skills 2026.703.7 → 2026.703.9
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/substack/SKILL.md +95 -0
- package/skills/substack/scripts/substack.py +465 -0
- package/skills/tgstat/SKILL.md +110 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@acedatacloud/skills",
|
|
3
|
-
"version": "2026.703.
|
|
3
|
+
"version": "2026.703.9",
|
|
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,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()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: tgstat
|
|
3
|
+
description: Search Telegram channels/chats and pull audience + engagement stats (subscribers, reach, ER/ERR) from TGStat via its Stat API. Use when the user wants to find Telegram channels to advertise in (esp. Russian-language / RU market), research competitor channels, or check a channel's reach/engagement before buying an ad placement.
|
|
4
|
+
when_to_use: |
|
|
5
|
+
Trigger when the user wants to discover Telegram channels by keyword /
|
|
6
|
+
category / country / language, inspect a specific channel's subscriber
|
|
7
|
+
count, average post reach and engagement (ER / ERR) to judge ad value,
|
|
8
|
+
or check their TGStat API quota. This is a marketing-research /
|
|
9
|
+
ad-channel-selection tool — it does NOT post to Telegram (use the
|
|
10
|
+
`telegram` connector for reading/sending messages).
|
|
11
|
+
connections: [tgstat]
|
|
12
|
+
allowed_tools: [Bash]
|
|
13
|
+
license: Apache-2.0
|
|
14
|
+
metadata:
|
|
15
|
+
author: acedatacloud
|
|
16
|
+
version: "1.0"
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
Call the **TGStat Stat API** with `curl + jq`. The user's token is in
|
|
20
|
+
`$TGSTAT_TOKEN` and is passed as the `token` query parameter on every request.
|
|
21
|
+
Base URL: `https://api.tgstat.ru`.
|
|
22
|
+
|
|
23
|
+
Responses are JSON shaped `{"status":"ok","response": ...}`. Errors come back as
|
|
24
|
+
`{"status":"error","error":"<message>"}` — show the `error` verbatim. An invalid
|
|
25
|
+
token or an inactive/expired API plan will surface here; tell the user to check
|
|
26
|
+
their token / plan in the TGStat 个人中心 and re-connect the connector.
|
|
27
|
+
|
|
28
|
+
**Always confirm the token + remaining quota first** (`usage/stat` is free and
|
|
29
|
+
does not count against tariff quota):
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
curl -sS "https://api.tgstat.ru/usage/stat?token=$TGSTAT_TOKEN" \
|
|
33
|
+
| jq '.status, (.response[]? | {serviceKey, title, spentChannels, spentRequests, expiredAt})'
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Find channels to advertise in (the main workflow)
|
|
37
|
+
|
|
38
|
+
`GET /channels/search` — at least one of `q` (keyword, min 3 chars) or
|
|
39
|
+
`category` is required.
|
|
40
|
+
|
|
41
|
+
Params: `q`, `category`, `country`, `language` (default `russian`),
|
|
42
|
+
`peer_type` (`channel` | `chat` | `all`, default `channel`),
|
|
43
|
+
`search_by_description` (`0`/`1`), `limit` (max 100).
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Russian-language AI/ChatGPT channels, biggest first.
|
|
47
|
+
curl -sS "https://api.tgstat.ru/channels/search" \
|
|
48
|
+
--data-urlencode "token=$TGSTAT_TOKEN" \
|
|
49
|
+
--data-urlencode "q=нейросети" \
|
|
50
|
+
--data-urlencode "language=russian" \
|
|
51
|
+
--data-urlencode "peer_type=channel" \
|
|
52
|
+
--data-urlencode "limit=50" -G \
|
|
53
|
+
| jq '.response.items | sort_by(-.participants_count)
|
|
54
|
+
| .[] | {username, title, subs: .participants_count, ci_index, link}'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- Use `--data-urlencode ... -G` so Cyrillic / spaces in `q` are encoded correctly.
|
|
58
|
+
- `ci_index` (индекс цитирования) is TGStat's citation/authority score — higher =
|
|
59
|
+
more reposted/mentioned elsewhere, a useful quality signal beyond raw subs.
|
|
60
|
+
- Try several keywords (`ChatGPT`, `нейросети`, `AI`, `разработка`, `API`) and
|
|
61
|
+
merge results; TGStat matches title/username (add `search_by_description=1` to
|
|
62
|
+
also match the channel description).
|
|
63
|
+
|
|
64
|
+
## Judge a channel's ad value
|
|
65
|
+
|
|
66
|
+
`GET /channels/stat?channelId=<@username | t.me/username | tgstat id>` returns
|
|
67
|
+
the numbers that actually matter for ad pricing:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
curl -sS "https://api.tgstat.ru/channels/stat?token=$TGSTAT_TOKEN&channelId=@durov" \
|
|
71
|
+
| jq '.response | {
|
|
72
|
+
subs: .participants_count,
|
|
73
|
+
avg_post_reach, # средний охват публикации
|
|
74
|
+
adv_reach_24h: .adv_post_reach_24h, # рекламный охват за 24ч — key for CPM
|
|
75
|
+
er_percent, err_percent, err24_percent,
|
|
76
|
+
daily_reach, ci_index, posts_count
|
|
77
|
+
}'
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- For **ad CPM estimation** use `adv_post_reach_24h` (average *advertising* reach
|
|
81
|
+
of a post over 24h), not raw subscriber count — subs are vanity, reach is what
|
|
82
|
+
the ad actually gets seen by.
|
|
83
|
+
- Low `err_percent` / `err24_percent` relative to subs = inflated/dead audience →
|
|
84
|
+
skip it.
|
|
85
|
+
- For a **chat** (not channel) the response instead has `dau`/`wau`/`mau` and
|
|
86
|
+
`messages_count_*` fields.
|
|
87
|
+
|
|
88
|
+
`GET /channels/get?channelId=...` returns descriptive info (title, about,
|
|
89
|
+
`category`, `country`, `language`, `participants_count`, `ci_index`) when you
|
|
90
|
+
just need to identify/verify a channel rather than full stats.
|
|
91
|
+
|
|
92
|
+
## Reference data
|
|
93
|
+
|
|
94
|
+
`GET /database/categories?token=$TGSTAT_TOKEN` lists valid `category` values you
|
|
95
|
+
can pass to `channels/search`. There are also `/database/countries` and
|
|
96
|
+
`/database/languages`. (See the docs for the full parameter/response shape.)
|
|
97
|
+
|
|
98
|
+
## Gotchas
|
|
99
|
+
|
|
100
|
+
- **Plan gating:** `channels/search` needs a **Stat API tariff S or higher**;
|
|
101
|
+
`channels/stat` / `channels/get` work on all Stat tariffs. If a call returns an
|
|
102
|
+
access error, the user's plan doesn't cover that method.
|
|
103
|
+
- **Quota:** each unique channel and each request counts against the monthly
|
|
104
|
+
tariff (visible via `usage/stat`). Don't loop over hundreds of channels
|
|
105
|
+
blindly — search, shortlist, then `stat` only the shortlist.
|
|
106
|
+
- **`q` min length is 3 chars** → `{"error":"param q is too short"}`.
|
|
107
|
+
- **Cyrillic:** always send `q` via `--data-urlencode` (or pre-URL-encode) so the
|
|
108
|
+
keyword isn't mangled.
|
|
109
|
+
- TGStat is a Russian service; the API and billing are RU-side — availability and
|
|
110
|
+
payment are the account owner's responsibility.
|