mdfetch 0.2.0__tar.gz → 0.2.1__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.
Files changed (138) hide show
  1. mdfetch-0.2.1/.github/workflows/integration.yml +86 -0
  2. mdfetch-0.2.1/.specify/feature.json +3 -0
  3. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/memory/changelog.md +22 -0
  4. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/memory/plan.md +22 -7
  5. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/memory/spec.md +57 -1
  6. {mdfetch-0.2.0 → mdfetch-0.2.1}/CLAUDE.md +13 -11
  7. {mdfetch-0.2.0 → mdfetch-0.2.1}/PKG-INFO +1 -1
  8. {mdfetch-0.2.0 → mdfetch-0.2.1}/pyproject.toml +1 -1
  9. mdfetch-0.2.1/specs/003-medium-freedium-fallback/checklists/requirements.md +34 -0
  10. mdfetch-0.2.1/specs/003-medium-freedium-fallback/contracts/extract-api.md +41 -0
  11. mdfetch-0.2.1/specs/003-medium-freedium-fallback/plan.md +187 -0
  12. mdfetch-0.2.1/specs/003-medium-freedium-fallback/research.md +73 -0
  13. mdfetch-0.2.1/specs/003-medium-freedium-fallback/spec.md +108 -0
  14. mdfetch-0.2.1/specs/003-medium-freedium-fallback/tasks.md +174 -0
  15. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/__init__.py +4 -3
  16. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/base.py +20 -4
  17. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/providers/medium.py +42 -1
  18. mdfetch-0.2.1/tests/integration/conftest.py +17 -0
  19. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/test_devto_integration.py +6 -4
  20. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/test_medium_integration.py +6 -4
  21. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/unit/test_fetch_errors.py +60 -1
  22. mdfetch-0.2.1/tests/unit/test_medium_extractor.py +329 -0
  23. {mdfetch-0.2.0 → mdfetch-0.2.1}/uv.lock +1 -1
  24. mdfetch-0.2.0/.specify/feature.json +0 -3
  25. mdfetch-0.2.0/tests/unit/test_medium_extractor.py +0 -144
  26. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-analyze/SKILL.md +0 -0
  27. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
  28. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-checklist/SKILL.md +0 -0
  29. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-clarify/SKILL.md +0 -0
  30. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-constitution/SKILL.md +0 -0
  31. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
  32. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
  33. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
  34. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
  35. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
  36. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-implement/SKILL.md +0 -0
  37. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-plan/SKILL.md +0 -0
  38. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
  39. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-specify/SKILL.md +0 -0
  40. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-tasks/SKILL.md +0 -0
  41. {mdfetch-0.2.0 → mdfetch-0.2.1}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
  42. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.analyze.toml +0 -0
  43. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.archive.run.toml +0 -0
  44. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.checklist.toml +0 -0
  45. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.clarify.toml +0 -0
  46. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.constitution.toml +0 -0
  47. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.implement.toml +0 -0
  48. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.plan.toml +0 -0
  49. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.reconcile.run.toml +0 -0
  50. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.specify.toml +0 -0
  51. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.tasks.toml +0 -0
  52. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gemini/commands/speckit.taskstoissues.toml +0 -0
  53. {mdfetch-0.2.0 → mdfetch-0.2.1}/.github/workflows/ci.yml +0 -0
  54. {mdfetch-0.2.0 → mdfetch-0.2.1}/.github/workflows/publish.yml +0 -0
  55. {mdfetch-0.2.0 → mdfetch-0.2.1}/.gitignore +0 -0
  56. {mdfetch-0.2.0 → mdfetch-0.2.1}/.python-version +0 -0
  57. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/.registry +0 -0
  58. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/archive/LICENSE +0 -0
  59. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/archive/README.md +0 -0
  60. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/archive/commands/archive.md +0 -0
  61. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/archive/extension.yml +0 -0
  62. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/README.md +0 -0
  63. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
  64. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
  65. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
  66. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
  67. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
  68. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/config-template.yml +0 -0
  69. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/extension.yml +0 -0
  70. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/git-config.yml +0 -0
  71. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
  72. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
  73. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
  74. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
  75. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
  76. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
  77. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
  78. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
  79. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/reconcile/LICENSE +0 -0
  80. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/reconcile/README.md +0 -0
  81. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
  82. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions/reconcile/extension.yml +0 -0
  83. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/extensions.yml +0 -0
  84. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/init-options.json +0 -0
  85. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/integration.json +0 -0
  86. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/integrations/claude.manifest.json +0 -0
  87. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/integrations/gemini.manifest.json +0 -0
  88. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/integrations/speckit.manifest.json +0 -0
  89. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/memory/constitution.md +0 -0
  90. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/scripts/bash/check-prerequisites.sh +0 -0
  91. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/scripts/bash/common.sh +0 -0
  92. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/scripts/bash/create-new-feature.sh +0 -0
  93. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/scripts/bash/setup-plan.sh +0 -0
  94. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/scripts/bash/setup-tasks.sh +0 -0
  95. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/templates/checklist-template.md +0 -0
  96. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/templates/constitution-template.md +0 -0
  97. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/templates/plan-template.md +0 -0
  98. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/templates/spec-template.md +0 -0
  99. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/templates/tasks-template.md +0 -0
  100. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/workflows/speckit/workflow.yml +0 -0
  101. {mdfetch-0.2.0 → mdfetch-0.2.1}/.specify/workflows/workflow-registry.json +0 -0
  102. {mdfetch-0.2.0 → mdfetch-0.2.1}/GEMINI.md +0 -0
  103. {mdfetch-0.2.0 → mdfetch-0.2.1}/LICENSE +0 -0
  104. {mdfetch-0.2.0 → mdfetch-0.2.1}/Makefile +0 -0
  105. {mdfetch-0.2.0 → mdfetch-0.2.1}/README.md +0 -0
  106. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
  107. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
  108. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
  109. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
  110. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
  111. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/research.md +0 -0
  112. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
  113. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
  114. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/checklists/requirements.md +0 -0
  115. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/contracts/public-api.md +0 -0
  116. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/data-model.md +0 -0
  117. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/plan.md +0 -0
  118. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/quickstart.md +0 -0
  119. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/research.md +0 -0
  120. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/spec.md +0 -0
  121. {mdfetch-0.2.0 → mdfetch-0.2.1}/specs/002-devto-provider/tasks.md +0 -0
  122. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/exceptions.py +0 -0
  123. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/providers/__init__.py +0 -0
  124. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/providers/devto.py +0 -0
  125. {mdfetch-0.2.0 → mdfetch-0.2.1}/src/mdfetch/router.py +0 -0
  126. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/__init__.py +0 -0
  127. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/conftest.py +0 -0
  128. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/__init__.py +0 -0
  129. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +0 -0
  130. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
  131. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
  132. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
  133. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/from-drift-to-parity.md +0 -0
  134. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/integration/snapshots/integration-digest-december-2025.md +0 -0
  135. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/unit/__init__.py +0 -0
  136. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/unit/test_devto_extractor.py +0 -0
  137. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/unit/test_router.py +0 -0
  138. {mdfetch-0.2.0 → mdfetch-0.2.1}/tests/unit/test_silent.py +0 -0
