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 +14 -0
- pubkit/__main__.py +7 -0
- pubkit/adapters/__init__.py +2 -0
- pubkit/adapters/api_base.py +67 -0
- pubkit/adapters/devto.py +195 -0
- pubkit/adapters/medium.py +184 -0
- pubkit/adapters/substack.py +136 -0
- pubkit/adapters/x.py +216 -0
- pubkit/browserctl.py +78 -0
- pubkit/cli.py +306 -0
- pubkit/core/__init__.py +2 -0
- pubkit/core/adapter.py +206 -0
- pubkit/core/anchors.py +89 -0
- pubkit/core/auth.py +209 -0
- pubkit/core/browser.py +426 -0
- pubkit/core/capabilities.py +256 -0
- pubkit/core/checks.py +344 -0
- pubkit/core/ir.py +240 -0
- pubkit/core/loader.py +278 -0
- pubkit/core/runner.py +268 -0
- pubkit/core/state.py +213 -0
- pubkit/core/transport.py +170 -0
- pubkit/py.typed +0 -0
- pubkit/registry.py +81 -0
- pubkit/render/__init__.py +2 -0
- pubkit/render/html.py +221 -0
- pubkit/scaffold.py +271 -0
- pubkit/workflows/__init__.py +2 -0
- pubkit/workflows/airflow.py +115 -0
- pubkit-0.1.0.dist-info/METADATA +291 -0
- pubkit-0.1.0.dist-info/RECORD +35 -0
- pubkit-0.1.0.dist-info/WHEEL +4 -0
- pubkit-0.1.0.dist-info/entry_points.txt +2 -0
- pubkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- pubkit-0.1.0.dist-info/licenses/NOTICE +7 -0
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,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
|
pubkit/adapters/devto.py
ADDED
|
@@ -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(",
|
|
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(",
|
|
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)
|