mdfetch 0.2.0__tar.gz → 0.2.2__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 (144) hide show
  1. mdfetch-0.2.2/.github/workflows/integration.yml +83 -0
  2. mdfetch-0.2.2/.specify/feature.json +3 -0
  3. mdfetch-0.2.2/.specify/memory/changelog.md +96 -0
  4. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/plan.md +27 -10
  5. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/spec.md +76 -4
  6. {mdfetch-0.2.0 → mdfetch-0.2.2}/CLAUDE.md +14 -10
  7. {mdfetch-0.2.0 → mdfetch-0.2.2}/PKG-INFO +1 -1
  8. {mdfetch-0.2.0 → mdfetch-0.2.2}/pyproject.toml +1 -1
  9. mdfetch-0.2.2/specs/003-medium-freedium-fallback/checklists/requirements.md +34 -0
  10. mdfetch-0.2.2/specs/003-medium-freedium-fallback/contracts/extract-api.md +41 -0
  11. mdfetch-0.2.2/specs/003-medium-freedium-fallback/plan.md +187 -0
  12. mdfetch-0.2.2/specs/003-medium-freedium-fallback/research.md +73 -0
  13. mdfetch-0.2.2/specs/003-medium-freedium-fallback/spec.md +108 -0
  14. mdfetch-0.2.2/specs/003-medium-freedium-fallback/tasks.md +174 -0
  15. mdfetch-0.2.2/specs/004-remove-backoff/checklists/requirements.md +34 -0
  16. mdfetch-0.2.2/specs/004-remove-backoff/plan.md +146 -0
  17. mdfetch-0.2.2/specs/004-remove-backoff/research.md +42 -0
  18. mdfetch-0.2.2/specs/004-remove-backoff/spec.md +92 -0
  19. mdfetch-0.2.2/specs/004-remove-backoff/tasks.md +147 -0
  20. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/__init__.py +4 -3
  21. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/base.py +18 -3
  22. mdfetch-0.2.2/src/mdfetch/providers/medium.py +138 -0
  23. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +97 -146
  24. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/from-drift-to-parity.md +19 -19
  25. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/integration-digest-december-2025.md +5 -5
  26. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/test_devto_integration.py +1 -4
  27. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/test_medium_integration.py +1 -4
  28. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_fetch_errors.py +39 -1
  29. mdfetch-0.2.2/tests/unit/test_medium_extractor.py +358 -0
  30. {mdfetch-0.2.0 → mdfetch-0.2.2}/uv.lock +1 -1
  31. mdfetch-0.2.0/.specify/feature.json +0 -3
  32. mdfetch-0.2.0/.specify/memory/changelog.md +0 -57
  33. mdfetch-0.2.0/src/mdfetch/providers/medium.py +0 -67
  34. mdfetch-0.2.0/tests/unit/test_medium_extractor.py +0 -144
  35. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-analyze/SKILL.md +0 -0
  36. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
  37. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-checklist/SKILL.md +0 -0
  38. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-clarify/SKILL.md +0 -0
  39. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-constitution/SKILL.md +0 -0
  40. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
  41. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
  42. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
  43. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
  44. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
  45. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-implement/SKILL.md +0 -0
  46. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-plan/SKILL.md +0 -0
  47. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
  48. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-specify/SKILL.md +0 -0
  49. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-tasks/SKILL.md +0 -0
  50. {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
  51. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.analyze.toml +0 -0
  52. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.archive.run.toml +0 -0
  53. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.checklist.toml +0 -0
  54. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.clarify.toml +0 -0
  55. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.constitution.toml +0 -0
  56. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.implement.toml +0 -0
  57. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.plan.toml +0 -0
  58. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.reconcile.run.toml +0 -0
  59. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.specify.toml +0 -0
  60. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.tasks.toml +0 -0
  61. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.taskstoissues.toml +0 -0
  62. {mdfetch-0.2.0 → mdfetch-0.2.2}/.github/workflows/ci.yml +0 -0
  63. {mdfetch-0.2.0 → mdfetch-0.2.2}/.github/workflows/publish.yml +0 -0
  64. {mdfetch-0.2.0 → mdfetch-0.2.2}/.gitignore +0 -0
  65. {mdfetch-0.2.0 → mdfetch-0.2.2}/.python-version +0 -0
  66. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/.registry +0 -0
  67. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/LICENSE +0 -0
  68. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/README.md +0 -0
  69. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/commands/archive.md +0 -0
  70. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/extension.yml +0 -0
  71. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/README.md +0 -0
  72. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
  73. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
  74. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
  75. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
  76. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
  77. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/config-template.yml +0 -0
  78. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/extension.yml +0 -0
  79. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/git-config.yml +0 -0
  80. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
  81. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
  82. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
  83. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
  84. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
  85. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
  86. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
  87. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
  88. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/LICENSE +0 -0
  89. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/README.md +0 -0
  90. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
  91. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/extension.yml +0 -0
  92. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions.yml +0 -0
  93. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/init-options.json +0 -0
  94. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integration.json +0 -0
  95. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/claude.manifest.json +0 -0
  96. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/gemini.manifest.json +0 -0
  97. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/speckit.manifest.json +0 -0
  98. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/constitution.md +0 -0
  99. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/check-prerequisites.sh +0 -0
  100. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/common.sh +0 -0
  101. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/create-new-feature.sh +0 -0
  102. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/setup-plan.sh +0 -0
  103. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/setup-tasks.sh +0 -0
  104. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/checklist-template.md +0 -0
  105. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/constitution-template.md +0 -0
  106. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/plan-template.md +0 -0
  107. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/spec-template.md +0 -0
  108. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/tasks-template.md +0 -0
  109. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/workflows/speckit/workflow.yml +0 -0
  110. {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/workflows/workflow-registry.json +0 -0
  111. {mdfetch-0.2.0 → mdfetch-0.2.2}/GEMINI.md +0 -0
  112. {mdfetch-0.2.0 → mdfetch-0.2.2}/LICENSE +0 -0
  113. {mdfetch-0.2.0 → mdfetch-0.2.2}/Makefile +0 -0
  114. {mdfetch-0.2.0 → mdfetch-0.2.2}/README.md +0 -0
  115. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
  116. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
  117. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
  118. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
  119. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
  120. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/research.md +0 -0
  121. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
  122. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
  123. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/checklists/requirements.md +0 -0
  124. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/contracts/public-api.md +0 -0
  125. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/data-model.md +0 -0
  126. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/plan.md +0 -0
  127. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/quickstart.md +0 -0
  128. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/research.md +0 -0
  129. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/spec.md +0 -0
  130. {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/tasks.md +0 -0
  131. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/exceptions.py +0 -0
  132. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/providers/__init__.py +0 -0
  133. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/providers/devto.py +0 -0
  134. {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/router.py +0 -0
  135. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/__init__.py +0 -0
  136. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/conftest.py +0 -0
  137. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/__init__.py +0 -0
  138. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
  139. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
  140. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
  141. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/__init__.py +0 -0
  142. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_devto_extractor.py +0 -0
  143. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_router.py +0 -0
  144. {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_silent.py +0 -0
@@ -0,0 +1,83 @@
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
+ run: make integration
34
+
35
+ - name: Create issue on failure
36
+ if: failure() && steps.integration.outcome == 'failure'
37
+ uses: actions/github-script@v7
38
+ with:
39
+ script: |
40
+ const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
41
+ const date = new Date().toISOString().slice(0, 10);
42
+
43
+ const search = await github.rest.search.issuesAndPullRequests({
44
+ q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:integration-failure`,
45
+ per_page: 1,
46
+ });
47
+
48
+ if (search.data.total_count > 0) {
49
+ await github.rest.issues.createComment({
50
+ owner: context.repo.owner,
51
+ repo: context.repo.repo,
52
+ issue_number: search.data.items[0].number,
53
+ body: `Integration tests failed again on ${date}.\n\n**Failed run:** ${runUrl}`,
54
+ });
55
+ return;
56
+ }
57
+
58
+ const issueParams = {
59
+ owner: context.repo.owner,
60
+ repo: context.repo.repo,
61
+ title: `Integration tests failed on ${date} — possible HTML structure change`,
62
+ body: [
63
+ "## Integration test failure",
64
+ "",
65
+ "The scheduled integration tests failed. This usually means a provider's",
66
+ "upstream HTML structure has changed and the extractor needs updating.",
67
+ "",
68
+ `**Failed run:** ${runUrl}`,
69
+ "",
70
+ "### Suggested steps",
71
+ "1. Open the failed run above and inspect the test output.",
72
+ "2. Identify which provider is broken (Medium, dev.to, …).",
73
+ "3. Update the relevant extractor in `src/mdfetch/providers/`.",
74
+ "4. Add or update snapshot fixtures in `tests/integration/` if needed.",
75
+ ].join("\n"),
76
+ };
77
+ try {
78
+ issueParams.labels = ["bug", "integration-failure"];
79
+ await github.rest.issues.create(issueParams);
80
+ } catch {
81
+ delete issueParams.labels;
82
+ await github.rest.issues.create(issueParams);
83
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "feature_directory": "specs/004-remove-backoff"
3
+ }
@@ -0,0 +1,96 @@
1
+ # Merged Features Log
2
+
3
+ ---
4
+
5
+ ### mdfetch — Remove Exponential Backoff — 2026-05-15
6
+
7
+ **Branch**: `004-remove-backoff`
8
+ **Spec**: specs/004-remove-backoff
9
+
10
+ **What was added**:
11
+ - 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.
12
+ - 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`.
13
+ - 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.
14
+
15
+ **New Components**:
16
+ - No new files. Pure removal: `tests/integration/conftest.py` deleted; `_MAX_RETRY_DELAY` constant removed from `src/mdfetch/base.py`.
17
+
18
+ **Tasks Completed**: 16/16
19
+
20
+ ---
21
+
22
+ ### mdfetch — Medium Freedium Fallback — 2026-05-15
23
+
24
+ **Branch**: `003-medium-freedium-fallback`
25
+ **Spec**: specs/003-medium-freedium-fallback
26
+
27
+ **What was added**:
28
+ - 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
29
+ - `_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)
30
+ - `_no_retry_codes: frozenset[int] | None = None` keyword-only parameter on `fetch_html()` for per-call override without instance mutation (thread-safe)
31
+ - `_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
32
+ - `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
33
+ - 18 new unit tests across `test_medium_extractor.py` (TestParseFreedium, TestFreediumFallback, TestRateLimitFallback, TestNoFallbackOnSuccess) and `test_fetch_errors.py`
34
+ - Integration test suite now resilient to medium.com 403 responses — paywalled URL included in snapshot tests
35
+
36
+ **New Components**:
37
+ - Changes to `src/mdfetch/base.py` — `_no_retry_status_codes` attribute + `_no_retry_codes` param on `fetch_html()`
38
+ - Changes to `src/mdfetch/providers/medium.py` — `_parse_freedium()` + `extract()` override + Freedium constants
39
+
40
+ **Tasks Completed**: 12/12
41
+
42
+ ---
43
+
44
+ ### mdfetch — dev.to Extractor — 2026-05-14
45
+
46
+ **Branch**: `002-devto-provider`
47
+ **Spec**: specs/002-devto-provider
48
+
49
+ **What was added**:
50
+ - `DevToExtractor` provider for `dev.to` articles, auto-discovered via `@register` decorator
51
+ - Article body isolation from `<div id="article-body">` with cover image extracted from `<header class="crayons-article__header">` and prepended to output
52
+ - `<iframe>` and liquid-tag embed (`ltag__*`) replacement with plain Markdown links (FR-019)
53
+ - `UnsupportedContentTypeError` raised for non-article dev.to pages (profiles, tag listings)
54
+ - 17 new unit tests in `tests/unit/test_devto_extractor.py`
55
+ - 3 dev.to integration tests in `tests/integration/test_devto_integration.py` with snapshot golden files
56
+ - Library version bumped from `0.1.0` to `0.2.0`
57
+ - `"dev.to"` added to `pyproject.toml` keywords (T013)
58
+
59
+ **New Components**:
60
+ - `src/mdfetch/providers/devto.py` — DevToExtractor
61
+ - `tests/unit/test_devto_extractor.py` — 17 unit tests
62
+ - `tests/integration/test_devto_integration.py` — 3 integration tests
63
+ - `tests/integration/snapshots/devto-integration-digest-december-2025.md`
64
+ - `tests/integration/snapshots/devto-integration-digest-july-2025.md`
65
+ - `tests/integration/snapshots/devto-integration-digest-march-2026.md`
66
+
67
+ **Tasks Completed**: 13/13
68
+
69
+ ---
70
+
71
+ ### mdfetch — Medium Extractor (Initial Release) — 2026-05-14
72
+
73
+ **Branch**: `feature/first-draft`
74
+ **Spec**: specs/001-mdfetch-medium-extractor
75
+
76
+ **What was added**:
77
+ - `extract(url: str) -> str` public API for converting Medium articles to Markdown
78
+ - Provider pattern with `BaseExtractor` ABC and `MediumExtractor` implementation
79
+ - Auto-discovery routing via `pkgutil.iter_modules` + `@register` decorator (SC-006 compliant)
80
+ - Full typed exception hierarchy: `MdfetchError` → `InvalidURLError`, `UnsupportedPlatformError`, `UnsupportedContentTypeError`, `FetchError` → `HTTPStatusError`, `EmptyContentError`
81
+ - Streaming HTTP fetch with 10 MB response size cap
82
+ - Browser-like User-Agent (FR-014 compliant, no library branding)
83
+ - Snapshot-based integration tests with retry logic (3 retries, 2-second delay)
84
+ - PyPI-ready package: `pyproject.toml` + `src/` layout + `hatchling` build backend
85
+ - `Makefile` with full dev workflow (setup, test, integration, lint, typecheck, format, build, upgrade-deps, clean)
86
+
87
+ **New Components**:
88
+ - `src/mdfetch/__init__.py` — public surface
89
+ - `src/mdfetch/exceptions.py` — typed exception hierarchy
90
+ - `src/mdfetch/base.py` — BaseExtractor ABC + fetch_html + extract template method
91
+ - `src/mdfetch/router.py` — @register, auto-discovery, route()
92
+ - `src/mdfetch/providers/medium.py` — MediumExtractor
93
+ - `tests/unit/` — 30 unit tests (offline)
94
+ - `tests/integration/` — 3 integration tests with 3 snapshot golden files
95
+
96
+ **Tasks Completed**: 32/32
@@ -1,7 +1,7 @@
1
1
  # mdfetch — Main Implementation Plan
2
2
 
3
- **Last Updated**: 2026-05-14
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]
5
5
 
6
6
  ---
7
7
 
@@ -38,15 +38,24 @@
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
+ │ fixed delay of retry_delay seconds between attempts (not exponential);
45
+ │ codes in _no_retry_codes (or class attribute) raise immediately
42
46
  ├── clean_html(soup) → Tag — abstract: platform-specific HTML isolation
43
47
  ├── convert_to_markdown(tag) → str— abstract: platform-specific Markdown conversion
44
48
  └── extract(url) → str — concrete template method (orchestrates the above)
45
49
 
46
50
  MediumExtractor(BaseExtractor) — src/mdfetch/providers/medium.py
47
51
  ├── DOMAINS = frozenset({"medium.com"})
52
+ ├── _FREEDIUM_BASE = "https://freedium-mirror.cfd/"
53
+ ├── _no_retry_status_codes = frozenset({403, 429}) — immediate fallback, no medium.com retry
48
54
  ├── clean_html() → removes nav, clap buttons, sidebars, share elements, post-footer, author bio
49
- └── convert_to_markdown() → markdownify with ATX headings, fenced code blocks
55
+ ├── convert_to_markdown() → markdownify with ATX headings, fenced code blocks
56
+ ├── _parse_freedium(soup) → remaps h4→h3/h5→h4/h6→h5; finds div.main-content; convert_to_markdown
57
+ └── extract() → override: on 403/429 calls fetch_html(freedium_url, _no_retry_codes=frozenset());
58
+ exc.url always set to original Medium URL on any Freedium failure
50
59
 
51
60
  DevToExtractor(BaseExtractor) — src/mdfetch/providers/devto.py
52
61
  ├── DOMAINS = frozenset({"dev.to"})
@@ -131,18 +140,18 @@ Makefile # setup / test / integration / lint / typecheck / f
131
140
 
132
141
  ## Testing Strategy
133
142
 
134
- **Unit tests** (47 tests, offline):
143
+ **Unit tests** (63 tests, offline):
135
144
  - Router: domain routing, subdomain suffix matching, duplicate registration, invalid URLs, unsupported platforms
136
- - MediumExtractor: clean_html, convert_to_markdown, empty content, non-article pages
145
+ - 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
146
  - 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
147
+ - 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
148
  - Silent: no stdout/stderr output, no logging during extraction
140
149
 
141
150
  **Integration tests** (6 tests, network required):
142
- - Parametrized over 3 real stn1slv.medium.com articles
151
+ - 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
152
  - Parametrized over 3 real dev.to/stn1slv articles [002-devto-provider]
144
- - Snapshot-based containment check: `expected_body in extracted_result`
145
- - 3 retries with 2-second delay on `FetchError` (covers transient 403s/timeouts)
153
+ - 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)
154
+ - 3 retries with 2-second **fixed** delay on `FetchError` (hardcoded; not env-var configurable) [004-remove-backoff]
146
155
  - Run with: `make integration` or `uv run pytest tests/integration/ --override-ini=addopts=`
147
156
  - Excluded from default `pytest` run via `addopts = "-m 'not integration'"` in pyproject.toml
148
157
 
@@ -178,6 +187,10 @@ Makefile # setup / test / integration / lint / typecheck / f
178
187
  | Routing | `pkgutil.iter_modules` auto-discovery + `@register` | SC-006: one new file = one new platform |
179
188
  | Integration tests | Snapshot containment + retry | Durable against minor HTML changes; resilient to transient 403s |
180
189
  | 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]
190
+ | 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]
191
+ | 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]
192
+ | 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]
193
+ | 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]
181
194
 
182
195
  ---
183
196
 
@@ -188,3 +201,7 @@ Makefile # setup / test / integration / lint / typecheck / f
188
201
  - [x] Coding Standards — PEP 8, strict type hints, `mypy --strict` passes
189
202
  - [x] Integration Testing — real Medium URLs, snapshot-based containment assertions
190
203
  - [x] Packaging and Distribution — `pyproject.toml` + `src/` layout + `hatchling`; all Makefile targets use `uv run`
204
+
205
+ ---
206
+
207
+ *Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/plan.md]*
@@ -1,7 +1,7 @@
1
1
  # mdfetch — Main Specification
2
2
 
3
- **Last Updated**: 2026-05-14
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]
5
5
 
6
6
  ---
7
7
 
@@ -72,6 +72,51 @@ 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
+
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
+
75
120
  ### US-006 — Integration Tests Pass Against Real dev.to Article URLs (P3)
76
121
  [Source: specs/002-devto-provider]
77
122
 
@@ -102,11 +147,23 @@ A developer runs the integration test suite and all dev.to integration tests pas
102
147
  ### Network
103
148
  - **FR-006**: The library MUST raise a descriptive error when a network request fails (connection error, timeout, non-2xx HTTP status).
104
149
  - **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.
150
+ - **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]
105
151
 
106
152
  ### Packaging & Testing
107
153
  - **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
154
  - **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
155
 
156
+ ### Freedium Fallback (Medium)
157
+ - **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]
158
+ - **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]
159
+ - **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]
160
+ - **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]
161
+ - **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]
162
+ - **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]
163
+ - **FR-026**: The Freedium fallback MUST only apply to Medium provider URLs; other providers are unaffected. [Source: specs/003-medium-freedium-fallback]
164
+ - **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]
165
+ - **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]
166
+
110
167
  ### dev.to Platform
111
168
  - **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
169
  - **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,13 +242,16 @@ caller provides URL string
185
242
 
186
243
  ## Edge Cases and Error Handling
187
244
 
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.
245
+ - **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]
246
+ - **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`.
247
+ - **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").
248
+ - **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
249
  - **HTML structure changes**: If Medium changes its HTML structure and `<article>` is absent, `UnsupportedContentTypeError` is raised.
190
250
  - **Empty article body**: If `<article>` is found but contains no extractable text, `EmptyContentError` is raised.
191
251
  - **Network timeouts**: Covered by `FetchError` (30-second fixed timeout).
192
252
  - **Oversized responses**: Responses exceeding 10 MB are rejected with `FetchError` to prevent OOM.
193
253
  - **Profile/tag pages**: When a `medium.com` URL points to a non-article page, `UnsupportedContentTypeError` is raised (distinct from `UnsupportedPlatformError`).
194
- - **HTTP 403 / transient failures**: Integration tests use a 3-retry helper with 2-second delay to handle transient rate limits.
254
+ - **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]
195
255
  - **dev.to profile pages**: When a `dev.to` URL points to an author profile (no `div#article-body`), `UnsupportedContentTypeError` is raised.
196
256
  - **dev.to tag listing pages**: When a `dev.to` URL points to a tag page (e.g., `dev.to/t/kafka`), `UnsupportedContentTypeError` is raised.
197
257
  - **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.
@@ -226,3 +286,15 @@ caller provides URL string
226
286
  - The library supports Python 3.12 and later.
227
287
  - The library operates on publicly accessible HTML; it does not execute JavaScript or render dynamic content.
228
288
  - Network timeouts use a fixed default of 30 seconds (not user-configurable in v1).
289
+ - **SC-018**: `make test` passes with zero failures (all unit tests green) when run without any `MDFETCH_*` environment variables. [Source: specs/004-remove-backoff]
290
+ - **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]
291
+ - **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]
292
+ - **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]
293
+ - **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]
294
+ - **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]
295
+ - **SC-016**: When the primary Medium request succeeds, there is no additional latency attributable to the fallback mechanism. [Source: specs/003-medium-freedium-fallback]
296
+ - **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]
297
+
298
+ ---
299
+
300
+ *Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/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,13 @@ 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
 
53
+ <!-- SPECKIT START -->
46
54
  ## Recent Changes
47
55
 
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
- <!-- SPECKIT START -->
52
- **Active feature plan**: none
56
+ - **004-remove-backoff**: Removed exponential backoff from `fetch_html`; restored fixed-delay retries (`retry_delay` seconds per attempt, constant between attempts). Deleted `MDFETCH_RETRIES`/`MDFETCH_RETRY_DELAY` env-var support from CI and integration fixtures; hardcoded `retries=3, retry_delay=2.0` in all integration tests. Deleted `tests/integration/conftest.py`.
53
57
  <!-- 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.2
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.2"
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.