Metadata-Version: 2.5
Name: article
Version: 0.1.4
Summary: Tools to semi-automate the publication of articles
Project-URL: Homepage, https://github.com/thorwhalen/article
Project-URL: Repository, https://github.com/thorwhalen/article
Project-URL: Documentation, https://thorwhalen.github.io/article
Author: Thor Whalen
License-Expression: MIT
License-File: LICENSE
Keywords: automation,canonical-url,dev.to,hashnode,medium,publishing,seo,substack,syndication
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Text Processing :: Markup
Requires-Python: >=3.10
Requires-Dist: cw<0.2,>=0.1.1
Requires-Dist: httpx
Requires-Dist: pydantic-settings>=2
Requires-Dist: pydantic>=2
Requires-Dist: python-dotenv
Requires-Dist: pyyaml
Provides-Extra: completion
Requires-Dist: argcomplete; extra == 'completion'
Provides-Extra: dev
Requires-Dist: pytest-cov>=4.0; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: python-substack<0.11,>=0.10; extra == 'dev'
Requires-Dist: ruff>=0.1.0; extra == 'dev'
Provides-Extra: docs
Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
Requires-Dist: sphinx>=6.0; extra == 'docs'
Provides-Extra: substack
Requires-Dist: markdown-it-py>=3; extra == 'substack'
Requires-Dist: mdit-py-plugins>=0.4; extra == 'substack'
Requires-Dist: python-substack<0.11,>=0.10; extra == 'substack'
Description-Content-Type: text/markdown

# article

Tools to semi-automate the publication of articles.

Write an article **once** as a JSON file. Publish it to a **primary** platform
(Substack), then **syndicate** it to secondary platforms (Medium, Dev.to,
Hashnode) — each pointing its canonical URL back to the primary, so you get
cross-platform reach without an SEO duplicate-content penalty.

```bash
pip install 'article[substack]'
article draft-substack essay.md            # an UNPUBLISHED Substack draft
```

`draft-substack` turns a Markdown file into a Substack draft: `[^n]` footnotes, `![alt](src "caption")` captions, `$...$` LaTeX and local images (uploaded to Substack) all survive. **It never publishes**: you review the draft in the Substack editor and press Publish there. Add `--dry-run` to check and convert without credentials or network.

## Markdown to a Substack draft

The essay is a plain `.md` file. The title is a `title:` in front matter, or else the `# Heading` (removed from the body, since Substack shows the title itself) when there is exactly one, or when the first of several opens the file; the slug is derived from the title unless front matter sets `slug:`. Front matter can also set `subtitle`, `tags` and `description`:

```markdown
---
subtitle: Why everyone agrees and nobody agrees
tags: [agile, ai]
---
# The Agile Religion

A claim.[^1]

![The tower of Babel](img/babel.jpg "Bruegel, The Tower of Babel (1563)")

[^1]: The footnote text.
```

Image paths resolve against the Markdown file's directory. Before anything is uploaded, the command refuses (listing every case, with the line) whatever Substack would silently lose:

| Construct | Why | What to do |
|---|---|---|
| A table | Substack has no table node | `--tables code` keeps it as a monospace code block; or render it to an image (in Python, pass `tables=` a function returning the image Markdown) |
| An image in a paragraph with text | the converter keeps only the alt text | give the image its own paragraph; caption goes in the title slot |
| A missing local image | nothing to upload | fix the path |
| A raw HTML block | the converter drops it | rewrite it in Markdown |
| A `data:` image | cannot be uploaded | save it as a file |
| A footnote that is never referenced, a `[^x]` with no definition, or a reference inside a footnote | dropped, left as text, or misnumbered | fix the reference |

```bash
article draft-substack essay.md --dry-run          # what survives, no credentials needed
article draft-substack essay.md                    # create the draft; prints its edit link
article draft-substack essay.md --tables code      # keep tables as code blocks
```

The draft's future public URL (`<publication>/p/<slug>`) is recorded as the canonical URL, so `syndicate-secondary` can point the other platforms at it.

### Substack credentials

