mdfetch 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.github/workflows/integration.yml +0 -3
- mdfetch-0.3.0/.specify/feature.json +3 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/memory/changelog.md +43 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/memory/plan.md +39 -14
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/memory/spec.md +99 -6
- {mdfetch-0.2.1 → mdfetch-0.3.0}/CLAUDE.md +9 -3
- {mdfetch-0.2.1 → mdfetch-0.3.0}/PKG-INFO +4 -2
- {mdfetch-0.2.1 → mdfetch-0.3.0}/README.md +2 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/pyproject.toml +2 -2
- mdfetch-0.3.0/specs/004-remove-backoff/checklists/requirements.md +34 -0
- mdfetch-0.3.0/specs/004-remove-backoff/plan.md +146 -0
- mdfetch-0.3.0/specs/004-remove-backoff/research.md +42 -0
- mdfetch-0.3.0/specs/004-remove-backoff/spec.md +92 -0
- mdfetch-0.3.0/specs/004-remove-backoff/tasks.md +147 -0
- mdfetch-0.3.0/specs/005-substack-provider/checklists/requirements.md +36 -0
- mdfetch-0.3.0/specs/005-substack-provider/contracts/extractor-api.md +59 -0
- mdfetch-0.3.0/specs/005-substack-provider/data-model.md +34 -0
- mdfetch-0.3.0/specs/005-substack-provider/plan.md +136 -0
- mdfetch-0.3.0/specs/005-substack-provider/quickstart.md +44 -0
- mdfetch-0.3.0/specs/005-substack-provider/research.md +75 -0
- mdfetch-0.3.0/specs/005-substack-provider/spec.md +117 -0
- mdfetch-0.3.0/specs/005-substack-provider/tasks.md +209 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/__init__.py +2 -2
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/base.py +6 -7
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/providers/medium.py +31 -1
- mdfetch-0.3.0/src/mdfetch/providers/substack.py +86 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +97 -146
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/from-drift-to-parity.md +19 -19
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/integration-digest-december-2025.md +5 -5
- mdfetch-0.3.0/tests/integration/snapshots/substack-api-trends-2025.md +99 -0
- mdfetch-0.3.0/tests/integration/snapshots/substack-kafka-topic-types.md +85 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/test_devto_integration.py +2 -7
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/test_medium_integration.py +2 -7
- mdfetch-0.3.0/tests/integration/test_substack_integration.py +51 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/test_fetch_errors.py +3 -24
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/test_medium_extractor.py +29 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/test_router.py +3 -3
- mdfetch-0.3.0/tests/unit/test_substack_extractor.py +278 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/uv.lock +1 -1
- mdfetch-0.2.1/.specify/feature.json +0 -3
- mdfetch-0.2.1/tests/integration/conftest.py +0 -17
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-analyze/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-checklist/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-clarify/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-constitution/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-implement/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-plan/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-specify/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-tasks/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.analyze.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.archive.run.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.checklist.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.clarify.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.constitution.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.implement.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.plan.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.reconcile.run.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.specify.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.tasks.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gemini/commands/speckit.taskstoissues.toml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.github/workflows/ci.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.github/workflows/publish.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.gitignore +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.python-version +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/.registry +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/archive/LICENSE +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/archive/README.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/archive/commands/archive.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/archive/extension.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/README.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/config-template.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/extension.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/git-config.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/reconcile/LICENSE +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/reconcile/README.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions/reconcile/extension.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/extensions.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/init-options.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/integration.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/integrations/claude.manifest.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/integrations/gemini.manifest.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/integrations/speckit.manifest.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/memory/constitution.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/scripts/bash/check-prerequisites.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/scripts/bash/common.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/scripts/bash/setup-plan.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/scripts/bash/setup-tasks.sh +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/templates/checklist-template.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/templates/constitution-template.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/templates/plan-template.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/templates/spec-template.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/templates/tasks-template.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/workflows/speckit/workflow.yml +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/.specify/workflows/workflow-registry.json +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/GEMINI.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/LICENSE +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/Makefile +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/research.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/checklists/requirements.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/contracts/public-api.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/data-model.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/plan.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/quickstart.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/research.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/spec.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/002-devto-provider/tasks.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/checklists/requirements.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/contracts/extract-api.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/plan.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/research.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/spec.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/specs/003-medium-freedium-fallback/tasks.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/exceptions.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/providers/__init__.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/providers/devto.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/src/mdfetch/router.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/__init__.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/conftest.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/__init__.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/__init__.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/test_devto_extractor.py +0 -0
- {mdfetch-0.2.1 → mdfetch-0.3.0}/tests/unit/test_silent.py +0 -0
|
@@ -2,6 +2,49 @@
|
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
+
### mdfetch — Substack Provider — 2026-05-15
|
|
6
|
+
|
|
7
|
+
**Branch**: `005-substack-provider`
|
|
8
|
+
**Spec**: specs/005-substack-provider
|
|
9
|
+
|
|
10
|
+
**What was added**:
|
|
11
|
+
- `SubstackExtractor` provider for `substack.com` and all `*.substack.com` subdomain articles, auto-discovered via `@register` decorator
|
|
12
|
+
- Article body isolation from `div.body.markup`; title prepended from `h1.post-title` in `div.post-header` (unconditional — structurally outside body); optional subtitle from `h3.subtitle` prepended after title
|
|
13
|
+
- `div.subscription-widget-wrap` stripped (inline subscribe CTAs and paywall terminal widget — achieves silent free-preview truncation for paywalled posts without error or marker)
|
|
14
|
+
- `<iframe>` and unknown `div[data-component-name]` embed containers converted to plain anchor links (consistent with dev.to embed handling)
|
|
15
|
+
- HTTP 429 retried via base-class fixed-delay retry (no `_no_retry_status_codes` override — no Freedium-style mirror for Substack)
|
|
16
|
+
- `UnsupportedContentTypeError` raised when `div.body.markup` is absent; `EmptyContentError` raised when body yields no extractable text
|
|
17
|
+
- 18 unit tests in `tests/unit/test_substack_extractor.py`; 3 integration tests in `tests/integration/test_substack_integration.py` with 2 snapshot golden files
|
|
18
|
+
- `test_router.py` unsupported-domain fixture updated from `substack.com` to `wordpress.com`
|
|
19
|
+
|
|
20
|
+
**New Components**:
|
|
21
|
+
- `src/mdfetch/providers/substack.py` — SubstackExtractor
|
|
22
|
+
- `tests/unit/test_substack_extractor.py` — 18 unit tests
|
|
23
|
+
- `tests/integration/test_substack_integration.py` — 3 integration tests
|
|
24
|
+
- `tests/integration/snapshots/substack-kafka-topic-types.md`
|
|
25
|
+
- `tests/integration/snapshots/substack-api-trends-2025.md`
|
|
26
|
+
|
|
27
|
+
**Tasks Completed**: 21/21
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
### mdfetch — Remove Exponential Backoff — 2026-05-15
|
|
32
|
+
|
|
33
|
+
**Branch**: `004-remove-backoff`
|
|
34
|
+
**Spec**: specs/004-remove-backoff
|
|
35
|
+
|
|
36
|
+
**What was added**:
|
|
37
|
+
- Fixed-delay retry behaviour: `fetch_html` now sleeps exactly `retry_delay` seconds between attempts (previously exponential `retry_delay × 2ⁿ`, capped at 60 s). The change makes retry timing predictable for callers and aligns with the Freedium fallback that already absorbs 403/429 errors.
|
|
38
|
+
- Removed `MDFETCH_RETRIES` and `MDFETCH_RETRY_DELAY` env-var support from integration test fixtures (`conftest.py` deleted) and CI workflow (`integration.yml`). Integration tests now hardcode `retries=3, retry_delay=2.0`.
|
|
39
|
+
- Deleted two unit tests (`test_exponential_backoff_sleep_sequence`, `test_exponential_backoff_capped_at_max_delay`) that verified the removed exponential schedule. Added sleep-value assertion to `test_status_code_not_in_no_retry_set_still_retries` to close the FR-029 gap.
|
|
40
|
+
|
|
41
|
+
**New Components**:
|
|
42
|
+
- No new files. Pure removal: `tests/integration/conftest.py` deleted; `_MAX_RETRY_DELAY` constant removed from `src/mdfetch/base.py`.
|
|
43
|
+
|
|
44
|
+
**Tasks Completed**: 16/16
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
5
48
|
### mdfetch — Medium Freedium Fallback — 2026-05-15
|
|
6
49
|
|
|
7
50
|
**Branch**: `003-medium-freedium-fallback`
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# mdfetch — Main Implementation Plan
|
|
2
2
|
|
|
3
|
-
**Last Updated**: 2026-05-
|
|
4
|
-
**Sources**: [specs/001-mdfetch-medium-extractor/plan.md], [specs/002-devto-provider/plan.md]
|
|
3
|
+
**Last Updated**: 2026-05-15
|
|
4
|
+
**Sources**: [specs/001-mdfetch-medium-extractor/plan.md], [specs/002-devto-provider/plan.md], [specs/003-medium-freedium-fallback/plan.md], [specs/004-remove-backoff/plan.md], [specs/005-substack-provider/plan.md]
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -41,6 +41,7 @@ BaseExtractor (ABC) — src/mdfetch/base.py
|
|
|
41
41
|
├── _no_retry_status_codes: frozenset[int] = frozenset() — codes that skip retry; overridden by providers
|
|
42
42
|
├── fetch_html(url, *, retries, retry_delay, _no_retry_codes=None) → str
|
|
43
43
|
│ — streaming HTTP, 30s timeout, 10 MB cap;
|
|
44
|
+
│ fixed delay of retry_delay seconds between attempts (not exponential);
|
|
44
45
|
│ codes in _no_retry_codes (or class attribute) raise immediately
|
|
45
46
|
├── clean_html(soup) → Tag — abstract: platform-specific HTML isolation
|
|
46
47
|
├── convert_to_markdown(tag) → str— abstract: platform-specific Markdown conversion
|
|
@@ -62,6 +63,17 @@ DevToExtractor(BaseExtractor) — src/mdfetch/providers/devto.py
|
|
|
62
63
|
│ replaces iframes and ltag embeds with anchor links; strips empty anchor-name
|
|
63
64
|
│ elements; prepends h1 + cover image from crayons-article__header
|
|
64
65
|
└── convert_to_markdown() → markdownify with ATX headings; collapses 3+ newlines to 2
|
|
66
|
+
|
|
67
|
+
SubstackExtractor(BaseExtractor) — src/mdfetch/providers/substack.py [005-substack-provider]
|
|
68
|
+
├── DOMAINS = frozenset({"substack.com"}) — also matches *.substack.com via suffix routing
|
|
69
|
+
├── _no_retry_status_codes = frozenset() — HTTP 429 retried (no Freedium-style fallback)
|
|
70
|
+
├── clean_html() → locates div.body.markup; raises UnsupportedContentTypeError if absent;
|
|
71
|
+
│ strips div.subscription-widget-wrap (inline CTAs + paywall terminal);
|
|
72
|
+
│ replaces iframes with anchor links using src/data-src;
|
|
73
|
+
│ replaces div[data-component-name] (except SubscribeWidget, Image2ToDOM) with anchor links;
|
|
74
|
+
│ prepends h3.subtitle from div.post-header (if present);
|
|
75
|
+
│ prepends h1.post-title from div.post-header (unconditional — structurally outside body)
|
|
76
|
+
└── convert_to_markdown() → markdownify with ATX headings; collapses 3+ newlines to 2; raises EmptyContentError if empty
|
|
65
77
|
```
|
|
66
78
|
|
|
67
79
|
### Router / Auto-Discovery
|
|
@@ -98,7 +110,8 @@ src/
|
|
|
98
110
|
└── providers/
|
|
99
111
|
├── __init__.py # Empty — auto-discovery handles registration
|
|
100
112
|
├── medium.py # MediumExtractor
|
|
101
|
-
|
|
113
|
+
├── devto.py # DevToExtractor [002-devto-provider]
|
|
114
|
+
└── substack.py # SubstackExtractor [005-substack-provider]
|
|
102
115
|
|
|
103
116
|
tests/
|
|
104
117
|
├── unit/
|
|
@@ -106,17 +119,21 @@ tests/
|
|
|
106
119
|
│ ├── test_medium_extractor.py
|
|
107
120
|
│ ├── test_fetch_errors.py
|
|
108
121
|
│ ├── test_silent.py
|
|
109
|
-
│
|
|
122
|
+
│ ├── test_devto_extractor.py # [002-devto-provider]
|
|
123
|
+
│ └── test_substack_extractor.py # [005-substack-provider]
|
|
110
124
|
└── integration/
|
|
111
125
|
├── snapshots/ # Golden Markdown files (article body snapshots)
|
|
112
126
|
│ ├── from-drift-to-parity.md
|
|
113
127
|
│ ├── architecting-the-asynchronous-agent.md
|
|
114
128
|
│ ├── integration-digest-december-2025.md
|
|
115
|
-
│ ├── devto-integration-digest-december-2025.md
|
|
116
|
-
│ ├── devto-integration-digest-july-2025.md
|
|
117
|
-
│
|
|
129
|
+
│ ├── devto-integration-digest-december-2025.md # [002-devto-provider]
|
|
130
|
+
│ ├── devto-integration-digest-july-2025.md # [002-devto-provider]
|
|
131
|
+
│ ├── devto-integration-digest-march-2026.md # [002-devto-provider]
|
|
132
|
+
│ ├── substack-kafka-topic-types.md # [005-substack-provider]
|
|
133
|
+
│ └── substack-api-trends-2025.md # [005-substack-provider]
|
|
118
134
|
├── test_medium_integration.py
|
|
119
|
-
|
|
135
|
+
├── test_devto_integration.py # [002-devto-provider]
|
|
136
|
+
└── test_substack_integration.py # [005-substack-provider]
|
|
120
137
|
|
|
121
138
|
specs/ # Speckit feature specifications
|
|
122
139
|
pyproject.toml # hatchling build backend, uv package manager
|
|
@@ -139,18 +156,20 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
139
156
|
|
|
140
157
|
## Testing Strategy
|
|
141
158
|
|
|
142
|
-
**Unit tests** (
|
|
159
|
+
**Unit tests** (84 tests, offline):
|
|
143
160
|
- Router: domain routing, subdomain suffix matching, duplicate registration, invalid URLs, unsupported platforms
|
|
144
161
|
- MediumExtractor: clean_html, convert_to_markdown, empty content, non-article pages, _parse_freedium (heading remap, missing main-content), fallback on 403/429 (URL construction, exc.url contract, no-sleep on 429), no-fallback on 200, UnsupportedContentTypeError.url on Freedium path [003-medium-freedium-fallback]
|
|
145
162
|
- DevToExtractor: clean_html (title/cover/heading/image preservation, iframe/ltag embed→link, anchor stripping, non-article error), convert_to_markdown (headings/code/lists/images, no raw HTML, empty content error) [002-devto-provider]
|
|
163
|
+
- SubstackExtractor: routing (subdomain + root domain + _no_retry_status_codes assertion), clean_html (body.markup tag return, subscription-widget strip, title prepend, subtitle prepend, prose preservation, iframe→anchor), convert_to_markdown (title heading, no triple blank lines, image syntax, link preservation), paywalled post (non-empty, Subscribe text absent, free preview present), error cases (UnsupportedContentTypeError on no body, EmptyContentError on whitespace body) [005-substack-provider]
|
|
146
164
|
- Fetch errors: HTTP 404, 503, timeout, connection error, size limit exceeded; `_no_retry_status_codes` immediate-raise + `_no_retry_codes` override [003-medium-freedium-fallback]
|
|
147
165
|
- Silent: no stdout/stderr output, no logging during extraction
|
|
148
166
|
|
|
149
|
-
**Integration tests** (
|
|
167
|
+
**Integration tests** (9 tests, network required):
|
|
150
168
|
- Parametrized over 3 real stn1slv.medium.com articles (including a known paywalled URL that exercises the Freedium fallback when medium.com returns 403) [003-medium-freedium-fallback]
|
|
151
169
|
- Parametrized over 3 real dev.to/stn1slv articles [002-devto-provider]
|
|
152
|
-
-
|
|
153
|
-
-
|
|
170
|
+
- Parametrized over 2 real Substack articles + 1 homepage error test (`UnsupportedContentTypeError`) [005-substack-provider]
|
|
171
|
+
- Snapshot-based containment check: `expected_body in extracted_result` — tests pass regardless of which source served the content
|
|
172
|
+
- 3 retries with 2-second **fixed** delay on `FetchError` (hardcoded; not env-var configurable) [004-remove-backoff]
|
|
154
173
|
- Run with: `make integration` or `uv run pytest tests/integration/ --override-ini=addopts=`
|
|
155
174
|
- Excluded from default `pytest` run via `addopts = "-m 'not integration'"` in pyproject.toml
|
|
156
175
|
|
|
@@ -186,9 +205,15 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
186
205
|
| Routing | `pkgutil.iter_modules` auto-discovery + `@register` | SC-006: one new file = one new platform |
|
|
187
206
|
| Integration tests | Snapshot containment + retry | Durable against minor HTML changes; resilient to transient 403s |
|
|
188
207
|
| test_router.py domain example | Changed from `dev.to` to `substack.com` for "unsupported domain" test | Once DevToExtractor registers `dev.to`, those tests would no longer raise UnsupportedPlatformError | [002-devto-provider]
|
|
208
|
+
| test_router.py domain example | Changed from `substack.com` to `wordpress.com` for "unsupported domain" test | Once SubstackExtractor registers `substack.com`, those tests would no longer raise UnsupportedPlatformError | [005-substack-provider]
|
|
209
|
+
| Substack article targeting | `div.body.markup` as extraction root | Contains article prose only; `div.available-content` is a transparent wrapper; `div.post-footer` and `div.visibility-check` are sibling elements never encountered when using body as root | [005-substack-provider]
|
|
210
|
+
| Substack title prepend | Unconditional prepend of `h1.post-title` from `div.post-header` | Structurally guaranteed outside `div.body.markup`; section headings use distinct class `header-anchor-post` — no deduplication needed | [005-substack-provider]
|
|
211
|
+
| Substack subtitle | Prepend `h3.subtitle` after title (inserted at index 0 first, then title at index 0 displaces it to index 1) | Author intent preserved; subtitle rendered as `###` heading | [005-substack-provider]
|
|
212
|
+
| Substack HTTP 429 | No `_no_retry_status_codes` override — base class `frozenset()` applies | Unlike Medium, Substack has no Freedium-style mirror; retry is the correct fallback | [005-substack-provider]
|
|
189
213
|
| Medium 403/429 fallback | Override `extract()` in `MediumExtractor`; `_no_retry_status_codes=frozenset({403,429})` on class | Immediate fallback with no medium.com retries; `BaseExtractor` extended with `_no_retry_codes` param for thread safety | [003-medium-freedium-fallback]
|
|
190
214
|
| Freedium HTML parsing | Dedicated `_parse_freedium()` method; `div.main-content`; h4→h3 remap | Freedium HTML is structurally incompatible with `clean_html()` (no `<article>`); heading remap ensures snapshot tests pass for both paths | [003-medium-freedium-fallback]
|
|
191
215
|
| Freedium exc.url contract | `inner_exc.url = url` unconditionally; error message is source-agnostic ("Fallback page…") | Preserves transparent-fallback contract (FR-028); `exc.url` is the authoritative field; message content is internal | [003-medium-freedium-fallback]
|
|
216
|
+
| Retry strategy | Fixed delay (`retry_delay` seconds per attempt, unchanged between attempts) | Exponential backoff removed (PR #8); Freedium fallback absorbs 403/429 at a higher level making exponential growth unnecessary; integration tests use hardcoded defaults (`retries=3, retry_delay=2.0`) with no env-var override | [004-remove-backoff]
|
|
192
217
|
|
|
193
218
|
---
|
|
194
219
|
|
|
@@ -197,9 +222,9 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
197
222
|
- [x] Provider Pattern Architecture — `BaseExtractor` ABC with concrete `fetch_html` and abstract `clean_html`, `convert_to_markdown`; `MediumExtractor` inherits
|
|
198
223
|
- [x] Technology Stack — `httpx`, `beautifulsoup4`/`lxml`, `markdownify`, `pytest`, `ruff`; `uv` for all dev workflows
|
|
199
224
|
- [x] Coding Standards — PEP 8, strict type hints, `mypy --strict` passes
|
|
200
|
-
- [x] Integration Testing — real Medium URLs, snapshot-based containment assertions
|
|
225
|
+
- [x] Integration Testing — real Medium, dev.to, and Substack URLs, snapshot-based containment assertions
|
|
201
226
|
- [x] Packaging and Distribution — `pyproject.toml` + `src/` layout + `hatchling`; all Makefile targets use `uv run`
|
|
202
227
|
|
|
203
228
|
---
|
|
204
229
|
|
|
205
|
-
*Last Updated: 2026-05-15 | Sources appended: [specs/
|
|
230
|
+
*Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/plan.md], [specs/005-substack-provider/plan.md]*
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# mdfetch — Main Specification
|
|
2
2
|
|
|
3
|
-
**Last Updated**: 2026-05-
|
|
4
|
-
**Sources**: [specs/001-mdfetch-medium-extractor/spec.md], [specs/002-devto-provider/spec.md]
|
|
3
|
+
**Last Updated**: 2026-05-15
|
|
4
|
+
**Sources**: [specs/001-mdfetch-medium-extractor/spec.md], [specs/002-devto-provider/spec.md], [specs/003-medium-freedium-fallback/spec.md], [specs/004-remove-backoff/spec.md], [specs/005-substack-provider/spec.md]
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
`mdfetch` is a Python library that extracts article content from web platforms and returns it as clean, well-structured Markdown. The library enforces a provider pattern — an abstract base defines the extraction contract, and each supported platform is implemented as a separate, independent provider. Supported platforms: `medium.com` (and subdomains), `dev.to
|
|
10
|
+
`mdfetch` is a Python library that extracts article content from web platforms and returns it as clean, well-structured Markdown. The library enforces a provider pattern — an abstract base defines the extraction contract, and each supported platform is implemented as a separate, independent provider. Supported platforms: `medium.com` (and subdomains), `dev.to`, `substack.com` (and `*.substack.com` subdomains).
|
|
11
11
|
|
|
12
12
|
---
|
|
13
13
|
|
|
@@ -105,6 +105,52 @@ A developer calls the library's extract function for a publicly accessible Mediu
|
|
|
105
105
|
|
|
106
106
|
---
|
|
107
107
|
|
|
108
|
+
### US-010 — Predictable Fixed-Delay Retry Behaviour (P1)
|
|
109
|
+
[Source: specs/004-remove-backoff]
|
|
110
|
+
|
|
111
|
+
A developer calling `extract()` encounters a transient network error. The library retries using a simple, predictable fixed delay — exactly `retry_delay` seconds between every attempt — rather than an exponentially growing delay. The retry timing is constant and easy to reason about regardless of which attempt number is being made.
|
|
112
|
+
|
|
113
|
+
**Acceptance Scenarios**:
|
|
114
|
+
1. Given `fetch_html` is called with `retries=3` and `retry_delay=2.0`, when the first two attempts raise a transient error, then the library sleeps exactly `2.0` seconds before each retry (not `2.0` then `4.0`).
|
|
115
|
+
2. Given `fetch_html` is called with `retries=1`, when the attempt fails, then no sleep occurs and the exception is raised immediately.
|
|
116
|
+
3. Given a status code in `_no_retry_status_codes`, when that error is raised, then no sleep or retry occurs.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
### US-011 — Extract a Free Substack Article to Markdown (P1)
|
|
121
|
+
[Source: specs/005-substack-provider]
|
|
122
|
+
|
|
123
|
+
A developer calls `extract()` with a free public `*.substack.com/p/...` URL. The function fetches the article and returns its content as clean Markdown — with no subscription banners, navigation menus, author bios, share buttons, or page chrome.
|
|
124
|
+
|
|
125
|
+
**Acceptance Scenarios**:
|
|
126
|
+
1. Given a valid URL pointing to a free public Substack post, when `extract()` is called, then it returns a non-empty Markdown string containing the article title as a top-level heading followed by the body content.
|
|
127
|
+
2. Given a Substack post with multiple headings, paragraphs, lists, and inline links, when `extract()` is called, then the returned Markdown preserves all headings, paragraphs, lists, and hyperlinks while stripping subscription CTAs and navigation elements.
|
|
128
|
+
3. Given a Substack post containing images, when `extract()` is called, then images appear in the output as Markdown image syntax (``).
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### US-012 — Handle a Paywalled Substack Post Gracefully (P2)
|
|
133
|
+
[Source: specs/005-substack-provider]
|
|
134
|
+
|
|
135
|
+
A developer calls `extract()` with a URL for a subscriber-only Substack post. The function returns the visible free-preview content as Markdown without raising an error.
|
|
136
|
+
|
|
137
|
+
**Acceptance Scenarios**:
|
|
138
|
+
1. Given a Substack post that is subscriber-only, when `extract()` is called, then it returns the freely available preview section as Markdown without raising an exception.
|
|
139
|
+
2. Given a paywalled post whose free preview contains at least one paragraph, when `extract()` is called, then the output does not contain the paywall call-to-action text (e.g., "Subscribe to read the full post").
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### US-013 — Reject Non-Article Substack Pages (P3)
|
|
144
|
+
[Source: specs/005-substack-provider]
|
|
145
|
+
|
|
146
|
+
A developer accidentally passes a Substack URL that does not point to an article (e.g., a publication homepage). The function raises a typed exception rather than returning empty or garbage Markdown.
|
|
147
|
+
|
|
148
|
+
**Acceptance Scenarios**:
|
|
149
|
+
1. Given a Substack publication homepage URL, when `extract()` is called, then `UnsupportedContentTypeError` is raised.
|
|
150
|
+
2. Given a Substack post whose extractable text is empty after stripping all chrome, when `extract()` is called, then `EmptyContentError` is raised.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
108
154
|
### US-006 — Integration Tests Pass Against Real dev.to Article URLs (P3)
|
|
109
155
|
[Source: specs/002-devto-provider]
|
|
110
156
|
|
|
@@ -135,6 +181,7 @@ A developer runs the integration test suite and all dev.to integration tests pas
|
|
|
135
181
|
### Network
|
|
136
182
|
- **FR-006**: The library MUST raise a descriptive error when a network request fails (connection error, timeout, non-2xx HTTP status).
|
|
137
183
|
- **FR-014**: HTTP requests MUST use a standard browser-like User-Agent string so that web servers return readable HTML. The User-Agent MUST NOT identify the library by name or version. The library does not check or respect `robots.txt` in v1.
|
|
184
|
+
- **FR-029**: The `fetch_html` method MUST use a fixed delay of exactly `retry_delay` seconds between retry attempts — no exponential multiplication. The retry delay is constant and does not grow with each attempt number. [Source: specs/004-remove-backoff]
|
|
138
185
|
|
|
139
186
|
### Packaging & Testing
|
|
140
187
|
- **FR-009**: The library MUST be packaged and distributed via PyPI using modern Python packaging best practices, enabling installation through the standard package manager without additional steps.
|
|
@@ -151,6 +198,19 @@ A developer runs the integration test suite and all dev.to integration tests pas
|
|
|
151
198
|
- **FR-027**: The Freedium fallback MUST be unconditionally active for all Medium URL extractions — no caller configuration, opt-in flag, or extractor parameter is required or supported. [Source: specs/003-medium-freedium-fallback]
|
|
152
199
|
- **FR-028**: The Freedium fallback MUST be fully transparent to the caller — no warning, signal, metadata, or result field shall indicate which source (medium.com or Freedium) provided the content. [Source: specs/003-medium-freedium-fallback]
|
|
153
200
|
|
|
201
|
+
### Substack Platform
|
|
202
|
+
- **FR-030**: The library MUST route all `substack.com` and `*.substack.com` URLs to the Substack provider using the existing domain-registration mechanism. [Source: specs/005-substack-provider]
|
|
203
|
+
- **FR-031**: The library MUST extract the main article body from a Substack post page (`div.body.markup`) and return it as clean Markdown. [Source: specs/005-substack-provider]
|
|
204
|
+
- **FR-032**: The library MUST strip all non-content elements from a Substack article page before conversion, including: navigation headers, subscription call-to-action blocks, paywall nag prompts, social share buttons, author bio sections, comment sections, and page footers. [Source: specs/005-substack-provider]
|
|
205
|
+
- **FR-033**: The library MUST prepend the article title as a top-level Markdown heading (`# Title`) from `h1.post-title` in `div.post-header`. Because Substack's HTML structure always places the post title outside `div.body.markup`, unconditional prepend achieves exactly-once inclusion. [Source: specs/005-substack-provider]
|
|
206
|
+
- **FR-034**: The library MUST preserve the article's structural content: headings (all levels), paragraphs, ordered and unordered lists, inline code, fenced code blocks, blockquotes, hyperlinks, images, and article subtitle (when present as `h3.subtitle` in the post header). [Source: specs/005-substack-provider]
|
|
207
|
+
- **FR-035**: The library MUST raise `UnsupportedContentTypeError` when the fetched Substack page does not contain a recognisable article body element (`div.body.markup`). [Source: specs/005-substack-provider]
|
|
208
|
+
- **FR-036**: The library MUST raise `EmptyContentError` when the Substack article body is present but yields no extractable text after stripping. [Source: specs/005-substack-provider]
|
|
209
|
+
- **FR-037**: For paywalled Substack posts, the library MUST silently extract only the publicly visible free-preview section without raising an exception and without appending any truncation marker, provided the preview contains at least some text. [Source: specs/005-substack-provider]
|
|
210
|
+
- **FR-038**: The library MUST collapse runs of three or more consecutive blank lines to a single blank line in the Substack output Markdown. [Source: specs/005-substack-provider]
|
|
211
|
+
- **FR-039**: The library MUST NOT treat HTTP 429 responses from Substack as a non-retryable condition; 429 MUST be retried up to the configured retry count with the standard fixed delay. [Source: specs/005-substack-provider]
|
|
212
|
+
- **FR-040**: The library MUST convert embedded third-party content in Substack posts (e.g., tweet embeds, YouTube video iframes, and similar rich-media widgets) to plain anchor links using the embed's source URL, matching the pattern used by the dev.to provider. [Source: specs/005-substack-provider]
|
|
213
|
+
|
|
154
214
|
### dev.to Platform
|
|
155
215
|
- **FR-015**: The library MUST add `dev.to` to the provider router so that any URL with the `dev.to` domain is dispatched to the dev.to provider without any change to the caller's code. [Source: specs/002-devto-provider]
|
|
156
216
|
- **FR-016**: The library MUST include a dev.to provider that fetches the article page, isolates the main article body from `<div id="article-body">`, removes all non-content elements (navigation, social reaction widgets, comments, author sidebar, tag links), and returns the body as Markdown. [Source: specs/002-devto-provider]
|
|
@@ -177,7 +237,26 @@ A developer runs the integration test suite and all dev.to integration tests pas
|
|
|
177
237
|
|-----------|------|-------------|
|
|
178
238
|
| `DOMAINS` | `frozenset[str]` | Domain suffixes this provider handles (e.g., `{"medium.com"}`, `{"dev.to"}`) |
|
|
179
239
|
|
|
180
|
-
**Invariants**: Each domain suffix registered to exactly one provider. Stateless — every call is independent. Registered providers: `MediumExtractor` (medium.com), `DevToExtractor` (dev.to).
|
|
240
|
+
**Invariants**: Each domain suffix registered to exactly one provider. Stateless — every call is independent. Registered providers: `MediumExtractor` (medium.com), `DevToExtractor` (dev.to), `SubstackExtractor` (substack.com and all `*.substack.com` subdomains).
|
|
241
|
+
|
|
242
|
+
### Substack Post
|
|
243
|
+
[Source: specs/005-substack-provider]
|
|
244
|
+
| Attribute | Type | Description |
|
|
245
|
+
|-----------|------|-------------|
|
|
246
|
+
| `url` | `str` | A `*.substack.com/p/<slug>` URL (or equivalent custom-domain path) |
|
|
247
|
+
| `title` | `str` | Article title from `h1.post-title` in `div.post-header` |
|
|
248
|
+
| `subtitle` | `str \| None` | Optional subtitle/deck from `h3.subtitle` in `div.post-header` |
|
|
249
|
+
| `body` | `Tag` | Prose content inside `div.body.markup` |
|
|
250
|
+
|
|
251
|
+
**Validation**: Page must contain `div.body.markup`; absent → `UnsupportedContentTypeError`. Body must yield non-empty text after stripping → else `EmptyContentError`.
|
|
252
|
+
|
|
253
|
+
### Free Preview
|
|
254
|
+
[Source: specs/005-substack-provider]
|
|
255
|
+
| Attribute | Type | Description |
|
|
256
|
+
|-----------|------|-------------|
|
|
257
|
+
| `content` | `str` | Portion of a paywalled post publicly readable without a subscription |
|
|
258
|
+
|
|
259
|
+
**Boundary**: In the DOM, bounded by the last `div.subscription-widget-wrap` at the truncation point. Stripping that element silently achieves truncation.
|
|
181
260
|
|
|
182
261
|
### ExtractionResult (Output)
|
|
183
262
|
| Attribute | Type | Description |
|
|
@@ -238,12 +317,17 @@ caller provides URL string
|
|
|
238
317
|
- **Network timeouts**: Covered by `FetchError` (30-second fixed timeout).
|
|
239
318
|
- **Oversized responses**: Responses exceeding 10 MB are rejected with `FetchError` to prevent OOM.
|
|
240
319
|
- **Profile/tag pages**: When a `medium.com` URL points to a non-article page, `UnsupportedContentTypeError` is raised (distinct from `UnsupportedPlatformError`).
|
|
241
|
-
- **HTTP 403 / transient failures**: Integration tests use
|
|
320
|
+
- **HTTP 403 / transient failures**: Integration tests use 3 retries with a 2-second fixed delay (hardcoded; not env-var configurable) to handle transient rate limits. [Source: specs/004-remove-backoff]
|
|
242
321
|
- **dev.to profile pages**: When a `dev.to` URL points to an author profile (no `div#article-body`), `UnsupportedContentTypeError` is raised.
|
|
243
322
|
- **dev.to tag listing pages**: When a `dev.to` URL points to a tag page (e.g., `dev.to/t/kafka`), `UnsupportedContentTypeError` is raised.
|
|
244
323
|
- **dev.to liquid-tag embeds**: Embedded third-party widgets (GitHub Gists, CodePen, YouTube) serialised as `<div class="ltag__*" data-url="...">` are replaced with plain Markdown links; they are never silently dropped.
|
|
245
324
|
- **dev.to cover image**: The cover image lives in `<header class="crayons-article__header">`, not in `div#article-body` — the provider explicitly extracts and prepends it.
|
|
246
325
|
- **dev.to HTML structure changes**: If `div#article-body` is absent, `UnsupportedContentTypeError` is raised.
|
|
326
|
+
- **Substack paywalled posts**: `div.body.markup` already contains only the free-preview content; stripping `div.subscription-widget-wrap` achieves silent truncation (no error, no marker). [Source: specs/005-substack-provider]
|
|
327
|
+
- **Substack homepage URLs**: `div.body.markup` is absent → `UnsupportedContentTypeError` is raised immediately. [Source: specs/005-substack-provider]
|
|
328
|
+
- **Substack rich embeds**: `<iframe>` elements and `div[data-component-name]` containers (excluding `SubscribeWidget` and `Image2ToDOM`) are converted to plain anchor links using the embed's source URL. [Source: specs/005-substack-provider]
|
|
329
|
+
- **Substack HTTP 429**: Treated as a retryable transient error (no `_no_retry_status_codes` override) — contrasts with `MediumExtractor` which uses `frozenset({403, 429})` to trigger Freedium fallback. [Source: specs/005-substack-provider]
|
|
330
|
+
- **Substack HTML structure changes**: If Substack redesigns and removes `div.body.markup`, the extractor will require an update.
|
|
247
331
|
|
|
248
332
|
---
|
|
249
333
|
|
|
@@ -262,6 +346,12 @@ caller provides URL string
|
|
|
262
346
|
- **SC-011**: 100% of integration tests pass against the three provided reference dev.to article URLs at the time of release. [Source: specs/002-devto-provider]
|
|
263
347
|
- **SC-012**: The dev.to provider is delivered as exactly one new file; no existing source files are modified (except `test_router.py` for expected domain-example maintenance when the provider registers `dev.to`). [Source: specs/002-devto-provider]
|
|
264
348
|
|
|
349
|
+
- **SC-021**: A free public Substack article returns Markdown that contains the full article title and body text with zero subscription prompt phrases (e.g., "Subscribe", "This post is for paid subscribers"). [Source: specs/005-substack-provider]
|
|
350
|
+
- **SC-022**: A paywalled Substack post returns a non-empty Markdown string (the free preview) without raising an exception, provided the free preview contains at least one paragraph. [Source: specs/005-substack-provider]
|
|
351
|
+
- **SC-023**: A Substack homepage URL raises `UnsupportedContentTypeError` within the normal fetch timeout. [Source: specs/005-substack-provider]
|
|
352
|
+
- **SC-024**: The extracted Markdown for any Substack article contains no consecutive blank-line runs of three or more lines. [Source: specs/005-substack-provider]
|
|
353
|
+
- **SC-025**: The Substack provider is exercised by at least one integration test using a real network request, matching the pattern established by existing providers. [Source: specs/005-substack-provider]
|
|
354
|
+
|
|
265
355
|
---
|
|
266
356
|
|
|
267
357
|
## Assumptions
|
|
@@ -273,6 +363,9 @@ caller provides URL string
|
|
|
273
363
|
- The library supports Python 3.12 and later.
|
|
274
364
|
- The library operates on publicly accessible HTML; it does not execute JavaScript or render dynamic content.
|
|
275
365
|
- Network timeouts use a fixed default of 30 seconds (not user-configurable in v1).
|
|
366
|
+
- **SC-018**: `make test` passes with zero failures (all unit tests green) when run without any `MDFETCH_*` environment variables. [Source: specs/004-remove-backoff]
|
|
367
|
+
- **SC-019**: `make integration` passes with zero failures when run without any `MDFETCH_RETRIES` or `MDFETCH_RETRY_DELAY` environment variables — integration tests use hardcoded defaults (3 retries, 2.0 s delay). [Source: specs/004-remove-backoff]
|
|
368
|
+
- **SC-020**: No reference to `MDFETCH_RETRIES`, `MDFETCH_RETRY_DELAY`, or `_MAX_RETRY_DELAY` appears in `src/`, `tests/`, or `.github/` (specification and documentation files excluded). [Source: specs/004-remove-backoff]
|
|
276
369
|
- **SC-013**: Articles that previously failed with a 403 paywall error are successfully extracted in at least 90% of cases where the Freedium mirror has the content available. [Source: specs/003-medium-freedium-fallback]
|
|
277
370
|
- **SC-014**: Articles that previously failed with a 429 rate-limit error are successfully extracted via fallback without requiring the caller to retry. [Source: specs/003-medium-freedium-fallback]
|
|
278
371
|
- **SC-015**: Zero changes are required in existing caller code to benefit from the Freedium fallback — existing integrations continue to work as-is. [Source: specs/003-medium-freedium-fallback]
|
|
@@ -281,4 +374,4 @@ caller provides URL string
|
|
|
281
374
|
|
|
282
375
|
---
|
|
283
376
|
|
|
284
|
-
*Last Updated: 2026-05-15 | Sources appended: [specs/
|
|
377
|
+
*Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/spec.md], [specs/005-substack-provider/spec.md]*
|
|
@@ -13,11 +13,12 @@ src/mdfetch/
|
|
|
13
13
|
└── providers/
|
|
14
14
|
├── __init__.py
|
|
15
15
|
├── medium.py # MediumExtractor (medium.com + *.medium.com)
|
|
16
|
-
|
|
16
|
+
├── devto.py # DevToExtractor (dev.to)
|
|
17
|
+
└── substack.py # SubstackExtractor (substack.com + *.substack.com)
|
|
17
18
|
|
|
18
19
|
tests/
|
|
19
20
|
├── unit/ # pytest unit tests (no network)
|
|
20
|
-
└── integration/ # real network tests (Medium + dev.to URLs + snapshots)
|
|
21
|
+
└── integration/ # real network tests (Medium + dev.to + Substack URLs + snapshots)
|
|
21
22
|
|
|
22
23
|
.github/workflows/
|
|
23
24
|
├── ci.yml # lint + unit tests on push/PR (Python 3.12–3.14)
|
|
@@ -51,5 +52,10 @@ uv run mypy src/ # type check
|
|
|
51
52
|
```
|
|
52
53
|
|
|
53
54
|
<!-- SPECKIT START -->
|
|
54
|
-
|
|
55
|
+
## Known Issues & Gotchas
|
|
56
|
+
|
|
57
|
+
### ⚠️ test_router.py "unsupported domain" fixture must be updated per new provider
|
|
58
|
+
**Issue:** When a new provider registers a domain (e.g., `dev.to`, then `substack.com`), the existing `test_raises_for_unsupported_domain` and `test_unsupported_error_includes_domain` tests in `tests/unit/test_router.py` use that domain as their "unsupported" example and start routing successfully instead of raising `UnsupportedPlatformError`.
|
|
59
|
+
**Root Cause:** The domain used in the router tests was `substack.com` after the dev.to feature; adding SubstackExtractor made it valid too.
|
|
60
|
+
**Prevention Rule:** When adding a new provider, update the unsupported-domain fixture in `test_router.py` to use a domain not registered by any provider (currently `wordpress.com`).
|
|
55
61
|
<!-- SPECKIT END -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdfetch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Extract article content from web platforms and return it as clean Markdown.
|
|
5
5
|
Project-URL: Homepage, https://github.com/stn1slv/md-fetch
|
|
6
6
|
Project-URL: Source, https://github.com/stn1slv/md-fetch
|
|
@@ -8,7 +8,7 @@ Project-URL: Issues, https://github.com/stn1slv/md-fetch/issues
|
|
|
8
8
|
Author-email: Stanislav Deviatov <devyatov@gmail.com>
|
|
9
9
|
License: MIT
|
|
10
10
|
License-File: LICENSE
|
|
11
|
-
Keywords: article,dev.to,extraction,markdown,medium,scraping
|
|
11
|
+
Keywords: article,dev.to,extraction,markdown,medium,scraping,substack
|
|
12
12
|
Classifier: Development Status :: 3 - Alpha
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
14
14
|
Classifier: License :: OSI Approved :: MIT License
|
|
@@ -47,6 +47,7 @@ from mdfetch import extract
|
|
|
47
47
|
# Works with any supported platform — just pass the URL
|
|
48
48
|
markdown = extract("https://medium.com/some-publication/article-slug-abc123")
|
|
49
49
|
markdown = extract("https://dev.to/username/article-slug")
|
|
50
|
+
markdown = extract("https://example.substack.com/p/article-slug")
|
|
50
51
|
print(markdown)
|
|
51
52
|
```
|
|
52
53
|
|
|
@@ -87,6 +88,7 @@ except EmptyContentError as e:
|
|
|
87
88
|
|----------|---------|
|
|
88
89
|
| Medium | `medium.com`, `*.medium.com` |
|
|
89
90
|
| dev.to | `dev.to` |
|
|
91
|
+
| Substack | `substack.com`, `*.substack.com` |
|
|
90
92
|
|
|
91
93
|
## Development
|
|
92
94
|
|
|
@@ -16,6 +16,7 @@ from mdfetch import extract
|
|
|
16
16
|
# Works with any supported platform — just pass the URL
|
|
17
17
|
markdown = extract("https://medium.com/some-publication/article-slug-abc123")
|
|
18
18
|
markdown = extract("https://dev.to/username/article-slug")
|
|
19
|
+
markdown = extract("https://example.substack.com/p/article-slug")
|
|
19
20
|
print(markdown)
|
|
20
21
|
```
|
|
21
22
|
|
|
@@ -56,6 +57,7 @@ except EmptyContentError as e:
|
|
|
56
57
|
|----------|---------|
|
|
57
58
|
| Medium | `medium.com`, `*.medium.com` |
|
|
58
59
|
| dev.to | `dev.to` |
|
|
60
|
+
| Substack | `substack.com`, `*.substack.com` |
|
|
59
61
|
|
|
60
62
|
## Development
|
|
61
63
|
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "mdfetch"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Extract article content from web platforms and return it as clean Markdown."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
11
11
|
authors = [{ name = "Stanislav Deviatov", email = "devyatov@gmail.com" }]
|
|
12
|
-
keywords = ["markdown", "scraping", "medium", "dev.to", "article", "extraction"]
|
|
12
|
+
keywords = ["markdown", "scraping", "medium", "dev.to", "substack", "article", "extraction"]
|
|
13
13
|
classifiers = [
|
|
14
14
|
"Development Status :: 3 - Alpha",
|
|
15
15
|
"Intended Audience :: Developers",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Specification Quality Checklist: Remove Exponential Backoff and Env-Var Retry Config
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-05-15
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [X] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [X] Focused on user value and business needs
|
|
11
|
+
- [X] Written for non-technical stakeholders
|
|
12
|
+
- [X] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [X] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [X] Requirements are testable and unambiguous
|
|
18
|
+
- [X] Success criteria are measurable
|
|
19
|
+
- [X] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [X] All acceptance scenarios are defined
|
|
21
|
+
- [X] Edge cases are identified
|
|
22
|
+
- [X] Scope is clearly bounded
|
|
23
|
+
- [X] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [X] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [X] User scenarios cover primary flows
|
|
29
|
+
- [X] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [X] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- All checklist items pass. Specification is ready for `/speckit-clarify` or `/speckit-plan`.
|