pubkit 0.1.0__py3-none-any.whl

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.
pubkit/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """pubkit — publish one source to many platforms, safely."""
4
+ from .core.ir import Asset, Code, Document, Figure, Heading, Paragraph, Series, Table # noqa: F401
5
+ from .core.loader import load_document, load_series # noqa: F401
6
+ from .core.runner import Pipeline, RunReport # noqa: F401
7
+ from .registry import build_adapter, list_adapters, register # noqa: F401
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = [
11
+ "Document", "Series", "Asset", "Figure", "Table", "Heading", "Paragraph", "Code",
12
+ "Pipeline", "RunReport", "load_document", "load_series",
13
+ "build_adapter", "register", "list_adapters", "__version__",
14
+ ]
pubkit/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Allow `python -m pubkit` as well as the `pubkit` console script."""
4
+ from .cli import main
5
+
6
+ if __name__ == "__main__":
7
+ main()
@@ -0,0 +1,2 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,67 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """ApiAdapter — the easy half.
4
+
5
+ API platforms are pleasant by comparison: no DOM, no editor model, no stripped
6
+ images. What they do have is rate limits, partial failure and the same
7
+ idempotency problem, so the base class handles those and nothing else.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from ..core.adapter import AdapterError, BaseAdapter, Context, RateLimited
17
+
18
+ log = logging.getLogger(__name__)
19
+
20
+
21
+ class ApiAdapter(BaseAdapter):
22
+ base_url: str = ""
23
+ timeout: float = 30.0
24
+
25
+ def __init__(self) -> None:
26
+ super().__init__()
27
+ self._client: httpx.AsyncClient | None = None
28
+
29
+ def headers(self, ctx: Context) -> dict[str, str]: # pragma: no cover - overridden
30
+ return {}
31
+
32
+ async def client(self, ctx: Context) -> httpx.AsyncClient:
33
+ if self._client is None:
34
+ self._client = httpx.AsyncClient(
35
+ base_url=self.base_url,
36
+ timeout=self.timeout,
37
+ headers={"user-agent": "pubkit/0.1 (+https://github.com/arunsingh/pubkit)", **self.headers(ctx)},
38
+ )
39
+ return self._client
40
+
41
+ async def request(self, ctx: Context, method: str, url: str, **kw: Any) -> httpx.Response:
42
+ await self.bucket.acquire()
43
+ client = await self.client(ctx)
44
+ resp = await client.request(method, url, **kw)
45
+
46
+ if resp.status_code == 429:
47
+ retry = float(resp.headers.get("retry-after", 30))
48
+ # x-rate-limit-reset is an epoch, not a delta — a detail worth
49
+ # getting right, because treating it as a delta means sleeping
50
+ # until roughly 2056.
51
+ reset = resp.headers.get("x-rate-limit-reset")
52
+ if reset:
53
+ import time
54
+
55
+ retry = max(1.0, float(reset) - time.time())
56
+ raise RateLimited(retry)
57
+
58
+ if resp.status_code >= 500:
59
+ raise AdapterError(f"{self.name}: {resp.status_code} from {url}")
60
+ if resp.status_code >= 400:
61
+ raise AdapterError(f"{self.name}: {resp.status_code} {resp.text[:300]}")
62
+ return resp
63
+
64
+ async def aclose(self) -> None:
65
+ if self._client is not None:
66
+ await self._client.aclose()
67
+ self._client = None
@@ -0,0 +1,195 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Dev.to and Hashnode.
4
+
5
+ These two exist partly for their own sake and partly as proof that the adapter
6
+ interface is genuinely platform-agnostic: same IR, same checks, same runner,
7
+ about 80 lines each, and native tables and code fences with zero degradation.
8
+
9
+ Both expect images to already be at public URLs, so they pair with an asset
10
+ host (`pubkit.assets`) rather than uploading through an editor.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ from ..core.adapter import Context, Fingerprint, PublishedRef, RemoteRef
17
+ from ..core.capabilities import Capabilities, TagSpec, select_tags
18
+ from ..core.ir import Document
19
+ from ..render.html import MarkdownRenderer
20
+ from .api_base import ApiAdapter
21
+
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ class DevToAdapter(ApiAdapter):
26
+ name = "devto"
27
+ base_url = "https://dev.to/api"
28
+ rate = 0.5
29
+ burst = 3
30
+
31
+ capabilities = Capabilities(
32
+ tables=True,
33
+ code_blocks="highlighted",
34
+ inline_html=True,
35
+ animated_gif=True,
36
+ headings=6,
37
+ image_upload="api",
38
+ canonical_url=True,
39
+ tags=TagSpec(max_count=4, max_len=20, strategy="api"),
40
+ drafts=True,
41
+ )
42
+
43
+ def __init__(self, asset_urls: dict[str, str] | None = None) -> None:
44
+ super().__init__()
45
+ self.asset_urls = asset_urls or {}
46
+ self.renderer = MarkdownRenderer(image_url_for=self.asset_urls.get)
47
+
48
+ def headers(self, ctx: Context) -> dict[str, str]:
49
+ return {"api-key": ctx.tokens.require(self.name), "content-type": "application/json"}
50
+
51
+ async def authenticate(self, ctx: Context) -> None:
52
+ await self.request(ctx, "GET", "/users/me")
53
+
54
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
55
+ existing = ctx.options.get("remote_ref")
56
+ if existing:
57
+ return RemoteRef(id=str(existing))
58
+ body = {
59
+ "article": {
60
+ "title": doc.title,
61
+ "published": False,
62
+ "body_markdown": self.renderer.render(doc),
63
+ "tags": [t.replace(" ", "").lower() for t in select_tags(doc.tags, self.capabilities.tags)],
64
+ "canonical_url": doc.canonical_url,
65
+ "description": doc.subtitle or "",
66
+ }
67
+ }
68
+ r = await self.request(ctx, "POST", "/articles", json=body)
69
+ data = r.json()
70
+ return RemoteRef(id=str(data["id"]), url=data.get("url"))
71
+
72
+ async def push_content(self, doc, plan, ref, ctx) -> None:
73
+ body = {
74
+ "article": {
75
+ "title": doc.title,
76
+ "body_markdown": self.renderer.render(doc),
77
+ "tags": [t.replace(" ", "").lower() for t in select_tags(doc.tags, self.capabilities.tags)],
78
+ "canonical_url": doc.canonical_url,
79
+ "description": doc.subtitle or "",
80
+ }
81
+ }
82
+ await self.request(ctx, "PUT", f"/articles/{ref.id}", json=body)
83
+
84
+ async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
85
+ r = await self.request(ctx, "GET", f"/articles/{ref.id}")
86
+ md = r.json().get("body_markdown", "")
87
+ return Fingerprint(
88
+ words=len(md.split()),
89
+ headings=[ln.lstrip("# ").strip()[:48] for ln in md.splitlines() if ln.startswith("#")],
90
+ images=md.count("!["),
91
+ links=md.count("]("),
92
+ markers=md.count("[[ IMAGE"),
93
+ )
94
+
95
+ async def publish(self, doc, ref, ctx) -> PublishedRef:
96
+ self.guard_publish(ctx)
97
+ r = await self.request(ctx, "PUT", f"/articles/{ref.id}", json={"article": {"published": True}})
98
+ data = r.json()
99
+ return PublishedRef(id=str(ref.id), url=data["url"])
100
+
101
+
102
+ class HashnodeAdapter(ApiAdapter):
103
+ name = "hashnode"
104
+ base_url = "https://gql.hashnode.com"
105
+ rate = 1.0
106
+ burst = 5
107
+
108
+ capabilities = Capabilities(
109
+ tables=True,
110
+ code_blocks="highlighted",
111
+ inline_html=True,
112
+ animated_gif=True,
113
+ headings=6,
114
+ image_upload="api",
115
+ canonical_url=True,
116
+ tags=TagSpec(max_count=5, max_len=30, strategy="api"),
117
+ drafts=True,
118
+ )
119
+
120
+ def __init__(self, publication_id: str = "", asset_urls: dict[str, str] | None = None) -> None:
121
+ super().__init__()
122
+ self.publication_id = publication_id
123
+ self.renderer = MarkdownRenderer(image_url_for=(asset_urls or {}).get)
124
+
125
+ def headers(self, ctx: Context) -> dict[str, str]:
126
+ return {"authorization": ctx.tokens.require(self.name), "content-type": "application/json"}
127
+
128
+ async def _gql(self, ctx: Context, query: str, variables: dict) -> dict:
129
+ r = await self.request(ctx, "POST", "/", json={"query": query, "variables": variables})
130
+ payload = r.json()
131
+ if payload.get("errors"):
132
+ from ..core.adapter import AdapterError
133
+
134
+ raise AdapterError(f"hashnode: {payload['errors'][0].get('message')}")
135
+ return payload["data"]
136
+
137
+ async def authenticate(self, ctx: Context) -> None:
138
+ await self._gql(ctx, "query { me { id username } }", {})
139
+
140
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
141
+ existing = ctx.options.get("remote_ref")
142
+ if existing:
143
+ return RemoteRef(id=str(existing))
144
+ q = """
145
+ mutation CreateDraft($input: CreateDraftInput!) {
146
+ createDraft(input: $input) { draft { id slug } }
147
+ }"""
148
+ data = await self._gql(
149
+ ctx,
150
+ q,
151
+ {
152
+ "input": {
153
+ "title": doc.title,
154
+ "subtitle": doc.subtitle or "",
155
+ "contentMarkdown": self.renderer.render(doc),
156
+ "publicationId": self.publication_id,
157
+ "tags": [{"name": t, "slug": t.lower().replace(" ", "-")} for t in select_tags(doc.tags, self.capabilities.tags)],
158
+ "originalArticleURL": doc.canonical_url,
159
+ }
160
+ },
161
+ )
162
+ draft = data["createDraft"]["draft"]
163
+ return RemoteRef(id=draft["id"])
164
+
165
+ async def push_content(self, doc, plan, ref, ctx) -> None:
166
+ q = """
167
+ mutation UpdateDraft($input: UpdateDraftInput!) {
168
+ updateDraft(input: $input) { draft { id } }
169
+ }"""
170
+ await self._gql(
171
+ ctx,
172
+ q,
173
+ {"input": {"id": ref.id, "title": doc.title, "contentMarkdown": self.renderer.render(doc)}},
174
+ )
175
+
176
+ async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
177
+ data = await self._gql(ctx, "query D($id: ObjectId!) { draft(id: $id) { content { markdown } } }", {"id": ref.id})
178
+ md = data["draft"]["content"]["markdown"]
179
+ return Fingerprint(
180
+ words=len(md.split()),
181
+ headings=[ln.lstrip("# ").strip()[:48] for ln in md.splitlines() if ln.startswith("#")],
182
+ images=md.count("!["),
183
+ links=md.count("]("),
184
+ markers=md.count("[[ IMAGE"),
185
+ )
186
+
187
+ async def publish(self, doc, ref, ctx) -> PublishedRef:
188
+ self.guard_publish(ctx)
189
+ q = """
190
+ mutation Publish($input: PublishDraftInput!) {
191
+ publishDraft(input: $input) { post { id url } }
192
+ }"""
193
+ data = await self._gql(ctx, q, {"input": {"draftId": ref.id}})
194
+ post = data["publishDraft"]["post"]
195
+ return PublishedRef(id=post["id"], url=post["url"])
@@ -0,0 +1,184 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Medium.
4
+
5
+ Medium retired public API tokens for new users, so this is a browser adapter.
6
+ It encodes, in order, every trap found while publishing a 15,000-word
7
+ illustrated three-part series:
8
+
9
+ * no table support at all → tables are pre-rendered to figures by the planner
10
+ * pasted HTML has every image stripped, `data:` and `https:` alike
11
+ * the only working image path is a synthetic File paste into the editor
12
+ * the figure lands above the caret's paragraph, so captions are the anchors
13
+ * DOM edits to links never persist; content must be right before it is sent
14
+ * the topics field will accept one 40-character invalid tag without complaint
15
+ * a delete that looks applied can be gone after a reload
16
+
17
+ Everything is verified after a reload before anything is published.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import logging
23
+ import re
24
+
25
+ from ..core.adapter import Context, Fingerprint, PublishedRef, RemoteRef
26
+ from ..core.browser import BrowserAdapter, EditorSelectors
27
+ from ..core.capabilities import Capabilities, TagSpec, select_tags
28
+ from ..core.ir import Document
29
+ from ..render.html import EditorHtmlRenderer
30
+
31
+ log = logging.getLogger(__name__)
32
+
33
+
34
+ class MediumAdapter(BrowserAdapter):
35
+ name = "medium"
36
+ rate = 0.5
37
+ burst = 2
38
+
39
+ capabilities = Capabilities(
40
+ tables=False, # the single biggest constraint
41
+ code_blocks="plain",
42
+ inline_html=False,
43
+ animated_gif=True, # GIFs autoplay inline, which is why
44
+ # animations survive as GIFs
45
+ headings=4, # h3/h4 only inside the body
46
+ max_body_chars=None,
47
+ image_upload="browser_paste",
48
+ canonical_url=True,
49
+ tags=TagSpec(max_count=5, max_len=25, strategy="comma"),
50
+ scheduling=True,
51
+ drafts=True,
52
+ )
53
+
54
+ selectors = EditorSelectors(
55
+ editable='.postArticle-content[contenteditable="true"]',
56
+ # ALL section roots. Using only the first is what turned a replace into
57
+ # an append and produced a scrambled, duplicated draft.
58
+ content_roots=".section-inner",
59
+ block=".graf",
60
+ figure="figure",
61
+ figure_img="figure img",
62
+ link="a",
63
+ publish_button='button[data-action="show-publish-flow"], [data-testid="publishButton"]',
64
+ tag_input='input[placeholder*="topic" i], input[data-testid="topicInput"]',
65
+ tag_chip='[data-testid="topicChip"], .js-tagToken',
66
+ confirm_publish='button:has-text("Publish now")',
67
+ )
68
+
69
+ login_url = "https://medium.com/m/signin"
70
+ new_story_url = "https://medium.com/new-story"
71
+ marker_regex = r"\\[\\[\\s*IMAGE"
72
+
73
+ def __init__(self, page=None, upload=None) -> None:
74
+ super().__init__()
75
+ self._page = page
76
+ self._upload = upload
77
+ self.renderer = EditorHtmlRenderer(heading_offset=2, max_heading=4)
78
+
79
+ # ------------------------------------------------------------------ auth
80
+ async def authenticate(self, ctx: Context) -> None:
81
+ """Session-based. pubkit never sees a password.
82
+
83
+ `pubkit auth login medium` opens a visible window, the user signs in,
84
+ and the resulting storage_state is what gets persisted.
85
+ """
86
+ state = ctx.sessions.load(self.name)
87
+ if state is None:
88
+ raise RuntimeError(
89
+ "no saved Medium session. Run `pubkit auth login medium` — "
90
+ "a browser window opens, you sign in yourself, pubkit stores "
91
+ "only the session cookie."
92
+ )
93
+ await self._page.goto("https://medium.com/me/stories/drafts", wait_until="domcontentloaded")
94
+ if "signin" in self._page.url:
95
+ ctx.sessions.forget(self.name)
96
+ raise RuntimeError("saved Medium session has expired; run `pubkit auth login medium`")
97
+
98
+ # ----------------------------------------------------------------- draft
99
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
100
+ """Reuse the recorded draft if there is one; never create a duplicate."""
101
+ existing = ctx.options.get("remote_ref")
102
+ if existing:
103
+ await self._page.goto(f"https://medium.com/p/{existing}/edit", wait_until="domcontentloaded")
104
+ await asyncio.sleep(3)
105
+ await self.install_helpers()
106
+ return RemoteRef(id=existing, url=f"https://medium.com/p/{existing}")
107
+
108
+ await self._page.goto(self.new_story_url, wait_until="domcontentloaded")
109
+ await asyncio.sleep(4)
110
+ await self.install_helpers()
111
+ m = re.search(r"/p/([0-9a-f]{8,})/edit", self._page.url)
112
+ if not m:
113
+ # A brand-new story gets its id on first save; nudge the editor.
114
+ await self._page.click(self.selectors.editable)
115
+ await self._page.keyboard.type(doc.title[:8])
116
+ await asyncio.sleep(3)
117
+ m = re.search(r"/p/([0-9a-f]{8,})/edit", self._page.url)
118
+ if not m:
119
+ raise RuntimeError(f"could not determine draft id from {self._page.url}")
120
+ return RemoteRef(id=m.group(1), url=f"https://medium.com/p/{m.group(1)}")
121
+
122
+ # --------------------------------------------------------------- content
123
+ async def push_content(self, doc, plan, ref, ctx) -> None:
124
+ """One paste for the whole document, with the link targets already
125
+ correct.
126
+
127
+ Links must be right *before* transmission: setting an href in the DOM
128
+ afterwards looks like it worked and is gone on the next reload, because
129
+ the editor syncs from its own model (failure B4).
130
+ """
131
+ html = self.renderer.render(doc)
132
+ if "URL-PART" in html or "${" in html:
133
+ raise RuntimeError(
134
+ "unresolved cross-reference placeholder in rendered HTML — "
135
+ "run phase 1 of the two-phase publish first"
136
+ )
137
+ await self.install_helpers()
138
+ await self.replace_document(html)
139
+
140
+ # ----------------------------------------------------------------- media
141
+ async def push_media(self, doc, plan, ref, ctx) -> None:
142
+ anchors = self.renderer.caption_anchors(doc)
143
+ if not anchors:
144
+ return
145
+ paths = [doc.assets[aid].path for aid, _ in anchors]
146
+ await self.stage_files(paths, self._upload)
147
+
148
+ used: set[int] = set()
149
+ for i, (asset_id, caption) in enumerate(anchors):
150
+ await self.bucket.acquire()
151
+ await self.insert_image_at(caption, i, used=used)
152
+ log.info("medium: placed %s above %r", asset_id, caption[:48])
153
+
154
+ # ---------------------------------------------------------------- verify
155
+ async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
156
+ expected = Fingerprint(
157
+ words=doc.word_count,
158
+ headings=[],
159
+ images=len(doc.figures),
160
+ links=0,
161
+ )
162
+
163
+ async def reload():
164
+ await self._page.goto(f"https://medium.com/p/{ref.id}/edit", wait_until="domcontentloaded")
165
+
166
+ return await self.verify_after_reload(expected, reload)
167
+
168
+ # --------------------------------------------------------------- publish
169
+ async def publish(self, doc, ref, ctx) -> PublishedRef:
170
+ self.guard_publish(ctx)
171
+ await self._page.goto(f"https://medium.com/p/{ref.id}/edit", wait_until="domcontentloaded")
172
+ await asyncio.sleep(3)
173
+ await self._page.click(self.selectors.publish_button)
174
+ await asyncio.sleep(3)
175
+
176
+ tags = select_tags(doc.tags, self.capabilities.tags)
177
+ await self.apply_tags(tags, self.capabilities.tags.strategy)
178
+
179
+ await self._page.click(self.selectors.confirm_publish)
180
+ await self._page.wait_for_url(re.compile(r"medium\.com/(@|p/)"), timeout=60_000)
181
+ await asyncio.sleep(2)
182
+ url = self._page.url.split("?")[0]
183
+ log.info("medium: published %s", url)
184
+ return PublishedRef(id=ref.id, url=url)
@@ -0,0 +1,136 @@
1
+ # Copyright 2026 The pubkit Authors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Substack.
4
+
5
+ No public write API, so this is a browser adapter — and the point of it being
6
+ only ~120 lines is that everything genuinely hard already lives in
7
+ `BrowserAdapter`. Substack's editor (ProseMirror) differs from Medium's in its
8
+ selectors and in accepting real tables; it does not differ in any of the ways
9
+ that caused actual pain.
10
+
11
+ What is Substack-specific:
12
+ * ProseMirror node selectors instead of Medium's `.graf`
13
+ * native tables, so no table→image degradation
14
+ * publish is a two-step dialog with an email-subscribers choice that must be
15
+ made explicitly rather than left to the default
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import logging
21
+ import re
22
+
23
+ from ..core.adapter import Context, Fingerprint, PublishedRef, RemoteRef
24
+ from ..core.browser import BrowserAdapter, EditorSelectors
25
+ from ..core.capabilities import Capabilities
26
+ from ..core.ir import Document
27
+ from ..render.html import EditorHtmlRenderer
28
+
29
+ log = logging.getLogger(__name__)
30
+
31
+
32
+ class SubstackAdapter(BrowserAdapter):
33
+ name = "substack"
34
+ rate = 0.5
35
+ burst = 2
36
+
37
+ capabilities = Capabilities(
38
+ tables=True,
39
+ code_blocks="fenced",
40
+ inline_html=True,
41
+ animated_gif=True,
42
+ headings=4,
43
+ image_upload="browser_paste",
44
+ canonical_url=True,
45
+ tags=None,
46
+ scheduling=True,
47
+ drafts=True,
48
+ )
49
+
50
+ selectors = EditorSelectors(
51
+ editable='div[contenteditable="true"].ProseMirror',
52
+ content_roots="div.ProseMirror",
53
+ block="div.ProseMirror > *",
54
+ figure="figure, div[data-attrs*='image']",
55
+ figure_img="img",
56
+ link="a",
57
+ publish_button='button:has-text("Continue")',
58
+ tag_input=None,
59
+ tag_chip=None,
60
+ confirm_publish='button:has-text("Send to everyone now"), button:has-text("Publish now")',
61
+ )
62
+
63
+ def __init__(self, publication: str, page=None, upload=None) -> None:
64
+ super().__init__()
65
+ self.publication = publication.rstrip("/")
66
+ self._page = page
67
+ self._upload = upload
68
+ # Substack renders tables natively, so the heading offset is smaller
69
+ # and tables stay tables.
70
+ self.renderer = EditorHtmlRenderer(heading_offset=1, max_heading=4)
71
+
72
+ async def authenticate(self, ctx: Context) -> None:
73
+ if ctx.sessions.load(self.name) is None:
74
+ raise RuntimeError(
75
+ "no saved Substack session. Run `pubkit auth login substack` "
76
+ "and sign in yourself in the window that opens."
77
+ )
78
+ await self._page.goto(f"{self.publication}/publish/posts", wait_until="domcontentloaded")
79
+ if "/sign-in" in self._page.url:
80
+ ctx.sessions.forget(self.name)
81
+ raise RuntimeError("saved Substack session expired; run `pubkit auth login substack`")
82
+
83
+ async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
84
+ existing = ctx.options.get("remote_ref")
85
+ if existing:
86
+ await self._page.goto(f"{self.publication}/publish/post/{existing}", wait_until="domcontentloaded")
87
+ else:
88
+ await self._page.goto(f"{self.publication}/publish/post?type=newsletter", wait_until="domcontentloaded")
89
+ await asyncio.sleep(4)
90
+ await self.install_helpers()
91
+ m = re.search(r"/publish/post/(\d+)", self._page.url)
92
+ if not m:
93
+ raise RuntimeError(f"could not determine Substack draft id from {self._page.url}")
94
+ return RemoteRef(id=m.group(1), url=f"{self.publication}/publish/post/{m.group(1)}")
95
+
96
+ async def push_content(self, doc, plan, ref, ctx) -> None:
97
+ await self._page.fill('input[placeholder*="Title" i]', doc.title)
98
+ if doc.subtitle:
99
+ await self._page.fill('textarea[placeholder*="subtitle" i], input[placeholder*="subtitle" i]', doc.subtitle)
100
+ await self.install_helpers()
101
+ await self.replace_document(self.renderer.render(doc))
102
+
103
+ async def push_media(self, doc, plan, ref, ctx) -> None:
104
+ anchors = self.renderer.caption_anchors(doc)
105
+ if not anchors:
106
+ return
107
+ await self.stage_files([doc.assets[a].path for a, _ in anchors], self._upload)
108
+ used: set[int] = set()
109
+ for i, (asset_id, caption) in enumerate(anchors):
110
+ await self.bucket.acquire()
111
+ await self.insert_image_at(caption, i, used=used)
112
+ log.info("substack: placed %s", asset_id)
113
+
114
+ async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
115
+ expected = Fingerprint(words=doc.word_count, headings=[], images=len(doc.figures), links=0)
116
+
117
+ async def reload():
118
+ await self._page.goto(f"{self.publication}/publish/post/{ref.id}", wait_until="domcontentloaded")
119
+
120
+ return await self.verify_after_reload(expected, reload)
121
+
122
+ async def publish(self, doc, ref, ctx) -> PublishedRef:
123
+ self.guard_publish(ctx)
124
+ await self._page.goto(f"{self.publication}/publish/post/{ref.id}", wait_until="domcontentloaded")
125
+ await asyncio.sleep(3)
126
+ await self._page.click(self.selectors.publish_button)
127
+ await asyncio.sleep(2)
128
+ # The email choice is explicit on purpose: silently mailing a few
129
+ # thousand subscribers is not a sensible default for an automation tool.
130
+ if ctx.options.get("email_subscribers", False):
131
+ await self._page.click('button:has-text("Send to everyone now")')
132
+ else:
133
+ await self._page.click('button:has-text("Publish now")')
134
+ await asyncio.sleep(5)
135
+ url = f"{self.publication}/p/{ctx.options.get('slug', ref.id)}"
136
+ return PublishedRef(id=ref.id, url=url)