Substack has no official write API; this uses the unofficial [python-substack](https://github.com/ma2za/python-substack) client, which talks to undocumented endpoints that can change. Put one of these in `.env` (never commit it):

- `SUBSTACK_COOKIES`: the cookie header from a logged-in browser session (developer tools, Network tab, any `substack.com` request, the `cookie` request header). The most reliable route, since sign-in by email link or captcha cannot be scripted.
- `SUBSTACK_COOKIES_PATH`: a JSON file of cookies (`{"substack.sid": "...", ...}`).
- `SUBSTACK_EMAIL` and `SUBSTACK_PASSWORD`, for accounts with a password.

Set `SUBSTACK_PUBLICATION_URL` (e.g. `https://you.substack.com`) if your account has more than one publication. The live smoke test (`tests/test_substack_live.py`) runs only when these are set; it creates one draft and deletes it.

## The single source of truth

The Substack public URL captured in phase 1 is the SSOT. It is stored
(keyed by article slug) and injected as `canonical_url` into every secondary
platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
back at the original.

```
author.json ──► publish-primary ──► Substack ──► live URL ─┐
                                                           │ stored as canonical_url
                                                           ▼  (pipeline_state.json, by slug)
                          syndicate-secondary ──► Medium / Dev.to / Hashnode
                                                  each canonical → Substack URL
```

The two phases are deliberately separate commands so you can syndicate later
(e.g. `+3 days`) on your own schedule. Phase 1 creates a Substack **draft**;
publish it in the Substack editor before syndicating.

## Two-command flow

```bash
# 1. Author once, then publish to the primary (records the canonical URL):
python -m article publish-primary article.json

# 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
python -m article syndicate-secondary article.json

# Subset / machine-readable output:
python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
```

After `pip install`, the `article` console script is equivalent
(`article publish-primary article.json`).

## The article JSON

Instead of a Markdown file, an article can be one JSON file. One standardized file is the single source of truth for content **and** which
platforms to publish to — a platform is published only if it appears under
`platforms` (see [`examples/article.example.json`](examples/article.example.json)):

```json
{
  "title": "Designing an SSOT Publishing Pipeline",
  "slug": "ssot-publishing-pipeline",
  "content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
  "tags": ["python", "automation", "seo"],
  "platforms": {
    "substack": { "tables": "error" },
    "medium":   { "publish_as_draft": true },
    "dev_to":   { "publish_as_draft": true },
    "hashnode": { "publish_as_draft": true }
  }
}
```

## Configuration

Secrets and settings load from `.env` (see
[`.env.example`](.env.example)) — copy it and fill in what you use:

```bash
cp .env.example .env
```

Every platform is optional; omit a platform's secrets (or its `platforms`
entry) to skip it.

## Using it from Python

The CLI is a thin wrapper over the same functions, so everything is callable
programmatically:

```python
import asyncio
from article import load_article, publish_primary, syndicate_secondary

art = load_article("article.json")
asyncio.run(publish_primary(art))  # phase 1 → records canonical_url
asyncio.run(syndicate_secondary(art))  # phase 2 → canonical SEO everywhere
```

## Adding a platform

Adapters are registered in an open-closed registry. To add a platform, write
one async function satisfying the `PublishAdapter` protocol and register it —
no core changes:

```python
from article import register_adapter, PublishResult
from article.util import require_canonical_url


@register_adapter("my_platform")
async def publish(article, *, canonical_url, config, secrets):
    require_canonical_url("my_platform", canonical_url)  # SSOT is never optional
    # … call the platform's API, injecting canonical_url …
    return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
```

## Architecture

| Layer | Module | Responsibility |
|------|--------|----------------|
| Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
| Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
| State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
| Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
| Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
| Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
| CLI | `article/__main__.py` | `cw` dispatch of the two commands |

> **Status, per platform:**
>
> - **Substack**: real. Creates unpublished drafts through python-substack; never publishes.
> - **Medium**: honest manual step. Its API is closed to new integrations, so the adapter sends nothing and reports a `[todo]` result: open Medium's *Import a story* page (https://medium.com/p/import) and paste the published Substack URL, which sets the canonical link.
> - **Dev.to, Hashnode**: still stubs; they build the payload (with the canonical field) but make no network call.
