Skip to content

Commit e3a6071

Browse files
snapsynapseclaude
andcommitted
chore(release): 2.0.0
Major release restructuring the project from single-file script to installable package, shipping paywall detection, concurrency, resume, logging, CI, and a 58-test suite. Breaking changes: - Flat substack2md.py removed; invoke via `substack2md` console script or `python -m substack2md` after `pip install .`. - requirements.txt and tests/requirements-dev.txt removed; use pyproject.toml (`pip install -e ".[dev]"` for dev). CHANGELOG.md carries the full release notes and a migration table. README: expanded the launch-browser.sh section with what it does, security properties (isolated profile, loopback-only port), and a pointer for non-macOS users; frontmatter example bumped to v2.0.0. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 6b8ee72 commit e3a6071

3 files changed

Lines changed: 60 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66

77
## [Unreleased]
88

9-
### Added
10-
- CI: GitHub Actions workflow running `pytest tests/` on push and pull request across Python 3.10, 3.11, 3.12, 3.13.
11-
- `CONTRIBUTING.md` with local dev setup, PR guidelines, and test instructions.
12-
- `CHANGELOG.md` (this file).
9+
_Nothing yet._
1310

14-
### Changed
15-
- Centralized the version string into a single `__version__` module constant. All `source:` frontmatter lines now derive from it instead of three hardcoded copies.
16-
- README: documented all four known Substack `audience` enum values (`everyone`, `only_free`, `only_paid`, `founding`) and the "unknown tier -> `is_paid=null`" contract.
17-
- README: fixed placeholder clone URL (`yourusername` -> `snapsynapse`).
11+
## [2.0.0] - 2026-04-16
1812

19-
### Fixed
20-
- Replaced deprecated `datetime.utcnow()` with `datetime.now(timezone.utc)` so Python 3.12+ runs without `DeprecationWarning` and Python 3.14 (which removes `utcnow`) still works.
13+
Major release. Restructures the project from a single-file script into an installable package with a console entry point, ships a much larger feature set, and tightens every user-facing surface. All Python-level library imports (`import substack2md; substack2md.fetch_paywall_status(...)`) remain backward compatible.
14+
15+
### Breaking changes
16+
17+
- **Removed the flat `substack2md.py` file.** Invocation has moved from `python substack2md.py URL` to the installed console script `substack2md URL` (or `python -m substack2md URL`). After `pip install .` the CLI is on your PATH.
18+
- **Removed `requirements.txt` and `tests/requirements-dev.txt`.** `pyproject.toml` is now the single source of truth for dependencies. Use `pip install .` or `pip install -e ".[dev]"` instead.
19+
20+
### Migration
2121

22-
## [1.2.0] - 2026-04-16
22+
| v1.x | v2.0.0 |
23+
|-|-|
24+
| `git clone && pip install -r requirements.txt` | `git clone && pip install .` |
25+
| `python substack2md.py URL` | `substack2md URL` |
26+
| `python substack2md.py --urls-file urls.txt` | `substack2md --urls-file urls.txt` |
27+
| `pip install -r tests/requirements-dev.txt` | `pip install -e ".[dev]"` |
2328