@@ -0,0 +1,86 @@
1
+ name: Integration Tests
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "30 23 * * 5"
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+ issues: write
11
+
12
+ jobs:
13
+ integration:
14
+ runs-on: [self-hosted, Linux, ARM64]
15
+ timeout-minutes: 30
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v4
21
+ with:
22
+ enable-cache: true
23
+ cache-dependency-glob: "uv.lock"
24
+
25
+ - name: Set up Python
26
+ run: uv python pin 3.12
27
+
28
+ - name: Install dependencies
29
+ run: uv sync --frozen --all-extras
30
+
31
+ - name: Run integration tests
32
+ id: integration
33
+ env:
34
+ MDFETCH_RETRIES: "6"
35
+ MDFETCH_RETRY_DELAY: "2.0"
36
+ run: make integration
37
+
38
+ - name: Create issue on failure
39
+ if: failure() && steps.integration.outcome == 'failure'
40
+ uses: actions/github-script@v7
41
+ with:
42
+ script: |
43
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
44
+ const date = new Date().toISOString().slice(0, 10);
45
+
46
+ const search = await github.rest.search.issuesAndPullRequests({
47
+ q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:integration-failure`,
48
+ per_page: 1,
49
+ });
50
+
51
+ if (search.data.total_count > 0) {
52
+ await github.rest.issues.createComment({
53
+ owner: context.repo.owner,
54
+ repo: context.repo.repo,
55
+ issue_number: search.data.items[0].number,
56
+ body: `Integration tests failed again on ${date}.\n\n**Failed run:** ${runUrl}`,
57
+ });
58
+ return;
59
+ }
60
+
61
+ const issueParams = {
62
+ owner: context.repo.owner,
63
+ repo: context.repo.repo,
64
+ title: `Integration tests failed on ${date} — possible HTML structure change`,
65
+ body: [
66
+ "## Integration test failure",
67
+ "",
68
+ "The scheduled integration tests failed. This usually means a provider's",
69
+ "upstream HTML structure has changed and the extractor needs updating.",
70
+ "",
71
+ `**Failed run:** ${runUrl}`,
72
+ "",
73
+ "### Suggested steps",
74
+ "1. Open the failed run above and inspect the test output.",
75
+ "2. Identify which provider is broken (Medium, dev.to, …).",
76
+ "3. Update the relevant extractor in `src/mdfetch/providers/`.",
77
+ "4. Add or update snapshot fixtures in `tests/integration/` if needed.",
78
+ ].join("\n"),
79
+ };
80
+ try {
81
+ issueParams.labels = ["bug", "integration-failure"];
82
+ await github.rest.issues.create(issueParams);
83
+ } catch {
84
+ delete issueParams.labels;
85
+ await github.rest.issues.create(issueParams);
86
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "feature_directory": "specs/003-medium-freedium-fallback"
3
+ }
@@ -2,6 +2,28 @@
2
2
 
3
3
  ---
4
4
 
5
+ ### mdfetch — Medium Freedium Fallback — 2026-05-15
6
+
7
+ **Branch**: `003-medium-freedium-fallback`
8
+ **Spec**: specs/003-medium-freedium-fallback
9
+
10
+ **What was added**:
11
+ - Transparent fallback to `https://freedium-mirror.cfd/` when medium.com returns HTTP 403 (paywall) or HTTP 429 (rate limit) — caller sees no difference in the `extract()` interface
12
+ - `_no_retry_status_codes: frozenset[int]` class attribute on `BaseExtractor`; codes in this set skip retry/backoff and raise immediately (defaults to `frozenset()` — safe for all existing providers)
13
+ - `_no_retry_codes: frozenset[int] | None = None` keyword-only parameter on `fetch_html()` for per-call override without instance mutation (thread-safe)
14
+ - `_parse_freedium(soup)` method on `MediumExtractor`: locates `div.main-content`, remaps h4→h3/h5→h4/h6→h5, converts to Markdown; heading remap ensures output is structurally identical to the direct medium.com path
15
+ - `extract()` override on `MediumExtractor`: on 403/429, fetches `freedium_url` with `_no_retry_codes=frozenset()`, routes to `_parse_freedium()`; always sets `exc.url` to the original Medium URL on failure
16
+ - 18 new unit tests across `test_medium_extractor.py` (TestParseFreedium, TestFreediumFallback, TestRateLimitFallback, TestNoFallbackOnSuccess) and `test_fetch_errors.py`
17
+ - Integration test suite now resilient to medium.com 403 responses — paywalled URL included in snapshot tests
18
+
19
+ **New Components**:
20
+ - Changes to `src/mdfetch/base.py` — `_no_retry_status_codes` attribute + `_no_retry_codes` param on `fetch_html()`
21
+ - Changes to `src/mdfetch/providers/medium.py` — `_parse_freedium()` + `extract()` override + Freedium constants
22
+
23
+ **Tasks Completed**: 12/12
24
+
25
+ ---
26
+
5
27
  ### mdfetch — dev.to Extractor — 2026-05-14
6
28
 
7
29
  **Branch**: `002-devto-provider`
@@ -38,15 +38,23 @@
38
38
 
39
39
  ```
40
40
  BaseExtractor (ABC) — src/mdfetch/base.py
41
- ├── fetch_html(url) → str — concrete: streaming HTTP with 30s timeout, 10 MB cap
41
+ ├── _no_retry_status_codes: frozenset[int] = frozenset() — codes that skip retry; overridden by providers
42
+ ├── fetch_html(url, *, retries, retry_delay, _no_retry_codes=None) → str
43
+ │ — streaming HTTP, 30s timeout, 10 MB cap;
44
+ │ codes in _no_retry_codes (or class attribute) raise immediately
42
45
  ├── clean_html(soup) → Tag — abstract: platform-specific HTML isolation
43
46
  ├── convert_to_markdown(tag) → str— abstract: platform-specific Markdown conversion
44
47
  └── extract(url) → str — concrete template method (orchestrates the above)
45
48
 
46
49
  MediumExtractor(BaseExtractor) — src/mdfetch/providers/medium.py
47
50
  ├── DOMAINS = frozenset({"medium.com"})
51
+ ├── _FREEDIUM_BASE = "https://freedium-mirror.cfd/"
52
+ ├── _no_retry_status_codes = frozenset({403, 429}) — immediate fallback, no medium.com retry
48
53
  ├── clean_html() → removes nav, clap buttons, sidebars, share elements, post-footer, author bio
49
- └── convert_to_markdown() → markdownify with ATX headings, fenced code blocks
54
+ ├── convert_to_markdown() → markdownify with ATX headings, fenced code blocks
55
+ ├── _parse_freedium(soup) → remaps h4→h3/h5→h4/h6→h5; finds div.main-content; convert_to_markdown
56
+ └── extract() → override: on 403/429 calls fetch_html(freedium_url, _no_retry_codes=frozenset());
57
+ exc.url always set to original Medium URL on any Freedium failure
50
58
 
51
59
  DevToExtractor(BaseExtractor) — src/mdfetch/providers/devto.py
52
60
  ├── DOMAINS = frozenset({"dev.to"})
@@ -131,17 +139,17 @@ Makefile # setup / test / integration / lint / typecheck / f
131
139
 
132
140
  ## Testing Strategy
133
141
 
134
- **Unit tests** (47 tests, offline):
142
+ **Unit tests** (65 tests, offline):
135
143
  - Router: domain routing, subdomain suffix matching, duplicate registration, invalid URLs, unsupported platforms
136
- - MediumExtractor: clean_html, convert_to_markdown, empty content, non-article pages
144
+ - 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]
137
145
  - 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]
138
- - Fetch errors: HTTP 404, 503, timeout, connection error, size limit exceeded
146
+ - 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]
139
147
  - Silent: no stdout/stderr output, no logging during extraction
140
148
 
141
149
  **Integration tests** (6 tests, network required):
142
- - Parametrized over 3 real stn1slv.medium.com articles
150
+ - 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]
143
151
  - Parametrized over 3 real dev.to/stn1slv articles [002-devto-provider]
144
- - Snapshot-based containment check: `expected_body in extracted_result`
152
+ - Snapshot-based containment check: `expected_body in extracted_result` — tests pass regardless of whether medium.com or Freedium served the content (heading normalisation ensures identical output)
145
153
  - 3 retries with 2-second delay on `FetchError` (covers transient 403s/timeouts)
146
154
  - Run with: `make integration` or `uv run pytest tests/integration/ --override-ini=addopts=`
147
155
  - Excluded from default `pytest` run via `addopts = "-m 'not integration'"` in pyproject.toml
@@ -178,6 +186,9 @@ Makefile # setup / test / integration / lint / typecheck / f
178
186
  | Routing | `pkgutil.iter_modules` auto-discovery + `@register` | SC-006: one new file = one new platform |
179
187
  | Integration tests | Snapshot containment + retry | Durable against minor HTML changes; resilient to transient 403s |
180
188
  | 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]
189
+ | 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
+ | 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
+ | 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]
181
192
 
182
193
  ---
183
194
 
@@ -188,3 +199,7 @@ Makefile # setup / test / integration / lint / typecheck / f
188
199
  - [x] Coding Standards — PEP 8, strict type hints, `mypy --strict` passes
189
200
  - [x] Integration Testing — real Medium URLs, snapshot-based containment assertions
190
201
  - [x] Packaging and Distribution — `pyproject.toml` + `src/` layout + `hatchling`; all Makefile targets use `uv run`
202
+
203
+ ---
204
+
205
+ *Last Updated: 2026-05-15 | Sources appended: [specs/003-medium-freedium-fallback/plan.md]*
@@ -72,6 +72,39 @@ A developer passing a dev.to URL that points to a profile page, a tag listing, o
72
72
 
73
73
  ---
74
74
 
75
+ ### US-007 — Transparent Fallback on Blocked Medium Article (P1)
76
+ [Source: specs/003-medium-freedium-fallback]
77
+
78
+ A developer calls the library's extract function with a Medium URL. The article is behind a paywall or the user is geo-blocked, causing Medium to return a 403 error. Without any code changes, the library automatically retrieves the same article via the Freedium mirror and returns clean Markdown content.
79
+
80
+ **Acceptance Scenarios**:
81
+ 1. Given a valid Medium article URL that returns 403 from medium.com, when `extract()` is called, then the library returns clean Markdown content retrieved via the Freedium mirror.
82
+ 2. Given a valid Medium article URL that returns 403 from medium.com, when the Freedium mirror also fails, then the library raises an appropriate extraction error with `exc.url` set to the original Medium URL.
83
+ 3. Given a valid Medium article URL that returns 403 from medium.com, when the library falls back to Freedium, then the caller receives the result without any knowledge of which source was used.
84
+
85
+ ---
86
+
87
+ ### US-008 — Automatic Fallback on Rate Limiting (P2)
88
+ [Source: specs/003-medium-freedium-fallback]
89
+
90
+ A developer calls the library's extract function for a Medium URL. Medium responds with 429 Too Many Requests. The library automatically uses the Freedium mirror as a fallback and returns clean Markdown without requiring the caller to retry.
91
+
92
+ **Acceptance Scenarios**:
93
+ 1. Given a valid Medium article URL that returns 429 from medium.com, when `extract()` is called, then the library returns clean Markdown content retrieved via the Freedium mirror.
94
+ 2. Given a valid Medium article URL that returns 429 from medium.com, when the Freedium mirror also fails, then the library raises an appropriate extraction error.
95
+
96
+ ---
97
+
98
+ ### US-009 — No Fallback When Primary Succeeds (P3)
99
+ [Source: specs/003-medium-freedium-fallback]
100
+
101
+ A developer calls the library's extract function for a publicly accessible Medium article. Medium responds successfully. The library returns the content directly without involving the Freedium mirror, preserving the existing happy-path behavior.
102
+
103
+ **Acceptance Scenarios**:
104
+ 1. Given a valid Medium article URL that returns a successful response, when `extract()` is called, then the library returns clean Markdown content without making any request to the Freedium mirror.
105
+
106
+ ---
107
+
75
108
  ### US-006 — Integration Tests Pass Against Real dev.to Article URLs (P3)
76
109
  [Source: specs/002-devto-provider]
77
110
 
@@ -107,6 +140,17 @@ A developer runs the integration test suite and all dev.to integration tests pas
107
140
  - **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.
108
141
  - **FR-010**: The test suite MUST include integration tests that supply real article URLs (Medium and dev.to) to the extraction function and assert that the output matches expected Markdown structure and content.
109
142
 
143
+ ### Freedium Fallback (Medium)
144
+ - **FR-020**: When a Medium article extraction results in HTTP 403, the system MUST immediately attempt extraction via the Freedium mirror (`https://freedium-mirror.cfd/{url}`) — no retries against medium.com are made first. [Source: specs/003-medium-freedium-fallback]
145
+ - **FR-021**: When a Medium article extraction results in HTTP 429, the system MUST immediately attempt extraction via the Freedium mirror — no retries against medium.com are made first. [Source: specs/003-medium-freedium-fallback]
146
+ - **FR-022**: When the Freedium mirror is used as a fallback, the system MUST return content in the same clean Markdown format as direct extraction; Freedium's `<h4>` headings are remapped to `<h3>` so both paths produce identical heading-level output. [Source: specs/003-medium-freedium-fallback]
147
+ - **FR-023**: When the primary Medium request succeeds (HTTP 200), the system MUST NOT make any request to the Freedium mirror. [Source: specs/003-medium-freedium-fallback]
148
+ - **FR-024**: When both the primary Medium request and the Freedium fallback fail, the system MUST raise an error consistent with the existing exception hierarchy; `exc.url` MUST be set to the original Medium URL (never the Freedium URL). [Source: specs/003-medium-freedium-fallback]
149
+ - **FR-025**: The Freedium fallback mechanism MUST require no changes to the caller's code — the public `extract()` interface remains unchanged. [Source: specs/003-medium-freedium-fallback]
150
+ - **FR-026**: The Freedium fallback MUST only apply to Medium provider URLs; other providers are unaffected. [Source: specs/003-medium-freedium-fallback]
151
+ - **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
+ - **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
+
110
154
  ### dev.to Platform
111
155
  - **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]
112
156
  - **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]
