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/adapters/x.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""X / Twitter, API v2.
|
|
4
|
+
|
|
5
|
+
The interesting problem here is not the API, it is that a 6,000-word article
|
|
6
|
+
and a 280-character post are different media. The ThreadRenderer does the
|
|
7
|
+
splitting; this adapter deals with the parts that bite:
|
|
8
|
+
|
|
9
|
+
* media upload still lives on the v1.1 host and needs its own flow
|
|
10
|
+
* `x-rate-limit-reset` is an epoch, not a delta
|
|
11
|
+
* a thread is a chain — a failure at tweet 7 of 11 leaves a visible,
|
|
12
|
+
half-finished thread, so the chain is checkpointed per tweet and resumable
|
|
13
|
+
* posting is irreversible and instantly public, so the publish guard matters
|
|
14
|
+
more here than anywhere else
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import base64
|
|
19
|
+
import logging
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from ..core.adapter import AdapterError, Context, Fingerprint, PublishedRef, RemoteRef
|
|
23
|
+
from ..core.capabilities import Capabilities
|
|
24
|
+
from ..core.ir import Document
|
|
25
|
+
from ..render.html import ThreadRenderer, plain
|
|
26
|
+
from .api_base import ApiAdapter
|
|
27
|
+
|
|
28
|
+
log = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class XAdapter(ApiAdapter):
|
|
32
|
+
name = "x"
|
|
33
|
+
base_url = "https://api.twitter.com"
|
|
34
|
+
rate = 0.2 # v2 free/basic tiers are stingy; be a good citizen
|
|
35
|
+
burst = 2
|
|
36
|
+
|
|
37
|
+
capabilities = Capabilities(
|
|
38
|
+
tables=False,
|
|
39
|
+
code_blocks="none",
|
|
40
|
+
inline_html=False,
|
|
41
|
+
animated_gif=True,
|
|
42
|
+
headings=1,
|
|
43
|
+
max_body_chars=280,
|
|
44
|
+
# promo mode never needs more than max_posts; see __init__.
|
|
45
|
+
image_upload="api",
|
|
46
|
+
canonical_url=False,
|
|
47
|
+
tags=None, # hashtags are body text here, not metadata
|
|
48
|
+
threads=True,
|
|
49
|
+
drafts=False, # no draft concept — publish is one-shot
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def __init__(self, limit: int = 280, mode: str = "promo", max_posts: int = 12) -> None:
|
|
53
|
+
"""`mode`:
|
|
54
|
+
|
|
55
|
+
promo (default)
|
|
56
|
+
A hook, three or four of the article's sharpest claims, and a link.
|
|
57
|
+
This is what actually works on X, and it is what a long-form author
|
|
58
|
+
wants: the article lives on the blog, the thread sells it.
|
|
59
|
+
|
|
60
|
+
full
|
|
61
|
+
Serialise the whole body. Honest, occasionally right, usually a
|
|
62
|
+
180-tweet wall nobody reads. Opt in deliberately.
|
|
63
|
+
"""
|
|
64
|
+
super().__init__()
|
|
65
|
+
self.mode = mode
|
|
66
|
+
self.max_posts = max_posts
|
|
67
|
+
self.renderer = ThreadRenderer(limit=limit)
|
|
68
|
+
|
|
69
|
+
def refine_plan(self, doc: Document, p):
|
|
70
|
+
"""promo mode summarises rather than serialises, so the generic
|
|
71
|
+
'body is too long' degradation is simply wrong here."""
|
|
72
|
+
from ..core.capabilities import Degradation, DegradationKind
|
|
73
|
+
|
|
74
|
+
if self.mode != "promo":
|
|
75
|
+
return p
|
|
76
|
+
n = len(self._thread(doc))
|
|
77
|
+
p.degradations = [
|
|
78
|
+
d for d in p.degradations
|
|
79
|
+
if d.kind not in (DegradationKind.SPLIT_INTO_THREAD, DegradationKind.CODE_FLATTENED,
|
|
80
|
+
DegradationKind.HEADINGS_CLAMPED)
|
|
81
|
+
]
|
|
82
|
+
p.blocking = [b for b in p.blocking if "body is" not in b]
|
|
83
|
+
p.degradations.insert(0, Degradation(
|
|
84
|
+
kind=DegradationKind.SPLIT_INTO_THREAD,
|
|
85
|
+
detail=f"promo thread: {n} posts — hook, key claims, link to the full article",
|
|
86
|
+
count=n,
|
|
87
|
+
))
|
|
88
|
+
p.estimated_body_chars = sum(len(t) for t in self._thread(doc))
|
|
89
|
+
return p
|
|
90
|
+
|
|
91
|
+
def headers(self, ctx: Context) -> dict[str, str]:
|
|
92
|
+
return {
|
|
93
|
+
"authorization": f"Bearer {ctx.tokens.require(self.name, 'bearer')}",
|
|
94
|
+
"content-type": "application/json",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async def authenticate(self, ctx: Context) -> None:
|
|
98
|
+
await self.request(ctx, "GET", "/2/users/me")
|
|
99
|
+
|
|
100
|
+
def _thread(self, doc: Document) -> list[str]:
|
|
101
|
+
if self.mode == "full":
|
|
102
|
+
return self.renderer.render(doc)[: self.max_posts * 4]
|
|
103
|
+
return self._promo(doc)
|
|
104
|
+
|
|
105
|
+
def _promo(self, doc: Document) -> list[str]:
|
|
106
|
+
"""Hook, the strongest claims, then the link."""
|
|
107
|
+
from ..core.ir import Callout, ListBlock, Paragraph
|
|
108
|
+
|
|
109
|
+
link = doc.canonical_url or ""
|
|
110
|
+
posts: list[str] = []
|
|
111
|
+
|
|
112
|
+
opener = doc.subtitle or next(
|
|
113
|
+
(b.text for b in doc.blocks if isinstance(b, Paragraph)), doc.title
|
|
114
|
+
)
|
|
115
|
+
posts.append(f"{doc.title}\n\n{plain(opener)}"[:265])
|
|
116
|
+
|
|
117
|
+
# Callouts and short standalone list items are the claims an author
|
|
118
|
+
# already marked as load-bearing; they make far better tweets than
|
|
119
|
+
# arbitrary paragraph slices.
|
|
120
|
+
claims: list[str] = []
|
|
121
|
+
for b in doc.blocks:
|
|
122
|
+
if isinstance(b, Callout):
|
|
123
|
+
claims.append(plain(b.text))
|
|
124
|
+
elif isinstance(b, ListBlock):
|
|
125
|
+
claims.extend(plain(i) for i in b.items if 40 <= len(i) <= 230)
|
|
126
|
+
if len(claims) >= self.max_posts - 2:
|
|
127
|
+
break
|
|
128
|
+
for c in claims[: self.max_posts - 2]:
|
|
129
|
+
posts.append(c[:265])
|
|
130
|
+
|
|
131
|
+
posts.append(("Full write-up: " + link).strip() if link else "Full write-up in the replies.")
|
|
132
|
+
total = len(posts)
|
|
133
|
+
return [f"{p} ({i+1}/{total})" if total > 1 else p for i, p in enumerate(posts)]
|
|
134
|
+
|
|
135
|
+
async def ensure_draft(self, doc: Document, ctx: Context) -> RemoteRef:
|
|
136
|
+
# No drafts on this platform. The "draft" is the rendered thread, which
|
|
137
|
+
# we hold locally so `plan` can show it before anything is posted.
|
|
138
|
+
return RemoteRef(id=f"local:{doc.content_id[:12]}", extra={"thread": self._thread(doc)})
|
|
139
|
+
|
|
140
|
+
async def push_content(self, doc, plan, ref, ctx) -> None:
|
|
141
|
+
ref.extra["thread"] = self._thread(doc)
|
|
142
|
+
|
|
143
|
+
async def push_media(self, doc, plan, ref, ctx) -> None:
|
|
144
|
+
"""Upload the first few figures and pin them to early tweets.
|
|
145
|
+
|
|
146
|
+
Deliberately not all of them: a thread where every tweet carries an
|
|
147
|
+
image reads as a slideshow and performs worse than one good visual on
|
|
148
|
+
the opening post.
|
|
149
|
+
"""
|
|
150
|
+
figures = doc.figures[:4]
|
|
151
|
+
media_ids: list[str] = []
|
|
152
|
+
for fig in figures:
|
|
153
|
+
asset = doc.assets[fig.asset_id]
|
|
154
|
+
media_ids.append(await self._upload_media(ctx, asset.path))
|
|
155
|
+
ref.extra["media_ids"] = media_ids
|
|
156
|
+
|
|
157
|
+
async def _upload_media(self, ctx: Context, path: Path) -> str:
|
|
158
|
+
data = base64.b64encode(path.read_bytes()).decode()
|
|
159
|
+
await self.bucket.acquire()
|
|
160
|
+
client = await self.client(ctx)
|
|
161
|
+
r = await client.post(
|
|
162
|
+
"https://upload.twitter.com/1.1/media/upload.json",
|
|
163
|
+
data={"media_data": data},
|
|
164
|
+
headers={"authorization": client.headers["authorization"]},
|
|
165
|
+
)
|
|
166
|
+
if r.status_code >= 400:
|
|
167
|
+
raise AdapterError(f"x: media upload failed {r.status_code} {r.text[:200]}")
|
|
168
|
+
return str(r.json()["media_id_string"])
|
|
169
|
+
|
|
170
|
+
async def verify(self, doc, plan, ref, ctx) -> Fingerprint:
|
|
171
|
+
thread = ref.extra.get("thread", [])
|
|
172
|
+
over = [i for i, t in enumerate(thread) if len(t) > 280]
|
|
173
|
+
if over:
|
|
174
|
+
raise AdapterError(f"tweets {over} exceed 280 characters after rendering")
|
|
175
|
+
return Fingerprint(
|
|
176
|
+
words=sum(len(t.split()) for t in thread),
|
|
177
|
+
headings=[],
|
|
178
|
+
images=len(ref.extra.get("media_ids", [])),
|
|
179
|
+
links=sum(t.count("http") for t in thread),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
async def publish(self, doc, ref, ctx) -> PublishedRef:
|
|
183
|
+
"""Post the chain, checkpointing after every tweet.
|
|
184
|
+
|
|
185
|
+
If tweet 7 of 11 fails, the already-posted 6 are recorded so a resumed
|
|
186
|
+
run continues the chain instead of starting a second one beside it.
|
|
187
|
+
"""
|
|
188
|
+
self.guard_publish(ctx)
|
|
189
|
+
thread: list[str] = ref.extra["thread"]
|
|
190
|
+
media_ids: list[str] = ref.extra.get("media_ids", [])
|
|
191
|
+
posted: list[str] = ref.extra.setdefault("posted", [])
|
|
192
|
+
|
|
193
|
+
reply_to = posted[-1] if posted else None
|
|
194
|
+
first_url = ref.extra.get("first_url")
|
|
195
|
+
|
|
196
|
+
for i, text in enumerate(thread):
|
|
197
|
+
if i < len(posted):
|
|
198
|
+
continue
|
|
199
|
+
payload: dict = {"text": text}
|
|
200
|
+
if reply_to:
|
|
201
|
+
payload["reply"] = {"in_reply_to_tweet_id": reply_to}
|
|
202
|
+
if i == 0 and media_ids:
|
|
203
|
+
payload["media"] = {"media_ids": media_ids[:1]}
|
|
204
|
+
elif i in (3, 6) and len(media_ids) > 1:
|
|
205
|
+
payload["media"] = {"media_ids": [media_ids.pop(1)]}
|
|
206
|
+
|
|
207
|
+
r = await self.request(ctx, "POST", "/2/tweets", json=payload)
|
|
208
|
+
tid = r.json()["data"]["id"]
|
|
209
|
+
posted.append(tid)
|
|
210
|
+
reply_to = tid
|
|
211
|
+
if i == 0:
|
|
212
|
+
first_url = f"https://x.com/i/status/{tid}"
|
|
213
|
+
ref.extra["first_url"] = first_url
|
|
214
|
+
log.info("x: posted %d/%d", i + 1, len(thread))
|
|
215
|
+
|
|
216
|
+
return PublishedRef(id=posted[0], url=first_url or f"https://x.com/i/status/{posted[0]}")
|
pubkit/browserctl.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Playwright lifecycle: one browser, many adapters, sessions persisted.
|
|
4
|
+
|
|
5
|
+
The interactive login flow is the important part. pubkit opens a *visible*
|
|
6
|
+
window, the person signs in themselves — password manager, MFA, device
|
|
7
|
+
confirmation, whatever the platform demands — and pubkit saves only the
|
|
8
|
+
resulting storage_state. It never sees, types or stores a password.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
from collections.abc import Sequence
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
|
|
17
|
+
from .core.auth import SessionStore
|
|
18
|
+
|
|
19
|
+
log = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
LOGIN_URLS = {
|
|
22
|
+
"medium": ("https://medium.com/m/signin", "https://medium.com/me/stories/drafts"),
|
|
23
|
+
"substack": ("https://substack.com/sign-in", "https://substack.com/home"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@asynccontextmanager
|
|
28
|
+
async def browser_page(platform: str, sessions: SessionStore, *, headless: bool = True):
|
|
29
|
+
from playwright.async_api import async_playwright
|
|
30
|
+
|
|
31
|
+
state = sessions.load(platform)
|
|
32
|
+
async with async_playwright() as pw:
|
|
33
|
+
browser = await pw.chromium.launch(headless=headless)
|
|
34
|
+
context = await browser.new_context(
|
|
35
|
+
storage_state=state,
|
|
36
|
+
viewport={"width": 1440, "height": 900},
|
|
37
|
+
)
|
|
38
|
+
page = await context.new_page()
|
|
39
|
+
try:
|
|
40
|
+
yield page, _uploader(page)
|
|
41
|
+
finally:
|
|
42
|
+
# Refresh the stored session on the way out: cookies rotate, and a
|
|
43
|
+
# session that silently expires mid-run is a bad afternoon.
|
|
44
|
+
try:
|
|
45
|
+
sessions.save(platform, await context.storage_state())
|
|
46
|
+
except Exception: # noqa: BLE001
|
|
47
|
+
log.debug("could not refresh %s session", platform)
|
|
48
|
+
await context.close()
|
|
49
|
+
await browser.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _uploader(page):
|
|
53
|
+
async def upload(selector: str, paths: Sequence[str]) -> None:
|
|
54
|
+
await page.set_input_files(selector, list(paths))
|
|
55
|
+
return upload
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def interactive_login(platform: str, sessions: SessionStore, timeout: float = 300.0) -> None:
|
|
59
|
+
from playwright.async_api import async_playwright
|
|
60
|
+
|
|
61
|
+
login_url, success_url = LOGIN_URLS.get(platform, ("https://" + platform, "https://" + platform))
|
|
62
|
+
async with async_playwright() as pw:
|
|
63
|
+
browser = await pw.chromium.launch(headless=False)
|
|
64
|
+
context = await browser.new_context(viewport={"width": 1280, "height": 900})
|
|
65
|
+
page = await context.new_page()
|
|
66
|
+
await page.goto(login_url)
|
|
67
|
+
print(f"Sign in to {platform} in the window that opened. Waiting up to {timeout:.0f}s…")
|
|
68
|
+
deadline = asyncio.get_event_loop().time() + timeout
|
|
69
|
+
host = success_url.split("/")[2]
|
|
70
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
71
|
+
if host in page.url and "sign" not in page.url and "login" not in page.url:
|
|
72
|
+
await asyncio.sleep(2)
|
|
73
|
+
sessions.save(platform, await context.storage_state())
|
|
74
|
+
await browser.close()
|
|
75
|
+
return
|
|
76
|
+
await asyncio.sleep(1.5)
|
|
77
|
+
await browser.close()
|
|
78
|
+
raise TimeoutError(f"no {platform} login detected within {timeout:.0f}s")
|
pubkit/cli.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# Copyright 2026 The pubkit Authors
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""pubkit CLI."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table as RichTable
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
from .core.adapter import Context
|
|
19
|
+
from .core.auth import SessionStore, TokenStore
|
|
20
|
+
from .core.capabilities import plan as make_plan
|
|
21
|
+
from .core.checks import run_checks
|
|
22
|
+
from .core.loader import load_document, load_series
|
|
23
|
+
from .core.runner import Pipeline
|
|
24
|
+
from .core.state import StateStore
|
|
25
|
+
from .registry import build_adapter, list_adapters
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(add_completion=False, help="Publish one source to many platforms, safely.")
|
|
28
|
+
auth_app = typer.Typer(help="Credentials. pubkit never accepts a password.")
|
|
29
|
+
app.add_typer(auth_app, name="auth")
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _version_callback(value: bool) -> None:
|
|
34
|
+
if value:
|
|
35
|
+
Console().print(f"pubkit {__version__}")
|
|
36
|
+
raise typer.Exit()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.callback()
|
|
40
|
+
def _root(
|
|
41
|
+
version: bool = typer.Option(
|
|
42
|
+
None, "--version", "-V", callback=_version_callback, is_eager=True, help="Show the version and exit."
|
|
43
|
+
),
|
|
44
|
+
) -> None:
|
|
45
|
+
"""Publish one source to many platforms, safely."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _setup_logging(verbose: bool) -> None:
|
|
49
|
+
logging.basicConfig(
|
|
50
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
51
|
+
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
52
|
+
datefmt="%H:%M:%S",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _docs(path: Path):
|
|
57
|
+
if path.is_dir():
|
|
58
|
+
files = sorted(path.glob("*.md"))
|
|
59
|
+
if not files:
|
|
60
|
+
raise typer.BadParameter(f"no .md files in {path}")
|
|
61
|
+
return load_series(files)
|
|
62
|
+
return load_document(path)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------- validate
|
|
66
|
+
@app.command()
|
|
67
|
+
def validate(
|
|
68
|
+
path: Path = typer.Argument(..., help="A .md file, or a directory for a series"),
|
|
69
|
+
strict: bool = typer.Option(False, help="Treat warnings as errors"),
|
|
70
|
+
verbose: bool = typer.Option(False, "-v"),
|
|
71
|
+
):
|
|
72
|
+
"""Run the check pipeline. No network, no browser, no side effects."""
|
|
73
|
+
_setup_logging(verbose)
|
|
74
|
+
target = _docs(path)
|
|
75
|
+
documents = target.documents if hasattr(target, "documents") else [target]
|
|
76
|
+
|
|
77
|
+
failed = False
|
|
78
|
+
for doc in documents:
|
|
79
|
+
res = run_checks(doc)
|
|
80
|
+
console.print(f"\n[bold]{doc.id}[/] — {doc.word_count:,} words, "
|
|
81
|
+
f"{len(doc.figures)} figures, {len(doc.tables)} tables")
|
|
82
|
+
if not res.findings:
|
|
83
|
+
console.print(" [green]all checks passed[/]")
|
|
84
|
+
for f in res.findings:
|
|
85
|
+
colour = {"error": "red", "warn": "yellow", "info": "dim"}[f.severity.value]
|
|
86
|
+
console.print(f" [{colour}]{f}[/]")
|
|
87
|
+
if res.errors or (strict and res.findings):
|
|
88
|
+
failed = True
|
|
89
|
+
|
|
90
|
+
raise typer.Exit(1 if failed else 0)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# -------------------------------------------------------------------- plan
|
|
94
|
+
@app.command()
|
|
95
|
+
def plan(
|
|
96
|
+
path: Path = typer.Argument(...),
|
|
97
|
+
platforms: str = typer.Option(..., "--to", help="Comma-separated: medium,x,substack,devto"),
|
|
98
|
+
verbose: bool = typer.Option(False, "-v"),
|
|
99
|
+
):
|
|
100
|
+
"""Show exactly what each platform will get, including every degradation."""
|
|
101
|
+
_setup_logging(verbose)
|
|
102
|
+
target = _docs(path)
|
|
103
|
+
documents = target.documents if hasattr(target, "documents") else [target]
|
|
104
|
+
names = [p.strip() for p in platforms.split(",") if p.strip()]
|
|
105
|
+
|
|
106
|
+
for doc in documents:
|
|
107
|
+
for name in names:
|
|
108
|
+
adapter = build_adapter(name)
|
|
109
|
+
p = make_plan(doc, name, adapter.capabilities, adapter)
|
|
110
|
+
console.print()
|
|
111
|
+
console.print(p.human())
|
|
112
|
+
|
|
113
|
+
console.print("\n[dim]Nothing has been sent. Use `pubkit publish` to apply.[/]")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ----------------------------------------------------------------- publish
|
|
117
|
+
@app.command()
|
|
118
|
+
def publish(
|
|
119
|
+
path: Path = typer.Argument(...),
|
|
120
|
+
platforms: str = typer.Option(..., "--to"),
|
|
121
|
+
confirm: bool = typer.Option(False, "--confirm", help="Actually publish (not just draft)"),
|
|
122
|
+
draft_only: bool = typer.Option(False, "--draft-only", help="Create/update drafts and stop"),
|
|
123
|
+
headless: bool = typer.Option(True, help="Browser adapters run headless"),
|
|
124
|
+
email_subscribers: bool = typer.Option(False, help="Substack: send the email too"),
|
|
125
|
+
verbose: bool = typer.Option(False, "-v"),
|
|
126
|
+
):
|
|
127
|
+
"""Create or update drafts and, with --confirm, publish.
|
|
128
|
+
|
|
129
|
+
Safe to re-run. A dropped connection costs you one step, not one run.
|
|
130
|
+
"""
|
|
131
|
+
_setup_logging(verbose)
|
|
132
|
+
confirm = confirm or os.environ.get("PUBKIT_CONFIRM") == "1"
|
|
133
|
+
target = _docs(path)
|
|
134
|
+
names = [p.strip() for p in platforms.split(",") if p.strip()]
|
|
135
|
+
|
|
136
|
+
tokens, sessions = TokenStore(), SessionStore()
|
|
137
|
+
adapters = [build_adapter(n) for n in names]
|
|
138
|
+
|
|
139
|
+
def ctx_for(name: str) -> Context:
|
|
140
|
+
return Context(
|
|
141
|
+
platform=name,
|
|
142
|
+
tokens=tokens,
|
|
143
|
+
sessions=sessions,
|
|
144
|
+
confirm=confirm and not draft_only,
|
|
145
|
+
headless=headless,
|
|
146
|
+
options={"email_subscribers": email_subscribers},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
pipe = Pipeline(StateStore())
|
|
150
|
+
|
|
151
|
+
async def go():
|
|
152
|
+
if hasattr(target, "documents"):
|
|
153
|
+
return await pipe.run_series(target, adapters, ctx_for, publish=confirm and not draft_only)
|
|
154
|
+
return await pipe.run([target], adapters, ctx_for, publish=confirm and not draft_only)
|
|
155
|
+
|
|
156
|
+
report = asyncio.run(go())
|
|
157
|
+
console.print(f"\n[bold]{report.run_id}[/]")
|
|
158
|
+
console.print(report.human())
|
|
159
|
+
if not confirm and not draft_only:
|
|
160
|
+
console.print("\n[yellow]Drafts only — nothing is public. Re-run with --confirm to publish.[/]")
|
|
161
|
+
raise typer.Exit(0 if report.ok else 1)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------ status
|
|
165
|
+
@app.command()
|
|
166
|
+
def status(as_json: bool = typer.Option(False, "--json")):
|
|
167
|
+
"""What exists where, and whether it is published."""
|
|
168
|
+
store = StateStore()
|
|
169
|
+
rows = store._conn.execute(
|
|
170
|
+
"SELECT document_id, platform, url, published, updated_at FROM remotes ORDER BY document_id"
|
|
171
|
+
).fetchall()
|
|
172
|
+
if as_json:
|
|
173
|
+
print(json.dumps([dict(r) for r in rows], indent=2))
|
|
174
|
+
return
|
|
175
|
+
t = RichTable("document", "platform", "state", "url")
|
|
176
|
+
for r in rows:
|
|
177
|
+
t.add_row(
|
|
178
|
+
r["document_id"],
|
|
179
|
+
r["platform"],
|
|
180
|
+
"published" if r["published"] else "draft",
|
|
181
|
+
r["url"] or "",
|
|
182
|
+
)
|
|
183
|
+
console.print(t)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command()
|
|
187
|
+
def platforms():
|
|
188
|
+
"""List available adapters and what they can do."""
|
|
189
|
+
t = RichTable("platform", "kind", "tables", "images", "tags", "threads")
|
|
190
|
+
for name, adapter in list_adapters():
|
|
191
|
+
c = adapter.capabilities
|
|
192
|
+
t.add_row(
|
|
193
|
+
name,
|
|
194
|
+
"browser" if c.image_upload == "browser_paste" else "api",
|
|
195
|
+
"yes" if c.tables else "no → images",
|
|
196
|
+
c.image_upload,
|
|
197
|
+
f"{c.tags.max_count}×{c.tags.max_len}" if c.tags else "—",
|
|
198
|
+
"yes" if c.threads else "—",
|
|
199
|
+
)
|
|
200
|
+
console.print(t)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# -------------------------------------------------------------------- init
|
|
204
|
+
@app.command()
|
|
205
|
+
def init(
|
|
206
|
+
path: Path = typer.Argument(Path("."), help="Where to scaffold"),
|
|
207
|
+
series: bool = typer.Option(False, help="Include a two-part series example"),
|
|
208
|
+
force: bool = typer.Option(False, help="Overwrite existing files"),
|
|
209
|
+
):
|
|
210
|
+
"""Scaffold a content repo that validates on the first try."""
|
|
211
|
+
from .scaffold import init_repo
|
|
212
|
+
|
|
213
|
+
written = init_repo(path, series=series, force=force)
|
|
214
|
+
if not written:
|
|
215
|
+
console.print("[yellow]nothing written — files already exist (use --force)[/]")
|
|
216
|
+
raise typer.Exit(1)
|
|
217
|
+
for p in written:
|
|
218
|
+
console.print(f" [green]+[/] {p}")
|
|
219
|
+
console.print(
|
|
220
|
+
f"\nNext:\n"
|
|
221
|
+
f" pubkit validate {path / 'content'}\n"
|
|
222
|
+
f" pubkit plan {path / 'content'} --to medium,devto\n"
|
|
223
|
+
f" pubkit auth login devto"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.command()
|
|
228
|
+
def doctor():
|
|
229
|
+
"""Diagnose the environment. Run this before opening an issue."""
|
|
230
|
+
from .scaffold import diagnose
|
|
231
|
+
|
|
232
|
+
findings = diagnose()
|
|
233
|
+
t = RichTable("", "check", "state")
|
|
234
|
+
problems = []
|
|
235
|
+
for f in findings:
|
|
236
|
+
t.add_row("[green]✓[/]" if f.ok else "[red]✗[/]", f.label, f.detail)
|
|
237
|
+
if not f.ok and f.fix:
|
|
238
|
+
problems.append((f.label, f.fix))
|
|
239
|
+
console.print(t)
|
|
240
|
+
for label, fix in problems:
|
|
241
|
+
console.print(f"[yellow]{label}[/]: " + fix.replace("[", "\\["))
|
|
242
|
+
console.print(f"\npubkit {__version__}")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# -------------------------------------------------------------------- auth
|
|
246
|
+
@auth_app.command("login")
|
|
247
|
+
def auth_login(
|
|
248
|
+
platform: str = typer.Argument(...),
|
|
249
|
+
token: str = typer.Option(None, "--token", help="API platforms only. Prefer stdin."),
|
|
250
|
+
):
|
|
251
|
+
"""Authenticate.
|
|
252
|
+
|
|
253
|
+
API platforms: paste a token (it goes to your OS keychain).
|
|
254
|
+
Browser platforms: a real browser window opens and you sign in yourself —
|
|
255
|
+
pubkit stores only the resulting session, never a password.
|
|
256
|
+
"""
|
|
257
|
+
tokens = TokenStore()
|
|
258
|
+
adapter = build_adapter(platform)
|
|
259
|
+
|
|
260
|
+
if adapter.capabilities.image_upload != "browser_paste" and adapter.name not in ("substack",):
|
|
261
|
+
if token is None:
|
|
262
|
+
token = typer.prompt(f"{platform} API token", hide_input=True)
|
|
263
|
+
tokens.set(platform, token.strip())
|
|
264
|
+
console.print(f"[green]stored {platform} token in the system keychain[/]")
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
from .browserctl import interactive_login
|
|
268
|
+
|
|
269
|
+
console.print(
|
|
270
|
+
f"Opening a browser window for {platform}. Sign in there — including MFA — "
|
|
271
|
+
"and pubkit will save the session when it sees you are logged in."
|
|
272
|
+
)
|
|
273
|
+
asyncio.run(interactive_login(platform, SessionStore()))
|
|
274
|
+
console.print(f"[green]saved {platform} session (encrypted)[/]")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@auth_app.command("logout")
|
|
278
|
+
def auth_logout(platform: str = typer.Argument(...)):
|
|
279
|
+
TokenStore().delete(platform)
|
|
280
|
+
SessionStore().forget(platform)
|
|
281
|
+
console.print(f"[green]forgot all {platform} credentials[/]")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@auth_app.command("list")
|
|
285
|
+
def auth_list():
|
|
286
|
+
tokens, sessions = TokenStore(), SessionStore()
|
|
287
|
+
t = RichTable("platform", "token", "session")
|
|
288
|
+
for name, _ in list_adapters():
|
|
289
|
+
t.add_row(
|
|
290
|
+
name,
|
|
291
|
+
"yes" if tokens.get(name) else "—",
|
|
292
|
+
"yes" if sessions.exists(name) else "—",
|
|
293
|
+
)
|
|
294
|
+
console.print(t)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def main() -> None: # pragma: no cover
|
|
298
|
+
try:
|
|
299
|
+
app()
|
|
300
|
+
except KeyboardInterrupt:
|
|
301
|
+
console.print("\n[yellow]interrupted — re-run the same command to resume[/]")
|
|
302
|
+
sys.exit(130)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
if __name__ == "__main__": # pragma: no cover
|
|
306
|
+
main()
|
pubkit/core/__init__.py
ADDED