@vornrun/connector-substack 0.1.0 → 0.2.1
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/CHANGELOG.md +29 -0
- package/README.md +37 -9
- package/dist/index.js +398 -28
- package/package.json +5 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@vornrun/connector-substack`.
|
|
4
4
|
|
|
5
|
+
## 0.2.1
|
|
6
|
+
|
|
7
|
+
Speaks Vorn's own connector protocol instead of MCP, so it needs Vorn 0.7.1-beta.3 or later. Action arguments arrive as typed values.
|
|
8
|
+
|
|
9
|
+
## 0.2.0
|
|
10
|
+
|
|
11
|
+
Adds the whole archive and a single post's body, so a workflow can check what
|
|
12
|
+
a publication already said, plus Notes, likes and restacks on posts, updating
|
|
13
|
+
a draft, and the subscriber count.
|
|
14
|
+
|
|
15
|
+
- **Actions:** `listPosts` (the archive, newest first, up to 500), `getPost`
|
|
16
|
+
(one post's full body as HTML and text), `updateDraft` (replace a draft's
|
|
17
|
+
title, subtitle and body; a published post is refused), `postNote`,
|
|
18
|
+
`readNotes`, `deleteNote`, `setPostLike`, `setPostRestack` (posts only) and
|
|
19
|
+
`readSubscriberCount`.
|
|
20
|
+
- **The publishing guard** still refuses every request path containing
|
|
21
|
+
`publish` or `schedule`, with one exception: a `GET` to
|
|
22
|
+
`/api/v1/publish-dashboard/`, the dashboard's read-only figures. Nothing
|
|
23
|
+
here publishes or schedules.
|
|
24
|
+
|
|
25
|
+
Each new request was made from a signed-in browser on 2026-09-12 against the
|
|
26
|
+
author's own publication, and then every new action ran live through the built
|
|
27
|
+
package against it, with every write undone: a draft saved, updated and
|
|
28
|
+
deleted, a Note posted and deleted, a like and a restack each made and taken
|
|
29
|
+
back. `updateDraft` changed the draft's title, subtitle and body as sent.
|
|
30
|
+
`readSubscriberCount` reports the dashboard's total as `subscribers`, from the
|
|
31
|
+
summary's `totalEmail`, and the summary's own `subscribers`, which counts paid
|
|
32
|
+
subscribers only, as `paidSubscribers`.
|
|
33
|
+
|
|
5
34
|
## 0.1.0
|
|
6
35
|
|
|
7
36
|
First release.
|
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# @vornrun/connector-substack
|
|
2
2
|
|
|
3
3
|
Trigger Vorn workflows from new posts on a Substack publication, and read a
|
|
4
|
-
feed, search posts, read comments, save
|
|
5
|
-
|
|
4
|
+
feed or the whole archive, search and read posts, read comments, save and
|
|
5
|
+
update drafts, comment, like and restack posts, and post and read Notes from a
|
|
6
|
+
workflow step. Substack publishes no API for any of this, so
|
|
6
7
|
the connector reads the public RSS feed and asks the same web endpoints
|
|
7
8
|
Substack's own pages use, from inside a window you signed in to.
|
|
8
9
|
|
|
@@ -22,22 +23,24 @@ When Substack signs the window out, a workflow step that needed it waits as
|
|
|
22
23
|
"waiting for sign-in" until you sign in again, then runs again. When Vorn is
|
|
23
24
|
closed on the desktop that signed in, the step fails and says to open it.
|
|
24
25
|
|
|
25
|
-
Reading a feed, searching posts
|
|
26
|
-
need no sign-in.
|
|
26
|
+
Reading a feed or the archive, searching posts, reading one post and reading
|
|
27
|
+
the comments on a public post need no sign-in.
|
|
27
28
|
|
|
28
29
|
## Settings
|
|
29
30
|
|
|
30
31
|
| Setting | Env | What it is |
|
|
31
32
|
| --- | --- | --- |
|
|
32
|
-
| `publication` | `SUBSTACK_PUBLICATION` | Your publication's substack.com subdomain, such as `
|
|
33
|
+
| `publication` | `SUBSTACK_PUBLICATION` | Your publication's substack.com subdomain, such as `exampleletter`. The `newPost` trigger reads its feed, and a step that names no publication uses it. When it is empty, signed-in actions use the account's primary publication. |
|
|
33
34
|
|
|
34
35
|
## It never publishes
|
|
35
36
|
|
|
36
37
|
Publishing a post emails every subscriber, so nothing here publishes or
|
|
37
38
|
schedules. `createDraft` saves a draft and returns the address to open it in
|
|
38
39
|
the editor, where you publish it yourself. Any request path containing
|
|
39
|
-
`publish` or `schedule` is refused before it leaves, and `deleteDraft`
|
|
40
|
-
the draft first and
|
|
40
|
+
`publish` or `schedule` is refused before it leaves, and `deleteDraft` and
|
|
41
|
+
`updateDraft` read the draft first and refuse a published post. The one
|
|
42
|
+
exception to the guard is a `GET` to `/api/v1/publish-dashboard/`, the
|
|
43
|
+
dashboard's read-only figures that `readSubscriberCount` reads.
|
|
41
44
|
|
|
42
45
|
## Substack's terms
|
|
43
46
|
|
|
@@ -64,11 +67,20 @@ for templates.
|
|
|
64
67
|
| `readFeed` | yes | no | `GET https://<publication>/feed`, for any publication, a custom domain included. `limit` from 1 to 20, default 10. Returns `publication`, `count` and `posts` (`id`, `title`, `subtitle`, `url`, `author`, `publishedAt`, `html`, `text`). |
|
|
65
68
|
| `searchPosts` | yes | no | `GET substack.com/api/v1/post/search` with `query` and `page` (from 0). Returns `count`, `more` and `posts` (`id`, `title`, `subtitle`, `url`, `publishedAt`, `author`, `authorHandle`, `likes`, `comments`, `publicationId`). |
|
|
66
69
|
| `readComments` | yes | no | Looks the post up by address or slug (`GET /api/v1/posts/<slug>`) unless given `postId`, then `GET /api/v1/post/<id>/comments?all_comments=true&sort=newest_first`. Returns `postId`, `count` and `comments` (`id`, `body`, `author`, `handle`, `date`, `likes`, `parentId`, `replies`), each reply after the comment it answers. |
|
|
70
|
+
| `listPosts` | yes | no | `GET /api/v1/archive?sort=new&limit=25&offset=<n>` on the publication, a page at a time until a page comes back short or `limit` is reached (1 to 500, default 50). Returns `publication`, `count` and `posts` (`id`, `title`, `subtitle`, `slug`, `url`, `publishedAt`, `audience`). |
|
|
71
|
+
| `getPost` | yes | no | `GET /api/v1/posts/<slug>` for the post given by address, or by slug on the publication. Returns the fields `listPosts` gives plus `wordcount`, `html`, `text`, `likes` and `restacks`. |
|
|
67
72
|
| `createDraft` | no | yes | `GET substack.com/api/v1/user/profile/self` for the byline, then `POST /api/v1/drafts` on the publication with the markdown body converted to the editor's document. Returns `id`, `title` and `editUrl`. |
|
|
73
|
+
| `updateDraft` | no | yes | `GET substack.com/api/v1/user/profile/self` for the byline, `GET /api/v1/drafts/<id>` to refuse a published post, then `PUT /api/v1/drafts/<id>` with the new title, subtitle and markdown body. Returns `updated`, `id`, `title` and `editUrl`. |
|
|
68
74
|
| `deleteDraft` | no | yes | `GET`, then `DELETE /api/v1/drafts/<id>`; a published post is refused. Returns `deleted`. |
|
|
69
75
|
| `commentOnPost` | no | yes | `POST /api/v1/post/<id>/comment` with `{ body }`. Returns `id` and `postId`. |
|
|
70
76
|
| `setCommentLike` | yes | yes | `POST` to like or `DELETE` to unlike `/api/v1/comment/<id>/reaction`, with `{ reaction: "❤" }`. Returns `liked`. |
|
|
71
77
|
| `deleteComment` | no | yes | `DELETE /api/v1/comment/<id>`. Returns `deleted`. |
|
|
78
|
+
| `setPostLike` | no | yes | `POST` to like or `DELETE` to unlike `/api/v1/post/<id>/reaction`, with `{ reaction: "❤" }`. Returns `postId` and `liked`. |
|
|
79
|
+
| `setPostRestack` | no | yes | `POST` to restack or `DELETE` to undo `/api/v1/restack/feed`, with `{ postId, commentId: null }`. Posts only. Returns `postId` and `restacked`. |
|
|
80
|
+
| `postNote` | no | yes | `GET substack.com/api/v1/user/profile/self` for the handle, then `POST substack.com/api/v1/comment/feed` with the markdown as the editor's document under `attrs.schemaVersion: "v1"`, plus `tabId: "for-you"`, `surface: "feed"` and `replyMinimumRole: "everyone"`. Returns `id` and `url`. |
|
|
81
|
+
| `readNotes` | yes | yes | Resolves a handle with `GET substack.com/api/v1/user/<handle>/public_profile`, or takes the signed-in account, then follows `nextCursor` through `GET substack.com/api/v1/reader/feed/profile/<userId>` for up to ten pages, keeping the items that are Notes. `limit` from 1 to 100, default 20. Returns `profile`, `count` and `notes` (`id`, `body`, `url`, `date`, `likes`, `restacks`). |
|
|
82
|
+
| `deleteNote` | no | yes | `DELETE substack.com/api/v1/comment/<id>`. Returns `deleted`. |
|
|
83
|
+
| `readSubscriberCount` | yes | yes | `GET /api/v1/publish-dashboard/summary` on your publication. Returns `subscribers` (every subscriber, free and paid, from the summary's `totalEmail`, the count the dashboard shows), `paidSubscribers` (from the summary's `subscribers`, which counts paid ones only), `appSubscribers`, `views` and `openRate`, as the summary reports them. |
|
|
72
84
|
|
|
73
85
|
Writes stay on `*.substack.com`. A publication on a custom domain can be read
|
|
74
86
|
at its own address, but a comment on one of its posts is refused; write to it
|
|
@@ -97,8 +109,8 @@ check skips.
|
|
|
97
109
|
## Built from
|
|
98
110
|
|
|
99
111
|
Substack publishes no API reference. Every request here was made from a
|
|
100
|
-
signed-in browser
|
|
101
|
-
|
|
112
|
+
signed-in browser against the author's own publication, and each write was
|
|
113
|
+
undone. On 2026-09-10:
|
|
102
114
|
|
|
103
115
|
- `GET substack.com/api/v1/user/profile/self` answers 200 with `id`, `name`,
|
|
104
116
|
`handle` and `publicationUsers` when signed in, and 401 when not.
|
|
@@ -108,3 +120,19 @@ each write was undone.
|
|
|
108
120
|
- `POST /api/v1/drafts` answers 400 `draft_bylines Invalid value` without a
|
|
109
121
|
byline and 200 with one. A deleted draft then reads back 404.
|
|
110
122
|
- Commenting, liking and deleting a comment are the requests listed above.
|
|
123
|
+
|
|
124
|
+
On 2026-09-12:
|
|
125
|
+
|
|
126
|
+
- The archive pages with `offset`, and `/api/v1/posts/<slug>` carries the
|
|
127
|
+
whole `body_html`, `reactions` and `restacks`.
|
|
128
|
+
- A Note posted with `POST /api/v1/comment/feed` showed on the profile feed as
|
|
129
|
+
a `comment` item whose `context.type` is `note`, and
|
|
130
|
+
`DELETE substack.com/api/v1/comment/<id>` removed it.
|
|
131
|
+
- Liking a post and restacking it, then taking both back, are the requests
|
|
132
|
+
listed above; the post read back unliked and unrestacked.
|
|
133
|
+
- The dashboard reads its figures with `GET /api/v1/publish-dashboard/summary`.
|
|
134
|
+
Its `subscribers` is the paid count (0 on the author's publication) and its
|
|
135
|
+
`totalEmail` the total (29, the dashboard's "29 subscribers").
|
|
136
|
+
- Then all nine new actions ran through the built package against the same
|
|
137
|
+
publication, and everything was put back. `updateDraft`'s `PUT` changed the
|
|
138
|
+
draft's title, subtitle and body as sent.
|
package/dist/index.js
CHANGED
|
@@ -1465,7 +1465,7 @@ function markdownToDoc(markdown) {
|
|
|
1465
1465
|
var SUBDOMAIN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
|
|
1466
1466
|
function publicationHost(value) {
|
|
1467
1467
|
const raw = String(value ?? "").trim();
|
|
1468
|
-
if (raw === "") throw new Error('publication is required: its substack.com subdomain, such as "
|
|
1468
|
+
if (raw === "") throw new Error('publication is required: its substack.com subdomain, such as "exampleletter"');
|
|
1469
1469
|
if (SUBDOMAIN.test(raw)) return `${raw.toLowerCase()}.substack.com`;
|
|
1470
1470
|
try {
|
|
1471
1471
|
return new URL(raw.includes("://") ? raw : `https://${raw}`).hostname.toLowerCase();
|
|
@@ -1477,7 +1477,7 @@ function substackHost(value) {
|
|
|
1477
1477
|
const host = publicationHost(value);
|
|
1478
1478
|
if (!host.endsWith(".substack.com")) {
|
|
1479
1479
|
throw new Error(
|
|
1480
|
-
`${host} is a custom domain; use the publication's substack.com address instead, such as
|
|
1480
|
+
`${host} is a custom domain; use the publication's substack.com address instead, such as exampleletter.substack.com`
|
|
1481
1481
|
);
|
|
1482
1482
|
}
|
|
1483
1483
|
return host;
|
|
@@ -1485,7 +1485,7 @@ function substackHost(value) {
|
|
|
1485
1485
|
function postRef(value) {
|
|
1486
1486
|
const raw = String(value ?? "").trim();
|
|
1487
1487
|
const notAPost = new Error(
|
|
1488
|
-
`post must be a post's address, such as https://
|
|
1488
|
+
`post must be a post's address, such as https://exampleletter.substack.com/p/its-slug; got "${raw}"`
|
|
1489
1489
|
);
|
|
1490
1490
|
let url;
|
|
1491
1491
|
try {
|
|
@@ -1501,8 +1501,8 @@ function postRef(value) {
|
|
|
1501
1501
|
// package.json
|
|
1502
1502
|
var package_default = {
|
|
1503
1503
|
name: "@vornrun/connector-substack",
|
|
1504
|
-
version: "0.1
|
|
1505
|
-
description: "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
|
|
1504
|
+
version: "0.2.1",
|
|
1505
|
+
description: "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
|
|
1506
1506
|
type: "module",
|
|
1507
1507
|
license: "MIT",
|
|
1508
1508
|
author: "Javier Canizalez <javier-canizalez@outlook.com>",
|
|
@@ -1515,8 +1515,7 @@ var package_default = {
|
|
|
1515
1515
|
"vorn",
|
|
1516
1516
|
"connector",
|
|
1517
1517
|
"substack",
|
|
1518
|
-
"newsletter"
|
|
1519
|
-
"mcp"
|
|
1518
|
+
"newsletter"
|
|
1520
1519
|
],
|
|
1521
1520
|
bin: {
|
|
1522
1521
|
"vorn-connector-substack": "dist/index.js"
|
|
@@ -1540,7 +1539,7 @@ var package_default = {
|
|
|
1540
1539
|
test: "vitest run"
|
|
1541
1540
|
},
|
|
1542
1541
|
dependencies: {
|
|
1543
|
-
"@vornrun/connector-sdk": "^0.7.
|
|
1542
|
+
"@vornrun/connector-sdk": "^0.7.1-beta.3"
|
|
1544
1543
|
},
|
|
1545
1544
|
devDependencies: {
|
|
1546
1545
|
"@types/node": "^22.10.2",
|
|
@@ -1558,6 +1557,7 @@ var package_default = {
|
|
|
1558
1557
|
"posts",
|
|
1559
1558
|
"comments",
|
|
1560
1559
|
"drafts",
|
|
1560
|
+
"notes",
|
|
1561
1561
|
"writing"
|
|
1562
1562
|
],
|
|
1563
1563
|
auth: "Sign in to Substack in the window Vorn opens; reading a publication's feed and searching posts need no sign-in.",
|
|
@@ -1569,11 +1569,20 @@ var package_default = {
|
|
|
1569
1569
|
var ORIGINS = ["https://substack.com", "https://*.substack.com"];
|
|
1570
1570
|
var PROFILE_URL = "https://substack.com/api/v1/user/profile/self";
|
|
1571
1571
|
var SEARCH_URL = "https://substack.com/api/v1/post/search";
|
|
1572
|
+
var NOTES_URL = "https://substack.com/api/v1/comment/feed";
|
|
1572
1573
|
var MAX_FEED_POSTS = 20;
|
|
1573
1574
|
var DEFAULT_FEED_POSTS = 10;
|
|
1575
|
+
var MAX_POSTS = 500;
|
|
1576
|
+
var DEFAULT_POSTS = 50;
|
|
1577
|
+
var MAX_NOTES = 100;
|
|
1578
|
+
var DEFAULT_NOTES = 20;
|
|
1579
|
+
var ARCHIVE_PAGE = 25;
|
|
1580
|
+
var NOTE_PAGES = 10;
|
|
1574
1581
|
var REACTION = "\u2764";
|
|
1575
1582
|
var SLUG = /^[a-z0-9][a-z0-9-]*$/i;
|
|
1583
|
+
var HANDLE = /^[\w.-]+$/;
|
|
1576
1584
|
var NEVER = /publish|schedul/i;
|
|
1585
|
+
var DASHBOARD = /^\/api\/v1\/publish-dashboard\//;
|
|
1577
1586
|
function text2(value) {
|
|
1578
1587
|
const trimmed = String(value ?? "").trim();
|
|
1579
1588
|
return trimmed || void 0;
|
|
@@ -1610,8 +1619,10 @@ async function call(fetchImpl, url, init = {}) {
|
|
|
1610
1619
|
}
|
|
1611
1620
|
function neverPublishing(fetchImpl) {
|
|
1612
1621
|
return ((input, init) => {
|
|
1613
|
-
const
|
|
1614
|
-
|
|
1622
|
+
const request = input instanceof Request ? input : void 0;
|
|
1623
|
+
const { pathname } = new URL(request ? request.url : String(input));
|
|
1624
|
+
const method = (init?.method ?? request?.method ?? "GET").toUpperCase();
|
|
1625
|
+
if (NEVER.test(pathname) && !(method === "GET" && DASHBOARD.test(pathname))) {
|
|
1615
1626
|
return Promise.reject(new Error(`Refused ${pathname}: this connector never publishes or schedules`));
|
|
1616
1627
|
}
|
|
1617
1628
|
return fetchImpl(input, init);
|
|
@@ -1639,15 +1650,18 @@ async function readPosts(fetchImpl, host) {
|
|
|
1639
1650
|
if (!res.ok) throw new Error(`GET /feed on ${host} answered ${res.status}`);
|
|
1640
1651
|
return parseFeed(await res.text());
|
|
1641
1652
|
}
|
|
1642
|
-
function
|
|
1653
|
+
function countLimit(value, fallback, max) {
|
|
1643
1654
|
const raw = text2(value);
|
|
1644
|
-
if (raw === void 0) return
|
|
1655
|
+
if (raw === void 0) return fallback;
|
|
1645
1656
|
const parsed = Number(raw);
|
|
1646
|
-
if (!Number.isInteger(parsed) || parsed < 1 || parsed >
|
|
1647
|
-
throw new Error(`limit must be a whole number from 1 to ${
|
|
1657
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > max) {
|
|
1658
|
+
throw new Error(`limit must be a whole number from 1 to ${max}`);
|
|
1648
1659
|
}
|
|
1649
1660
|
return parsed;
|
|
1650
1661
|
}
|
|
1662
|
+
function feedLimit(value) {
|
|
1663
|
+
return countLimit(value, DEFAULT_FEED_POSTS, MAX_FEED_POSTS);
|
|
1664
|
+
}
|
|
1651
1665
|
function wholeId(value, name) {
|
|
1652
1666
|
const parsed = Number(text2(value));
|
|
1653
1667
|
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${name} must be a Substack id, a whole number`);
|
|
@@ -1710,11 +1724,54 @@ function flattenComments(comments) {
|
|
|
1710
1724
|
return [record, ...flattenComments(comment.children)];
|
|
1711
1725
|
});
|
|
1712
1726
|
}
|
|
1727
|
+
function listedPost(post) {
|
|
1728
|
+
return {
|
|
1729
|
+
id: Number(post.id ?? 0),
|
|
1730
|
+
title: String(post.title ?? ""),
|
|
1731
|
+
subtitle: String(post.subtitle ?? ""),
|
|
1732
|
+
slug: String(post.slug ?? ""),
|
|
1733
|
+
url: String(post.canonical_url ?? ""),
|
|
1734
|
+
publishedAt: String(post.post_date ?? ""),
|
|
1735
|
+
audience: String(post.audience ?? "")
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
function likesOf(entry) {
|
|
1739
|
+
return Number(entry.reaction_count ?? entry.reactions?.[REACTION] ?? 0);
|
|
1740
|
+
}
|
|
1741
|
+
function noteUrl(handle, id) {
|
|
1742
|
+
return `https://substack.com/@${handle}/note/c-${id}`;
|
|
1743
|
+
}
|
|
1744
|
+
function noteRecord(note, handle) {
|
|
1745
|
+
const id = Number(note.id ?? 0);
|
|
1746
|
+
return {
|
|
1747
|
+
id,
|
|
1748
|
+
body: String(note.body ?? ""),
|
|
1749
|
+
url: noteUrl(text2(note.handle) ?? handle, id),
|
|
1750
|
+
date: String(note.date ?? ""),
|
|
1751
|
+
likes: likesOf(note),
|
|
1752
|
+
restacks: Number(note.restacks ?? 0)
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
async function unpublishedDraft(session, host, id, verb) {
|
|
1756
|
+
const draft = await call(session.fetch, `https://${host}/api/v1/drafts/${id}`);
|
|
1757
|
+
if (draft.is_published === true) {
|
|
1758
|
+
throw new Error(`${id} is a published post, not a draft; this action ${verb} only drafts`);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function draftFields(me2, title, subtitle, body) {
|
|
1762
|
+
return {
|
|
1763
|
+
draft_title: title,
|
|
1764
|
+
draft_subtitle: text2(subtitle) ?? "",
|
|
1765
|
+
draft_body: JSON.stringify(markdownToDoc(body)),
|
|
1766
|
+
// Substack refuses a draft without a byline and says so; only a profile with no id leaves it out.
|
|
1767
|
+
...typeof me2.id === "number" && { draft_bylines: [{ id: me2.id, is_guest: false }] }
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1713
1770
|
var SAMPLE_POST = {
|
|
1714
|
-
id: "https://
|
|
1715
|
-
title: "
|
|
1771
|
+
id: "https://exampleletter.substack.com/p/the-weekly-letter",
|
|
1772
|
+
title: "A weekly letter about the week in software.",
|
|
1716
1773
|
subtitle: "Both moves are really about one question: who can pay for compute.",
|
|
1717
|
-
url: "https://
|
|
1774
|
+
url: "https://exampleletter.substack.com/p/the-weekly-letter",
|
|
1718
1775
|
author: "Javier Canizalez",
|
|
1719
1776
|
publishedAt: "2026-05-29T20:03:32.000Z",
|
|
1720
1777
|
html: "<p>Both moves are really about one question.</p>",
|
|
@@ -1750,7 +1807,7 @@ var connector = defineConnector({
|
|
|
1750
1807
|
id: "substack",
|
|
1751
1808
|
name: "Substack",
|
|
1752
1809
|
version: package_default.version,
|
|
1753
|
-
description: "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
|
|
1810
|
+
description: "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
|
|
1754
1811
|
icon: {
|
|
1755
1812
|
viewBox: "0 0 24 24",
|
|
1756
1813
|
paths: [
|
|
@@ -1770,7 +1827,7 @@ var connector = defineConnector({
|
|
|
1770
1827
|
key: "publication",
|
|
1771
1828
|
label: "Publication",
|
|
1772
1829
|
env: "SUBSTACK_PUBLICATION",
|
|
1773
|
-
description: "Your publication's substack.com subdomain, such as
|
|
1830
|
+
description: "Your publication's substack.com subdomain, such as exampleletter. The new-post trigger reads its feed, and a step that names no publication uses it.",
|
|
1774
1831
|
builderHint: "The part before .substack.com in the publication address; a custom domain works for reading only."
|
|
1775
1832
|
}
|
|
1776
1833
|
],
|
|
@@ -1878,6 +1935,79 @@ var connector = defineConnector({
|
|
|
1878
1935
|
return { postId: id, count: comments.length, comments };
|
|
1879
1936
|
}
|
|
1880
1937
|
},
|
|
1938
|
+
{
|
|
1939
|
+
type: "listPosts",
|
|
1940
|
+
label: "List posts",
|
|
1941
|
+
description: `A publication's archive, newest first, up to ${MAX_POSTS} posts, where the feed holds only the last twenty. Needs no sign-in.`,
|
|
1942
|
+
idempotent: true,
|
|
1943
|
+
inputs: [
|
|
1944
|
+
PUBLICATION_INPUT,
|
|
1945
|
+
{
|
|
1946
|
+
key: "limit",
|
|
1947
|
+
label: "Posts",
|
|
1948
|
+
type: "number",
|
|
1949
|
+
description: `How many of the newest posts, from 1 to ${MAX_POSTS}; ${DEFAULT_POSTS} when empty.`
|
|
1950
|
+
}
|
|
1951
|
+
],
|
|
1952
|
+
outputs: [
|
|
1953
|
+
{ key: "publication", type: "string", description: "The host the archive was read from" },
|
|
1954
|
+
{ key: "count", type: "number", description: "How many posts came back, in `posts`" }
|
|
1955
|
+
],
|
|
1956
|
+
run: async (args, ctx) => {
|
|
1957
|
+
const host = publicationHost(stepPublication(args.publication, ctx.config));
|
|
1958
|
+
const limit = countLimit(args.limit, DEFAULT_POSTS, MAX_POSTS);
|
|
1959
|
+
const posts = [];
|
|
1960
|
+
for (let offset = 0; posts.length < limit; offset += ARCHIVE_PAGE) {
|
|
1961
|
+
const page = await call(
|
|
1962
|
+
ctx.fetch,
|
|
1963
|
+
`https://${host}/api/v1/archive?sort=new&limit=${ARCHIVE_PAGE}&offset=${offset}`
|
|
1964
|
+
);
|
|
1965
|
+
const rows = Array.isArray(page) ? page : [];
|
|
1966
|
+
posts.push(...rows);
|
|
1967
|
+
if (rows.length < ARCHIVE_PAGE) break;
|
|
1968
|
+
}
|
|
1969
|
+
const listed = posts.slice(0, limit).map(listedPost);
|
|
1970
|
+
return { publication: host, count: listed.length, posts: listed };
|
|
1971
|
+
}
|
|
1972
|
+
},
|
|
1973
|
+
{
|
|
1974
|
+
type: "getPost",
|
|
1975
|
+
label: "Get post",
|
|
1976
|
+
description: "One post with its whole body as HTML and as text, and its likes and restacks. Needs no sign-in for a public post.",
|
|
1977
|
+
idempotent: true,
|
|
1978
|
+
inputs: [
|
|
1979
|
+
{
|
|
1980
|
+
key: "post",
|
|
1981
|
+
label: "Post",
|
|
1982
|
+
required: true,
|
|
1983
|
+
description: "The post's address, or its slug on the publication."
|
|
1984
|
+
},
|
|
1985
|
+
PUBLICATION_INPUT
|
|
1986
|
+
],
|
|
1987
|
+
outputs: [
|
|
1988
|
+
{ key: "id", type: "number", description: "The post's id" },
|
|
1989
|
+
{ key: "title", type: "string", description: "The post's title" },
|
|
1990
|
+
{ key: "url", type: "string", description: "The post's address" },
|
|
1991
|
+
{ key: "text", type: "string", description: "The body as plain text" }
|
|
1992
|
+
],
|
|
1993
|
+
run: async (args, ctx) => {
|
|
1994
|
+
const raw = text2(args.post);
|
|
1995
|
+
if (raw === void 0) throw new Error("post is required");
|
|
1996
|
+
const host = postHost(raw, stepPublication(args.publication, ctx.config));
|
|
1997
|
+
const slug = raw.includes("/") ? postRef(raw).slug : raw;
|
|
1998
|
+
if (!SLUG.test(slug)) throw new Error(`"${slug}" is not a post slug`);
|
|
1999
|
+
const post = await call(ctx.fetch, `https://${host}/api/v1/posts/${slug}`);
|
|
2000
|
+
const html = String(post.body_html ?? "");
|
|
2001
|
+
return {
|
|
2002
|
+
...listedPost(post),
|
|
2003
|
+
wordcount: Number(post.wordcount ?? 0),
|
|
2004
|
+
html,
|
|
2005
|
+
text: plainText(html),
|
|
2006
|
+
likes: likesOf(post),
|
|
2007
|
+
restacks: Number(post.restacks ?? 0)
|
|
2008
|
+
};
|
|
2009
|
+
}
|
|
2010
|
+
},
|
|
1881
2011
|
{
|
|
1882
2012
|
type: "createDraft",
|
|
1883
2013
|
label: "Save draft",
|
|
@@ -1909,11 +2039,7 @@ var connector = defineConnector({
|
|
|
1909
2039
|
const draft = await call(session.fetch, `https://${host}/api/v1/drafts`, {
|
|
1910
2040
|
method: "POST",
|
|
1911
2041
|
body: {
|
|
1912
|
-
|
|
1913
|
-
draft_subtitle: text2(args.subtitle) ?? "",
|
|
1914
|
-
draft_body: JSON.stringify(markdownToDoc(body)),
|
|
1915
|
-
// Substack refuses a draft without a byline and says so; only a profile with no id leaves it out.
|
|
1916
|
-
...typeof me2.id === "number" && { draft_bylines: [{ id: me2.id, is_guest: false }] },
|
|
2042
|
+
...draftFields(me2, title, args.subtitle, body),
|
|
1917
2043
|
type: "newsletter",
|
|
1918
2044
|
audience: "everyone",
|
|
1919
2045
|
section_chosen: false,
|
|
@@ -1924,6 +2050,51 @@ var connector = defineConnector({
|
|
|
1924
2050
|
return { title, ...id !== void 0 && { id, editUrl: `https://${host}/publish/post/${id}` } };
|
|
1925
2051
|
}
|
|
1926
2052
|
},
|
|
2053
|
+
{
|
|
2054
|
+
type: "updateDraft",
|
|
2055
|
+
label: "Update draft",
|
|
2056
|
+
description: "Replace a draft's title, subtitle and body with new markdown. A published post is refused, and it never publishes.",
|
|
2057
|
+
idempotent: false,
|
|
2058
|
+
inputs: [
|
|
2059
|
+
{
|
|
2060
|
+
key: "draftId",
|
|
2061
|
+
label: "Draft id",
|
|
2062
|
+
type: "number",
|
|
2063
|
+
required: true,
|
|
2064
|
+
description: "The id Save draft returned."
|
|
2065
|
+
},
|
|
2066
|
+
{ key: "title", label: "Title", required: true, description: "The draft's title." },
|
|
2067
|
+
{ key: "subtitle", label: "Subtitle", description: "The line under the title; empty leaves the draft without one." },
|
|
2068
|
+
{
|
|
2069
|
+
key: "body",
|
|
2070
|
+
label: "Body",
|
|
2071
|
+
required: true,
|
|
2072
|
+
description: "The whole post in markdown, replacing what the draft held."
|
|
2073
|
+
},
|
|
2074
|
+
{ ...OWN_PUBLICATION_INPUT, type: "select", loadOptions: "publications" }
|
|
2075
|
+
],
|
|
2076
|
+
outputs: [
|
|
2077
|
+
{ key: "updated", type: "boolean", description: "Whether the draft was saved" },
|
|
2078
|
+
{ key: "id", type: "number", description: "The draft's id" },
|
|
2079
|
+
{ key: "editUrl", type: "string", description: "Where to open the draft in the Substack editor" }
|
|
2080
|
+
],
|
|
2081
|
+
run: async (args, ctx) => {
|
|
2082
|
+
const session = signedIn(ctx.session);
|
|
2083
|
+
const id = wholeId(args.draftId, "draftId");
|
|
2084
|
+
const title = text2(args.title);
|
|
2085
|
+
const body = text2(args.body);
|
|
2086
|
+
if (title === void 0) throw new Error("title is required");
|
|
2087
|
+
if (body === void 0) throw new Error("body is required");
|
|
2088
|
+
const me2 = await call(session.fetch, PROFILE_URL);
|
|
2089
|
+
const host = namedPublication(args.publication, ctx.config) ?? primaryPublication(me2);
|
|
2090
|
+
await unpublishedDraft(session, host, id, "updates");
|
|
2091
|
+
await call(session.fetch, `https://${host}/api/v1/drafts/${id}`, {
|
|
2092
|
+
method: "PUT",
|
|
2093
|
+
body: draftFields(me2, title, args.subtitle, body)
|
|
2094
|
+
});
|
|
2095
|
+
return { updated: true, id, title, editUrl: `https://${host}/publish/post/${id}` };
|
|
2096
|
+
}
|
|
2097
|
+
},
|
|
1927
2098
|
{
|
|
1928
2099
|
type: "deleteDraft",
|
|
1929
2100
|
label: "Delete draft",
|
|
@@ -1944,10 +2115,7 @@ var connector = defineConnector({
|
|
|
1944
2115
|
const session = signedIn(ctx.session);
|
|
1945
2116
|
const id = wholeId(args.draftId, "draftId");
|
|
1946
2117
|
const host = await ownPublication(args.publication, ctx.config, session);
|
|
1947
|
-
|
|
1948
|
-
if (draft.is_published === true) {
|
|
1949
|
-
throw new Error(`${id} is a published post, not a draft; this action deletes only drafts`);
|
|
1950
|
-
}
|
|
2118
|
+
await unpublishedDraft(session, host, id, "deletes");
|
|
1951
2119
|
await call(session.fetch, `https://${host}/api/v1/drafts/${id}`, { method: "DELETE" });
|
|
1952
2120
|
return { deleted: true, id };
|
|
1953
2121
|
}
|
|
@@ -2038,6 +2206,208 @@ var connector = defineConnector({
|
|
|
2038
2206
|
await call(session.fetch, `https://${host}/api/v1/comment/${id}`, { method: "DELETE" });
|
|
2039
2207
|
return { deleted: true, commentId: id };
|
|
2040
2208
|
}
|
|
2209
|
+
},
|
|
2210
|
+
{
|
|
2211
|
+
type: "setPostLike",
|
|
2212
|
+
label: "Like or unlike post",
|
|
2213
|
+
description: "Like a post as the signed-in account, or take the like back.",
|
|
2214
|
+
idempotent: false,
|
|
2215
|
+
inputs: [
|
|
2216
|
+
...POST_INPUTS,
|
|
2217
|
+
{
|
|
2218
|
+
key: "liked",
|
|
2219
|
+
label: "Liked",
|
|
2220
|
+
type: "boolean",
|
|
2221
|
+
required: true,
|
|
2222
|
+
description: "true to like it, false to take the like back."
|
|
2223
|
+
},
|
|
2224
|
+
PUBLICATION_INPUT
|
|
2225
|
+
],
|
|
2226
|
+
outputs: [{ key: "liked", type: "boolean", description: "The state now set" }],
|
|
2227
|
+
run: async (args, ctx) => {
|
|
2228
|
+
const session = signedIn(ctx.session);
|
|
2229
|
+
const liked = args.liked === true;
|
|
2230
|
+
const publication = stepPublication(args.publication, ctx.config);
|
|
2231
|
+
const host = substackHost(postHost(args.post, publication));
|
|
2232
|
+
const { id } = await resolvePost(ctx.fetch, args.post, args.postId, publication);
|
|
2233
|
+
await call(session.fetch, `https://${host}/api/v1/post/${id}/reaction`, {
|
|
2234
|
+
method: liked ? "POST" : "DELETE",
|
|
2235
|
+
body: { reaction: REACTION }
|
|
2236
|
+
});
|
|
2237
|
+
return { postId: id, liked };
|
|
2238
|
+
}
|
|
2239
|
+
},
|
|
2240
|
+
{
|
|
2241
|
+
type: "setPostRestack",
|
|
2242
|
+
label: "Restack or undo restack post",
|
|
2243
|
+
description: "Restack a post to the signed-in account's followers, or take the restack back. Posts only, not Notes.",
|
|
2244
|
+
idempotent: false,
|
|
2245
|
+
inputs: [
|
|
2246
|
+
...POST_INPUTS,
|
|
2247
|
+
{
|
|
2248
|
+
key: "restacked",
|
|
2249
|
+
label: "Restacked",
|
|
2250
|
+
type: "boolean",
|
|
2251
|
+
required: true,
|
|
2252
|
+
description: "true to restack it, false to take the restack back."
|
|
2253
|
+
},
|
|
2254
|
+
PUBLICATION_INPUT
|
|
2255
|
+
],
|
|
2256
|
+
outputs: [{ key: "restacked", type: "boolean", description: "The state now set" }],
|
|
2257
|
+
run: async (args, ctx) => {
|
|
2258
|
+
const session = signedIn(ctx.session);
|
|
2259
|
+
const restacked = args.restacked === true;
|
|
2260
|
+
const publication = stepPublication(args.publication, ctx.config);
|
|
2261
|
+
const host = substackHost(postHost(args.post, publication));
|
|
2262
|
+
const { id } = await resolvePost(ctx.fetch, args.post, args.postId, publication);
|
|
2263
|
+
await call(session.fetch, `https://${host}/api/v1/restack/feed`, {
|
|
2264
|
+
method: restacked ? "POST" : "DELETE",
|
|
2265
|
+
body: { postId: id, commentId: null }
|
|
2266
|
+
});
|
|
2267
|
+
return { postId: id, restacked };
|
|
2268
|
+
}
|
|
2269
|
+
},
|
|
2270
|
+
{
|
|
2271
|
+
type: "postNote",
|
|
2272
|
+
label: "Post a note",
|
|
2273
|
+
description: "Post a Note as the signed-in account. Followers see it in their feeds as soon as it posts; Delete note takes it down.",
|
|
2274
|
+
idempotent: false,
|
|
2275
|
+
inputs: [
|
|
2276
|
+
{
|
|
2277
|
+
key: "body",
|
|
2278
|
+
label: "Note",
|
|
2279
|
+
required: true,
|
|
2280
|
+
description: "The Note in markdown, converted the way a draft's body is."
|
|
2281
|
+
}
|
|
2282
|
+
],
|
|
2283
|
+
outputs: [
|
|
2284
|
+
{ key: "id", type: "number", description: "The Note's id" },
|
|
2285
|
+
{ key: "url", type: "string", description: "The Note's address" }
|
|
2286
|
+
],
|
|
2287
|
+
run: async (args, ctx) => {
|
|
2288
|
+
const session = signedIn(ctx.session);
|
|
2289
|
+
const body = text2(args.body);
|
|
2290
|
+
if (body === void 0) throw new Error("body is required");
|
|
2291
|
+
const me2 = await call(session.fetch, PROFILE_URL);
|
|
2292
|
+
const note = await call(session.fetch, NOTES_URL, {
|
|
2293
|
+
method: "POST",
|
|
2294
|
+
body: {
|
|
2295
|
+
bodyJson: { ...markdownToDoc(body), attrs: { schemaVersion: "v1" } },
|
|
2296
|
+
tabId: "for-you",
|
|
2297
|
+
surface: "feed",
|
|
2298
|
+
replyMinimumRole: "everyone"
|
|
2299
|
+
}
|
|
2300
|
+
});
|
|
2301
|
+
if (typeof note.id !== "number") return {};
|
|
2302
|
+
const handle = text2(me2.handle);
|
|
2303
|
+
return { id: note.id, ...handle !== void 0 && { url: noteUrl(handle, note.id) } };
|
|
2304
|
+
}
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
type: "readNotes",
|
|
2308
|
+
label: "Read notes",
|
|
2309
|
+
description: `A profile's Notes, newest first, up to ${MAX_NOTES}, from its activity feed as the signed-in account sees it.`,
|
|
2310
|
+
idempotent: true,
|
|
2311
|
+
inputs: [
|
|
2312
|
+
{
|
|
2313
|
+
key: "profile",
|
|
2314
|
+
label: "Profile",
|
|
2315
|
+
description: "A Substack handle, with or without @; the signed-in account when empty."
|
|
2316
|
+
},
|
|
2317
|
+
{
|
|
2318
|
+
key: "limit",
|
|
2319
|
+
label: "Notes",
|
|
2320
|
+
type: "number",
|
|
2321
|
+
description: `How many of the newest Notes, from 1 to ${MAX_NOTES}; ${DEFAULT_NOTES} when empty.`
|
|
2322
|
+
}
|
|
2323
|
+
],
|
|
2324
|
+
outputs: [
|
|
2325
|
+
{ key: "profile", type: "string", description: "The handle the Notes are from" },
|
|
2326
|
+
{ key: "count", type: "number", description: "How many Notes came back, in `notes`" }
|
|
2327
|
+
],
|
|
2328
|
+
run: async (args, ctx) => {
|
|
2329
|
+
const session = signedIn(ctx.session);
|
|
2330
|
+
const limit = countLimit(args.limit, DEFAULT_NOTES, MAX_NOTES);
|
|
2331
|
+
const named = text2(args.profile)?.replace(/^@/, "");
|
|
2332
|
+
if (named !== void 0 && !HANDLE.test(named)) throw new Error(`"${named}" is not a Substack handle`);
|
|
2333
|
+
const who = await call(
|
|
2334
|
+
session.fetch,
|
|
2335
|
+
named === void 0 ? PROFILE_URL : `https://substack.com/api/v1/user/${named}/public_profile`
|
|
2336
|
+
);
|
|
2337
|
+
const handle = text2(who.handle) ?? named ?? "";
|
|
2338
|
+
const notes = [];
|
|
2339
|
+
let cursor;
|
|
2340
|
+
for (let page = 0; typeof who.id === "number" && page < NOTE_PAGES && notes.length < limit; page++) {
|
|
2341
|
+
const url = new URL(`https://substack.com/api/v1/reader/feed/profile/${who.id}`);
|
|
2342
|
+
if (cursor !== void 0) url.searchParams.set("cursor", cursor);
|
|
2343
|
+
const feed = await call(session.fetch, url.href);
|
|
2344
|
+
for (const entry of feed.items ?? []) {
|
|
2345
|
+
if (entry.type === "comment" && entry.context?.type === "note" && entry.comment) {
|
|
2346
|
+
notes.push(noteRecord(entry.comment, handle));
|
|
2347
|
+
}
|
|
2348
|
+
}
|
|
2349
|
+
cursor = text2(feed.nextCursor);
|
|
2350
|
+
if (cursor === void 0) break;
|
|
2351
|
+
}
|
|
2352
|
+
const kept = notes.slice(0, limit);
|
|
2353
|
+
return { profile: handle, count: kept.length, notes: kept };
|
|
2354
|
+
}
|
|
2355
|
+
},
|
|
2356
|
+
{
|
|
2357
|
+
type: "deleteNote",
|
|
2358
|
+
label: "Delete note",
|
|
2359
|
+
description: "Delete one of the signed-in account's Notes.",
|
|
2360
|
+
idempotent: false,
|
|
2361
|
+
inputs: [
|
|
2362
|
+
{
|
|
2363
|
+
key: "noteId",
|
|
2364
|
+
label: "Note id",
|
|
2365
|
+
type: "number",
|
|
2366
|
+
required: true,
|
|
2367
|
+
description: "The id Post a note or Read notes returned."
|
|
2368
|
+
}
|
|
2369
|
+
],
|
|
2370
|
+
outputs: [{ key: "deleted", type: "boolean", description: "Whether the Note was deleted" }],
|
|
2371
|
+
run: async (args, ctx) => {
|
|
2372
|
+
const session = signedIn(ctx.session);
|
|
2373
|
+
const id = wholeId(args.noteId, "noteId");
|
|
2374
|
+
await call(session.fetch, `https://substack.com/api/v1/comment/${id}`, { method: "DELETE" });
|
|
2375
|
+
return { deleted: true, noteId: id };
|
|
2376
|
+
}
|
|
2377
|
+
},
|
|
2378
|
+
{
|
|
2379
|
+
type: "readSubscriberCount",
|
|
2380
|
+
label: "Read subscriber count",
|
|
2381
|
+
description: "Your publication's subscriber, email and view figures, as its dashboard shows them. Needs the signed-in account to run the publication.",
|
|
2382
|
+
idempotent: true,
|
|
2383
|
+
inputs: [{ ...OWN_PUBLICATION_INPUT, type: "select", loadOptions: "publications" }],
|
|
2384
|
+
outputs: [
|
|
2385
|
+
{
|
|
2386
|
+
key: "subscribers",
|
|
2387
|
+
type: "number",
|
|
2388
|
+
description: "Every subscriber, free and paid: the count the dashboard's subscribers page shows"
|
|
2389
|
+
},
|
|
2390
|
+
{ key: "paidSubscribers", type: "number", description: "Paid subscribers only" },
|
|
2391
|
+
{ key: "appSubscribers", type: "number", description: "The dashboard summary\u2019s app subscriber figure" },
|
|
2392
|
+
{ key: "views", type: "number", description: "The dashboard summary\u2019s view count" },
|
|
2393
|
+
{ key: "openRate", type: "number", description: "Email open rate, as the dashboard summary reports it" }
|
|
2394
|
+
],
|
|
2395
|
+
run: async (args, ctx) => {
|
|
2396
|
+
const session = signedIn(ctx.session);
|
|
2397
|
+
const host = await ownPublication(args.publication, ctx.config, session);
|
|
2398
|
+
const summary = await call(
|
|
2399
|
+
session.fetch,
|
|
2400
|
+
`https://${host}/api/v1/publish-dashboard/summary`
|
|
2401
|
+
);
|
|
2402
|
+
return {
|
|
2403
|
+
publication: host,
|
|
2404
|
+
subscribers: Number(summary.totalEmail ?? 0),
|
|
2405
|
+
paidSubscribers: Number(summary.subscribers ?? 0),
|
|
2406
|
+
appSubscribers: Number(summary.appSubscribers ?? 0),
|
|
2407
|
+
views: Number(summary.views ?? 0),
|
|
2408
|
+
openRate: Number(summary.openRate ?? 0)
|
|
2409
|
+
};
|
|
2410
|
+
}
|
|
2041
2411
|
}
|
|
2042
2412
|
]
|
|
2043
2413
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vornrun/connector-substack",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Trigger workflows from new posts on a Substack publication, and read, search and comment on posts or save a draft from a step.",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Trigger workflows from new posts on a Substack publication, and read, search, like, restack and comment on posts, post and read Notes, or save and update a draft from a step.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Javier Canizalez <javier-canizalez@outlook.com>",
|
|
@@ -14,8 +14,7 @@
|
|
|
14
14
|
"vorn",
|
|
15
15
|
"connector",
|
|
16
16
|
"substack",
|
|
17
|
-
"newsletter"
|
|
18
|
-
"mcp"
|
|
17
|
+
"newsletter"
|
|
19
18
|
],
|
|
20
19
|
"bin": {
|
|
21
20
|
"vorn-connector-substack": "dist/index.js"
|
|
@@ -39,7 +38,7 @@
|
|
|
39
38
|
"test": "vitest run"
|
|
40
39
|
},
|
|
41
40
|
"dependencies": {
|
|
42
|
-
"@vornrun/connector-sdk": "^0.7.
|
|
41
|
+
"@vornrun/connector-sdk": "^0.7.1-beta.3"
|
|
43
42
|
},
|
|
44
43
|
"devDependencies": {
|
|
45
44
|
"@types/node": "^22.10.2",
|
|
@@ -57,6 +56,7 @@
|
|
|
57
56
|
"posts",
|
|
58
57
|
"comments",
|
|
59
58
|
"drafts",
|
|
59
|
+
"notes",
|
|
60
60
|
"writing"
|
|
61
61
|
],
|
|
62
62
|
"auth": "Sign in to Substack in the window Vorn opens; reading a publication's feed and searching posts need no sign-in.",
|