@@ -185,7 +229,10 @@ caller provides URL string
185
229
 
186
230
  ## Edge Cases and Error Handling
187
231
 
188
- - **Paywalled content**: If Medium gates content behind a paywall and the body is not in the public HTML, the library may return an `EmptyContentError` or a reduced extraction. Full paywall bypass is out of scope for v1.
232
+ - **Paywalled content (HTTP 403)**: When Medium returns HTTP 403 (paywall or geo-block), the library automatically retries via the Freedium mirror (`https://freedium-mirror.cfd/`). If Freedium also fails, the error raised carries `exc.url` set to the original Medium URL. [Source: specs/003-medium-freedium-fallback]
233
+ - **Freedium mirror unreachable**: If the Freedium mirror returns a network error, timeout, or non-2xx status, the fallback fails and the exception is propagated with `exc.url` set to the original Medium URL — Freedium's URL never appears in `exc.url`.
234
+ - **Freedium HTML structure**: Freedium uses `<div class="main-content">` (no `<article>`); if this element is absent, `UnsupportedContentTypeError` is raised with a source-agnostic message ("Fallback page missing main-content element").
235
+ - **Freedium heading levels**: Freedium renders section headings as `<h4>` vs. medium.com's `<h2>`/`<h3>`; `_parse_freedium()` remaps h4→h3, h5→h4, h6→h5 before conversion so snapshot tests pass regardless of which source served the content.
189
236
  - **HTML structure changes**: If Medium changes its HTML structure and `<article>` is absent, `UnsupportedContentTypeError` is raised.