2429
### Added
25-
- `--detect-paywall` CLI flag (from #1, @drewid74): queries Substack's public `/api/v1/posts/{slug}` API to classify posts as free or subscriber-only. Adds `is_paid` (bool) and `audience` (str) to YAML frontmatter. Opt-in, graceful fallback to `null` on API errors, no additional authentication required.
26-
- Test suite: 30 tests under `tests/` covering audience decoding, HTTP failure modes, request shape, frontmatter serialization, CLI wiring, and publication-slug edge cases. Opt-in live smoke test under `SUBSTACK2MD_LIVE=1`.
27-
- `tests/EVALS.md` documenting the paywall-detection eval report.
30+
31+
- **`--detect-paywall` flag** (originally #1 from @drewid74): queries Substack's public `/api/v1/posts/{slug}` API to classify posts as free or subscriber-only. Writes `is_paid` and `audience` fields to YAML frontmatter. Opt-in, graceful fallback to `null` on API errors, no additional authentication required.
32+
- **`--concurrency N` flag**: opt-in parallel processing. Defaults to 1 (sequential). Posts from the same publication are still serialized via per-host locks to avoid bot heuristics; parallelism is across different publications only.
33+
- **Resume-from-interrupt**: every successfully written URL is appended to `<base-dir>/.substack2md-state`. Subsequent runs skip already-completed URLs before any network call. Pass `--no-resume` to disable. Clean `KeyboardInterrupt` handling reports progress before exit.
34+
- **`--log-level {DEBUG,INFO,WARNING,ERROR}`, `--quiet`/`-q`, `--version` flags**. Diagnostics now flow through the `logging` module; `[ok]`/`[skip]` progress becomes `INFO`-level so `--quiet` can suppress them cleanly.
35+
- **Teaser-warning detection**: when `--detect-paywall` reports a paid post and the extracted body is under 300 words, substack2md logs a warning that you may have only captured the teaser and need to authenticate in the CDP-connected browser.
36+
- **Custom-domain Substack support**: publications with custom domains (e.g. stratechery.com) now route paywall API calls to their canonical `<pub>.substack.com` subdomain via the new `resolve_substack_canonical()` helper.
37+
- **Richer tag extraction**: merges `<meta name="keywords">`, ld+json `keywords`, and ld+json `articleSection` before normalization, so posts get the author's real taxonomy instead of just `["substack"]`.
38+
- **`--from-md` + `--detect-paywall`**: backfill paywall metadata on existing markdown archives without re-fetching HTML.
39+
- **`launch-browser.sh`**: macOS helper that detects Brave or Chrome, isolates a dedicated CDP profile at `$HOME/.*-cdp-profile`, opens port 9222 on loopback, and verifies the endpoint before exiting.
40+
- **CI via GitHub Actions**: `pytest` and `ruff` run on push and PR across Python 3.10, 3.11, 3.12, 3.13.
41+
- **Test suite**: 58 tests covering audience decoding, HTTP failure modes, request shape, frontmatter serialization, CLI wiring, publication-slug edges, canonical resolution, tag extraction, teaser warning, resume state, and concurrency.
42+
- **Docs**: `CONTRIBUTING.md`, `SECURITY.md`, `CHANGELOG.md`, and `tests/EVALS.md`.
43+
44+
### Changed
45+
46+
- **Package layout**: `substack2md.py``substack2md/` package with `_core.py` (library), `cli.py` (pipeline + main), `_version.py` (single-source version), `__main__.py` (enables `python -m substack2md`).
47+
- **`pyproject.toml`**: registers `substack2md` as a console script, declares runtime and dev dependencies, sets Python ≥ 3.10, wires the `ruff` lint/format config and the `pytest` config.
48+
- **README**: rewritten installation and Quick Start for the package flow; full and current CLI reference; paywall section documents all four `audience` enum values; links to `launch-browser.sh` and `CONTRIBUTING.md`.
49+
- **Logging**: `print(..., file=sys.stderr)` replaced with the `substack2md` logger. Formatter includes level + logger name so downstream aggregators can filter.
50+
- **Code style**: full `ruff` format pass applied. 100-char soft limit, isort-sorted imports, modern type-hint syntax under `UP`.
2851

2952
### Fixed
30-
- Founding-tier posts (Substack `audience: founding`) are now correctly classified as `is_paid: true`. Previously matched only `only_paid` exactly, silently leaking paid content as free.
31-
- Missing `audience` field in a 200 response now returns `(is_paid=None, audience=None)` instead of defaulting to `"everyone"/False`. Matches the docstring's null-on-uncertainty promise.
32-
- Unknown audience values (future Substack tiers) are preserved verbatim as `audience` but `is_paid` is left as `null` so downstream workflows treat the post as "status unknown" rather than silently free.
53+
54+
- **Founding-tier posts** (`audience: founding`) are now correctly classified as `is_paid: true`. Previously matched only `only_paid` exactly, which silently leaked paid content as free.
55+
- **Missing `audience` field** in a 200 response now returns `(is_paid=None, audience=None)` instead of defaulting to `"everyone"`/`False`. Matches the documented null-on-uncertainty contract.
56+
- **Unknown audience values** (future Substack tiers) are preserved verbatim as `audience` but `is_paid` is left as `null` so downstream treats the post as "status unknown" rather than silently free.
57+
- **`datetime.utcnow()` deprecation**: swapped for `datetime.now(timezone.utc)` so Python 3.12+ runs without `DeprecationWarning` and Python 3.14 (which removes `utcnow`) works.
58+
- **CDP target leak**: `CDPClient.fetch_html` now wraps navigate/eval in `try/finally` so `Target.closeTarget` always runs, preventing tab-pool exhaustion during long batches.
59+
- **`--timeout` not threaded through to paywall API**: the CLI `--timeout` value now reaches `fetch_paywall_status` instead of being hardcoded to 10s.
3360

3461
## [1.1.0] - prior
3562

3663
First tagged reference point. CDP-driven Substack-to-markdown converter with Obsidian wikilink rewriting, publication mapping, transcript cleanup, and batch URL file support.
64+
65+
[Unreleased]: https://github.com/snapsynapse/substack2md/compare/v2.0.0...HEAD
66+
[2.0.0]: https://github.com/snapsynapse/substack2md/compare/v1.1.0...v2.0.0
67+
[1.1.0]: https://github.com/snapsynapse/substack2md/releases/tag/v1.1.0

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ The repo ships a helper that detects Brave or Chrome, isolates a dedicated CDP p
4747
./launch-browser.sh
4848
```
4949

50+
What it does:
51+
52+
- Prefers Brave; falls back to Chrome (arch-aware on Apple Silicon).
53+
- Creates an isolated browser profile at `$HOME/.brave-cdp-profile` or `$HOME/.chrome-cdp-profile` so your main browsing session, cookies, and extensions are untouched.
54+
- Binds `--remote-debugging-port=9222` to loopback only (`127.0.0.1`) and sets `--remote-allow-origins` so only local clients can connect.
55+
- If port 9222 is already in use, prompts before killing the existing process.
56+
- Verifies CDP is reachable after launch.
57+
58+
The script is macOS-only (uses `open` and `/Applications`). Linux or Windows users can use the manual invocations below, or submit a PR adding platform support.
59+
5060
Prefer to run the commands yourself? The underlying invocations are:
5161

5262
**Brave (Recommended):**
@@ -197,7 +207,7 @@ is_paid: false
197207
audience: "everyone"
198208
links_internal: 3
199209
links_external: 12
200-
source: "substack2md v1.2.0"
210+
source: "substack2md v2.0.0"
201211
---
202212
203213
Content starts here...

substack2md/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@
55
dependency check and would fail in a build-isolation environment).
66
"""
77

8-
__version__ = "1.2.0"
8+
__version__ = "2.0.0"

0 commit comments

Comments
 (0)