190
237
  - **Empty article body**: If `<article>` is found but contains no extractable text, `EmptyContentError` is raised.
191
238
  - **Network timeouts**: Covered by `FetchError` (30-second fixed timeout).
@@ -226,3 +273,12 @@ caller provides URL string
226
273
  - The library supports Python 3.12 and later.
227
274
  - The library operates on publicly accessible HTML; it does not execute JavaScript or render dynamic content.
228
275
  - Network timeouts use a fixed default of 30 seconds (not user-configurable in v1).
276
+ - **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
+ - **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
+ - **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]
279
+ - **SC-016**: When the primary Medium request succeeds, there is no additional latency attributable to the fallback mechanism. [Source: specs/003-medium-freedium-fallback]
280
+ - **SC-017**: All existing unit and integration tests for the Medium extractor continue to pass without modification after the fallback is introduced. [Source: specs/003-medium-freedium-fallback]
281
+
282
+ ---
283
+
284
+ *Last Updated: 2026-05-15 | Sources appended: [specs/003-medium-freedium-fallback/spec.md]*
@@ -11,11 +11,18 @@ src/mdfetch/
11
11
  ├── base.py # BaseExtractor ABC
12
12
  ├── router.py # Domain-to-provider routing
13
13
  └── providers/
14
- └── medium.py # MediumExtractor (medium.com + *.medium.com)
14
+ ├── __init__.py
15
+ ├── medium.py # MediumExtractor (medium.com + *.medium.com)
16
+ └── devto.py # DevToExtractor (dev.to)
15
17
 
16
18
  tests/
17
19
  ├── unit/ # pytest unit tests (no network)
18
- └── integration/ # pytest -m integration (real Medium URLs)
20
+ └── integration/ # real network tests (Medium + dev.to URLs + snapshots)
21
+
22
+ .github/workflows/
23
+ ├── ci.yml # lint + unit tests on push/PR (Python 3.12–3.14)
24
+ ├── integration.yml # scheduled integration tests every Friday 23:30 UTC
25
+ └── publish.yml # PyPI publish on release
19
26
 
20
27
  specs/ # Speckit feature specifications
21
28
  pyproject.toml # hatchling build, uv package manager
@@ -38,16 +45,11 @@ make test # unit tests only
38
45
  make lint # ruff check
39
46
  make format # ruff format
40
47
  make build # uv build (wheel + sdist)
41
- make upgrade-deps # uv sync --upgrade
42
- uv run pytest -m integration # integration tests (network required)
43
- uv run mypy src/ # type check
48
+ make upgrade-deps # uv sync --all-extras --upgrade
49
+ make integration # integration tests (network required)
50
+ uv run mypy src/ # type check
44
51
  ```
45
52
 
46
- ## Recent Changes
47
-
48
- - 002-devto-provider: dev.to article extraction provider — `DevToExtractor`, cover image + embed→link handling, 17 new unit tests, 3 integration tests, version 0.2.0
49
- - 001-mdfetch-medium-extractor: Initial library release — `extract()` API, Medium provider, typed exceptions, auto-discovery routing, PyPI packaging, snapshot integration tests
50
-
51
53
  <!-- SPECKIT START -->
52
- **Active feature plan**: none
54
+ **Recent changes**: `003-medium-freedium-fallback` — Transparent Freedium mirror fallback for Medium 403/429 responses; `_no_retry_status_codes` hook on `BaseExtractor`; `_parse_freedium()` with h4→h3 heading remap on `MediumExtractor`
53
55
  <!-- SPECKIT END -->
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdfetch
3
- Version: 0.2.0
3
+ Version: 0.2.1
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mdfetch"
7
- version = "0.2.0"
7
+ version = "0.2.1"
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" }
@@ -0,0 +1,34 @@
1
+ # Specification Quality Checklist: Medium Freedium Fallback
2
+
3
+ **Purpose**: Validate specification completeness and quality before proceeding to planning
4
+ **Created**: 2026-05-14
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 items pass. Specification is ready for `/speckit-plan`.
@@ -0,0 +1,41 @@
1
+ # Contract: `extract()` Public API
2
+
3
+ **Feature**: Medium Freedium Fallback | **Status**: Unchanged
4
+
5
+ ## Signature
6
+
7
+ ```python
8
+ def extract(url: str, *, retries: int = 3, retry_delay: float = 2.0) -> str
9
+ ```
10
+
11
+ ## Behaviour (unchanged)
12
+
13
+ | Input | Output |
14
+ |---|---|
15
+ | Valid Medium URL, article accessible | Clean Markdown string |
16
+ | Valid Medium URL, 403 or 429 from medium.com, Freedium succeeds | Clean Markdown string (source is transparent) |
17
+ | Valid Medium URL, 403 or 429 from medium.com, Freedium also fails | Raises `FetchError` or `HTTPStatusError` |
18
+ | Valid Medium URL, other HTTP error (404, 500, …) | Raises `HTTPStatusError` after retries |
19
+ | Valid Medium URL, network timeout/error | Raises `FetchError` after retries |
20
+ | Non-Medium URL | Raises `UnsupportedPlatformError` |
21
+ | Invalid URL | Raises `InvalidURLError` |
22
+
23
+ ## Exception Hierarchy (unchanged)
24
+
25
+ ```
26
+ MdfetchError
27
+ ├── InvalidURLError
28
+ ├── UnsupportedPlatformError
29
+ ├── UnsupportedContentTypeError
30
+ ├── EmptyContentError
31
+ └── FetchError
32
+ └── HTTPStatusError (status_code: int, url: str | None)
33
+ ```
34
+
35
+ ## Guarantees introduced by this feature
36
+
37
+ - When fallback is invoked, `exc.url` on any raised exception contains the **original Medium URL**, not the Freedium mirror URL.
38
+ - No new exception types are introduced.
39
+ - No new parameters are added to `extract()`.
40
+ - The Freedium mirror URL is never exposed in return values or the `exc.url` field.
41
+ - `exc.url` is the authoritative public URL field and is always set to the original Medium URL when fallback fails. `exc.message` is an internal implementation detail and may contain transport-level information (e.g. the URL that was actually fetched); callers MUST NOT rely on its contents.
@@ -0,0 +1,187 @@
1
+ # Implementation Plan: Medium Freedium Fallback
2
+
3
+ **Branch**: `003-medium-freedium-fallback` | **Date**: 2026-05-14 | **Spec**: [spec.md](spec.md)
4
+
5
+ **Input**: Feature specification from `specs/003-medium-freedium-fallback/spec.md`
6
+
7
+ ## Summary
8
+
9
+ Add transparent fallback to the Freedium mirror (`https://freedium-mirror.cfd/`) within `MediumExtractor` when `medium.com` returns HTTP 403 or 429. On these status codes the fallback is immediate (no retries against `medium.com` first). The public `extract()` interface is unchanged. The implementation requires a minimal extension to `BaseExtractor` (a `_no_retry_status_codes` class attribute) and an override of `extract()` in `MediumExtractor`.
10
+
11
+ ## Technical Context
12
+
13
+ **Language/Version**: Python 3.12–3.14
14
+
15
+ **Primary Dependencies**: httpx ≥0.27, beautifulsoup4 ≥4.12, lxml ≥5.0, markdownify ≥0.13 — all existing; no new runtime dependencies
16
+
17
+ **Storage**: N/A
18
+
19
+ **Testing**: pytest with `@pytest.mark.integration` marker for network tests
20
+
21
+ **Target Platform**: Any (Python library distributed via PyPI)
22
+
23
+ **Project Type**: library
24
+
25
+ **Performance Goals**: Zero added latency on successful primary fetch; fallback adds exactly one additional HTTP roundtrip
26
+
27
+ **Constraints**: Public `extract(url, *, retries, retry_delay)` signature unchanged; no new runtime dependencies; all existing tests must pass unmodified
28
+
29
+ **Scale/Scope**: Per-call behaviour change within `MediumExtractor`; no state, no storage. Thread-safe by design: `_no_retry_codes` override is passed as a parameter to `fetch_html()` rather than mutating instance state.
30
+
31
+ ## Constitution Check
32
+
33
+ *GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
34
+
35
+ - [x] Validates Provider Pattern Architecture — changes confined to `MediumExtractor` plus one minimal hook in `BaseExtractor`; no code duplication
36
+ - [x] Confirms Technology Stack — httpx (existing), BeautifulSoup (existing); no new deps
37
+ - [x] Adheres to Coding Standards — PEP 8, strict type hints, clear naming required throughout
38
+ - [x] Incorporates Integration Testing — new `@pytest.mark.integration` test for paywalled URL via Freedium
39
+ - [x] Respects Packaging and Distribution standards — `pyproject.toml` + `src/` layout unchanged; all Makefile targets delegate to `uv run`
40
+
41
+ **Post-design re-check**: All gates remain green. No violations requiring justification.
42
+
43
+ ## Project Structure
44
+
45
+ ### Documentation (this feature)
46
+
47
+ ```text
48
+ specs/003-medium-freedium-fallback/
49
+ ├── plan.md # This file
50
+ ├── research.md # Phase 0 output
51
+ ├── contracts/
52
+ │ └── extract-api.md # Phase 1 output — public extract() contract
53
+ └── tasks.md # Phase 2 output (/speckit-tasks command)
54
+ ```
55
+
56
+ ### Source Code (repository root)
57
+
58
+ No new files. Changes to existing files only:
59
+
60
+ ```text
61
+ src/mdfetch/
62
+ ├── base.py # Add _no_retry_status_codes + loop guard
63
+ └── providers/
64
+ └── medium.py # Add _parse_freedium(); override _no_retry_status_codes + extract()
65
+
66
+ tests/
67
+ ├── unit/
68
+ │ ├── test_fetch_errors.py # Add tests: 403/429 not retried for MediumExtractor
69
+ │ └── test_medium_extractor.py # Add tests: _parse_freedium(), fallback URL + 403/429 paths
70
+ └── integration/
71
+ └── test_medium_integration.py # Add test: paywalled URL succeeds via Freedium (keyword assertion)
72
+ ```
73
+
74
+ **Structure Decision**: Single project layout unchanged; all changes are surgical modifications to existing files.
75
+
76
+ ## Phase 0: Research
77
+
78
+ See [research.md](research.md) for full rationale. Key decisions:
79
+
80
+ | Topic | Decision |
81
+ |---|---|
82
+ | Preventing retries on 403/429 | Add `_no_retry_status_codes: frozenset[int] = frozenset()` to `BaseExtractor`; override with `frozenset({403, 429})` in `MediumExtractor` |
83
+ | Fallback implementation location | Override `extract()` in `MediumExtractor` |
84
+ | Freedium URL construction | `f"https://freedium-mirror.cfd/{original_url}"` |
85
+ | HTML parsing | Freedium uses `<div class="main-content">` — **incompatible** with `clean_html()` which requires `<article>`; add dedicated `_parse_freedium(soup)` method to `MediumExtractor` |
86
+ | Error when both fail | Raise `HTTPStatusError`/`FetchError` from Freedium attempt; set `exc.url` to original Medium URL |
87
+ | Integration test | Snapshot containment assertion (`expected in result`) — paywalled URL covered by existing snapshot test suite; dedicated keyword-only test removed as redundant |
88
+
89
+ ## Phase 1: Design & Contracts
90
+
91
+ ### BaseExtractor changes (`src/mdfetch/base.py`)
92
+
93
+ Add class attribute:
94
+ ```python
95
+ _no_retry_status_codes: frozenset[int] = frozenset()
96
+ ```
97
+
98
+ Also add a `_no_retry_codes` keyword-only parameter to `fetch_html()` so callers can pass a per-call override without mutating instance state (used for the Freedium fetch):
99
+
100
+ ```python
101
+ def fetch_html(
102
+ self,
103
+ url: str,
104
+ *,
105
+ retries: int = 3,
106
+ retry_delay: float = 2.0,
107
+ _no_retry_codes: frozenset[int] | None = None,
108
+ ) -> str: ...
109
+ ```
110
+
111
+ In the retry loop, resolve the effective set before the loop:
112
+ ```python
113
+ no_retry = self._no_retry_status_codes if _no_retry_codes is None else _no_retry_codes
114
+ # ...
115
+ except FetchError as exc:
116
+ if isinstance(exc, HTTPStatusError) and exc.status_code in no_retry:
117
+ raise
118
+ last_exc = exc
119
+ if attempt < retries - 1:
120
+ time.sleep(...)
121
+ ```
122
+
123
+ These are the only changes to `base.py`. The attribute is empty by default, so all existing providers and tests are unaffected.
124
+
125
+ ### MediumExtractor changes (`src/mdfetch/providers/medium.py`)
126
+
127
+ **Verified finding**: Freedium HTML uses `<div class="main-content">` as the content root — no `<article>` element exists. The existing `clean_html()` always raises `UnsupportedContentTypeError` on Freedium HTML. A dedicated parser method is required.
128
+
129
+ Add two class-level constants, a new `_parse_freedium()` method, and override `extract()`:
130
+
131
+ ```python
132
+ _FREEDIUM_BASE = "https://freedium-mirror.cfd/"
133
+ _no_retry_status_codes: frozenset[int] = frozenset({403, 429})
134
+
135
+ def _parse_freedium(self, soup: BeautifulSoup) -> str:
136
+ """Parse Freedium mirror HTML, which uses div.main-content instead of <article>."""
137
+ content = soup.find("div", class_="main-content")
138
+ if not isinstance(content, Tag):
139
+ raise UnsupportedContentTypeError(
140
+ "Fallback page missing main-content element",
141
+ )
142
+ # Remap h4→h3, h5→h4, h6→h5 so output matches medium.com's heading levels
143
+ for level in (4, 5, 6):
144
+ for tag in list(content.find_all(f"h{level}")):
145
+ tag.name = f"h{level - 1}"
146
+ return self.convert_to_markdown(content)
147
+
148
+ def extract(self, url: str, *, retries: int = 3, retry_delay: float = 2.0) -> str:
149
+ """Extract article, falling back to Freedium mirror on HTTP 403 or 429."""
150
+ try:
151
+ return super().extract(url, retries=retries, retry_delay=retry_delay)
152
+ except HTTPStatusError as exc:
153
+ if exc.status_code not in self._no_retry_status_codes:
154
+ raise
155
+ freedium_url = f"{self._FREEDIUM_BASE}{url}"
156
+ # Pass _no_retry_codes=frozenset() so a 429 from Freedium is retried
157
+ # with backoff rather than raised immediately (thread-safe: no mutation).
158
+ try:
159
+ html = self.fetch_html(
160
+ freedium_url, retries=retries, retry_delay=retry_delay, _no_retry_codes=frozenset()
161
+ )
162
+ soup = BeautifulSoup(html, "lxml")
163
+ return self._parse_freedium(soup)
164
+ except MdfetchError as inner_exc:
165
+ inner_exc.url = url
166
+ raise
167
+ ```
168
+
169
+ Notes:
170
+ - `HTTPStatusError` and `MdfetchError` imported in `medium.py`
171
+ - `_parse_freedium()` skips all Medium-specific stripping — Freedium's `main-content` div contains only article body
172
+ - Error message in `UnsupportedContentTypeError` is source-agnostic ("Fallback page…") to preserve transparent-fallback contract (FR-009)
173
+ - `exc.url` is set unconditionally to the original URL (not guarded by `is None`) — ensures the Freedium URL never escapes to callers regardless of which exception type is raised
174
+ - `_no_retry_codes=frozenset()` on the Freedium fetch prevents class-level 429 no-retry behavior from suppressing retries against the mirror (thread-safe: no instance mutation)
175
+
176
+ ### Data Model
177
+
178
+ No new entities. This feature is a pure behaviour change with no persistent state.
179
+
180
+ ### Contracts
181
+
182
+ See [contracts/extract-api.md](contracts/extract-api.md). The public `extract()` signature is unchanged. The Freedium URL is a fully internal implementation detail — never exposed through any public interface or exception message visible to callers.
183
+
184
+ ### Revision: Implementation Sync 2026-05-15
185
+ - Code snippets updated to reflect shipped implementation: `_no_retry_codes` parameter on `fetch_html()`, heading remap in `_parse_freedium()`, unconditional `exc.url` assignment, source-agnostic error message ("Fallback page…"), thread-safe Freedium fetch via parameter override.
186
+ - Integration test strategy updated: keyword-only paywalled test removed; paywalled URL is covered by snapshot containment test.
187
+ - Scale/Scope updated: concurrency safety now explicitly documented.