mdfetch 0.9.4__tar.gz → 0.9.6__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 (268) hide show
  1. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/scripts/generate_resources.py +40 -7
  2. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/ci.yml +3 -3
  3. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/integration.yml +2 -2
  4. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/publish.yml +15 -9
  5. mdfetch-0.9.6/.python-version +1 -0
  6. {mdfetch-0.9.4 → mdfetch-0.9.6}/CLAUDE.md +6 -1
  7. {mdfetch-0.9.4 → mdfetch-0.9.6}/PKG-INFO +7 -3
  8. {mdfetch-0.9.4 → mdfetch-0.9.6}/README.md +6 -2
  9. {mdfetch-0.9.4 → mdfetch-0.9.6}/pyproject.toml +1 -1
  10. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/__init__.py +4 -1
  11. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/base.py +16 -4
  12. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/exceptions.py +7 -0
  13. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/medium.py +5 -3
  14. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/substack.py +1 -1
  15. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/conftest.py +2 -1
  16. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_thenewstack_integration.py +15 -3
  17. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_fallback.py +23 -1
  18. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_fetch_errors.py +53 -1
  19. mdfetch-0.9.6/uv.lock +945 -0
  20. mdfetch-0.9.4/.python-version +0 -1
  21. mdfetch-0.9.4/uv.lock +0 -767
  22. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-analyze/SKILL.md +0 -0
  23. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-checklist/SKILL.md +0 -0
  24. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-clarify/SKILL.md +0 -0
  25. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-constitution/SKILL.md +0 -0
  26. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-converge/SKILL.md +0 -0
  27. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-implement/SKILL.md +0 -0
  28. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-plan/SKILL.md +0 -0
  29. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-specify/SKILL.md +0 -0
  30. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-tasks/SKILL.md +0 -0
  31. {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-taskstoissues/SKILL.md +0 -0
  32. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-analyze/SKILL.md +0 -0
  33. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
  34. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-checklist/SKILL.md +0 -0
  35. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-clarify/SKILL.md +0 -0
  36. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-constitution/SKILL.md +0 -0
  37. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-converge/SKILL.md +0 -0
  38. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
  39. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
  40. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
  41. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
  42. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
  43. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-implement/SKILL.md +0 -0
  44. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-plan/SKILL.md +0 -0
  45. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
  46. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-specify/SKILL.md +0 -0
  47. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-tasks/SKILL.md +0 -0
  48. {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
  49. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.analyze.toml +0 -0
  50. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.checklist.toml +0 -0
  51. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.clarify.toml +0 -0
  52. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.constitution.toml +0 -0
  53. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.implement.toml +0 -0
  54. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.plan.toml +0 -0
  55. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.specify.toml +0 -0
  56. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.tasks.toml +0 -0
  57. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.taskstoissues.toml +0 -0
  58. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gitattributes +0 -0
  59. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/copilot-instructions.md +0 -0
  60. {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/dependabot.yml +0 -0
  61. {mdfetch-0.9.4 → mdfetch-0.9.6}/.gitignore +0 -0
  62. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/.registry +0 -0
  63. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/LICENSE +0 -0
  64. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/README.md +0 -0
  65. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/commands/archive.md +0 -0
  66. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/extension.yml +0 -0
  67. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/README.md +0 -0
  68. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
  69. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
  70. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
  71. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
  72. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
  73. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/config-template.yml +0 -0
  74. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/extension.yml +0 -0
  75. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/git-config.yml +0 -0
  76. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
  77. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
  78. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
  79. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
  80. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
  81. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
  82. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
  83. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
  84. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/LICENSE +0 -0
  85. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/README.md +0 -0
  86. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
  87. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/extension.yml +0 -0
  88. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions.yml +0 -0
  89. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/feature.json +0 -0
  90. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/init-options.json +0 -0
  91. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integration.json +0 -0
  92. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/agy.manifest.json +0 -0
  93. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/claude.manifest.json +0 -0
  94. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/gemini.manifest.json +0 -0
  95. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/speckit.manifest.json +0 -0
  96. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/changelog.md +0 -0
  97. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/constitution.md +0 -0
  98. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/plan.md +0 -0
  99. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/spec.md +0 -0
  100. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/check-prerequisites.sh +0 -0
  101. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/common.sh +0 -0
  102. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/create-new-feature.sh +0 -0
  103. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/setup-plan.sh +0 -0
  104. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/setup-tasks.sh +0 -0
  105. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/checklist-template.md +0 -0
  106. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/constitution-template.md +0 -0
  107. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/plan-template.md +0 -0
  108. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/spec-template.md +0 -0
  109. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/tasks-template.md +0 -0
  110. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/workflows/speckit/workflow.yml +0 -0
  111. {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/workflows/workflow-registry.json +0 -0
  112. /mdfetch-0.9.4/GEMINI.md → /mdfetch-0.9.6/AGENTS.md +0 -0
  113. {mdfetch-0.9.4 → mdfetch-0.9.6}/LICENSE +0 -0
  114. {mdfetch-0.9.4 → mdfetch-0.9.6}/Makefile +0 -0
  115. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
  116. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
  117. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
  118. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
  119. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
  120. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/research.md +0 -0
  121. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
  122. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
  123. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/checklists/requirements.md +0 -0
  124. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/contracts/public-api.md +0 -0
  125. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/data-model.md +0 -0
  126. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/plan.md +0 -0
  127. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/quickstart.md +0 -0
  128. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/research.md +0 -0
  129. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/spec.md +0 -0
  130. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/tasks.md +0 -0
  131. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/checklists/requirements.md +0 -0
  132. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/contracts/extract-api.md +0 -0
  133. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/plan.md +0 -0
  134. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/research.md +0 -0
  135. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/spec.md +0 -0
  136. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/tasks.md +0 -0
  137. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/checklists/requirements.md +0 -0
  138. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/plan.md +0 -0
  139. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/research.md +0 -0
  140. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/spec.md +0 -0
  141. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/tasks.md +0 -0
  142. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/checklists/requirements.md +0 -0
  143. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/contracts/extractor-api.md +0 -0
  144. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/data-model.md +0 -0
  145. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/plan.md +0 -0
  146. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/quickstart.md +0 -0
  147. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/research.md +0 -0
  148. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/spec.md +0 -0
  149. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/tasks.md +0 -0
  150. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/checklists/requirements.md +0 -0
  151. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/contracts/public-api.md +0 -0
  152. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/data-model.md +0 -0
  153. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/plan.md +0 -0
  154. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/quickstart.md +0 -0
  155. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/research.md +0 -0
  156. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/spec.md +0 -0
  157. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/tasks.md +0 -0
  158. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/checklists/requirements.md +0 -0
  159. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/contracts/public-api.md +0 -0
  160. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/data-model.md +0 -0
  161. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/plan.md +0 -0
  162. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/quickstart.md +0 -0
  163. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/research.md +0 -0
  164. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/spec.md +0 -0
  165. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/tasks.md +0 -0
  166. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/checklists/requirements.md +0 -0
  167. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/contracts/cli.md +0 -0
  168. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/data-model.md +0 -0
  169. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/plan.md +0 -0
  170. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/quickstart.md +0 -0
  171. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/research.md +0 -0
  172. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/spec.md +0 -0
  173. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/tasks.md +0 -0
  174. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/checklists/requirements.md +0 -0
  175. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/contracts/formula.md +0 -0
  176. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/contracts/tap-update-job.md +0 -0
  177. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/data-model.md +0 -0
  178. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/plan.md +0 -0
  179. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/quickstart.md +0 -0
  180. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/research.md +0 -0
  181. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/spec.md +0 -0
  182. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/tasks.md +0 -0
  183. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/checklists/requirements.md +0 -0
  184. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/contracts/extractor-contract.md +0 -0
  185. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/data-model.md +0 -0
  186. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/plan.md +0 -0
  187. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/quickstart.md +0 -0
  188. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/research.md +0 -0
  189. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/spec.md +0 -0
  190. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/tasks.md +0 -0
  191. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/checklists/requirements.md +0 -0
  192. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/contracts/extractor-contract.md +0 -0
  193. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/data-model.md +0 -0
  194. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/plan.md +0 -0
  195. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/quickstart.md +0 -0
  196. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/research.md +0 -0
  197. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/spec.md +0 -0
  198. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/tasks.md +0 -0
  199. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/checklists/requirements.md +0 -0
  200. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/contracts/cli.md +0 -0
  201. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/data-model.md +0 -0
  202. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/plan.md +0 -0
  203. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/quickstart.md +0 -0
  204. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/research.md +0 -0
  205. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/spec.md +0 -0
  206. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/tasks.md +0 -0
  207. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/checklists/requirements.md +0 -0
  208. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/contracts/api.md +0 -0
  209. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/contracts/cli.md +0 -0
  210. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/data-model.md +0 -0
  211. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/plan.md +0 -0
  212. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/quickstart.md +0 -0
  213. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/research.md +0 -0
  214. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/spec.md +0 -0
  215. {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/tasks.md +0 -0
  216. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/cli.py +0 -0
  217. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/fallback.py +0 -0
  218. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/__init__.py +0 -0
  219. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/boomi.py +0 -0
  220. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/devto.py +0 -0
  221. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/dzone.py +0 -0
  222. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/kong.py +0 -0
  223. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/thenewstack.py +0 -0
  224. {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/router.py +0 -0
  225. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/__init__.py +0 -0
  226. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/__init__.py +0 -0
  227. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/conftest.py +0 -0
  228. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +0 -0
  229. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-data-consistency-saas-on-prem.md +0 -0
  230. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-gartner-magic-quadrant-ipaas-2026.md +0 -0
  231. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-real-time-vs-batch-data-integration.md +0 -0
  232. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
  233. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
  234. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
  235. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-image-classification-pipeline-camel-djl.md +0 -0
  236. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-integration-patterns-fail-production.md +0 -0
  237. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-kiro-feature-to-requirements-design-tasks.md +0 -0
  238. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/from-drift-to-parity.md +0 -0
  239. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/integration-digest-december-2025.md +0 -0
  240. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-ai-gateway-vs-litellm.md +0 -0
  241. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-gateway-3-14.md +0 -0
  242. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-insomnia-12-6.md +0 -0
  243. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/substack-api-trends-2025.md +0 -0
  244. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/substack-kafka-topic-types.md +0 -0
  245. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-api-mcp-agent.md +0 -0
  246. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-async-apis.md +0 -0
  247. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-developer-portal-api.md +0 -0
  248. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-json-schema-ai.md +0 -0
  249. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-mcp-api-governance.md +0 -0
  250. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_boomi_integration.py +0 -0
  251. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_cli_integration.py +0 -0
  252. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_devto_integration.py +0 -0
  253. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_dzone_integration.py +0 -0
  254. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_fallback_integration.py +0 -0
  255. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_kong_integration.py +0 -0
  256. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_medium_integration.py +0 -0
  257. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_substack_integration.py +0 -0
  258. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/__init__.py +0 -0
  259. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_boomi_extractor.py +0 -0
  260. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_cli.py +0 -0
  261. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_devto_extractor.py +0 -0
  262. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_dzone_extractor.py +0 -0
  263. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_kong_extractor.py +0 -0
  264. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_medium_extractor.py +0 -0
  265. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_router.py +0 -0
  266. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_silent.py +0 -0
  267. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_substack_extractor.py +0 -0
  268. {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_thenewstack_extractor.py +0 -0
@@ -1,20 +1,39 @@
1
- import sys
1
+ import argparse
2
2
  import json
3
+ import sys
3
4
  import urllib.request
4
5
 
5
- def generate():
6
+ from packaging.markers import Marker, default_environment
7
+
8
+
9
+ def target_environment(python_version):
10
+ """Marker environment of the Homebrew install: macOS with the formula's Python."""
11
+ env = default_environment()
12
+ env.update(
13
+ python_version=python_version,
14
+ python_full_version=f"{python_version}.0",
15
+ sys_platform="darwin",
16
+ platform_system="Darwin",
17
+ os_name="posix",
18
+ )
19
+ return env
20
+
21
+
22
+ def generate(python_version):
23
+ env = target_environment(python_version)
24
+ seen = {}
6
25
  for line in sys.stdin:
7
26
  line = line.strip()
8
27
  # Skip empty lines, comments, and local editable installs
9
28
  if not line or line.startswith('#') or line.startswith('-e'):
10
29
  continue
11
30
 
12
- # Very basic environment marker filtering to skip Windows/Linux specific packages
31
+ # Keep only requirements whose environment marker holds for the Homebrew
32
+ # install. A lock resolved for several Python versions lists some
33
+ # packages once per version range, e.g. anyio for < 3.15 and >= 3.15.
13
34
  if ';' in line:
14
35
  req, marker = line.split(';', 1)
15
- # Remove whitespace and quotes for simple matching
16
- marker_clean = marker.replace(' ', '').replace('"', '').replace("'", "")
17
- if 'sys_platform==win32' in marker_clean or 'sys_platform==linux' in marker_clean:
36
+ if not Marker(marker.strip()).evaluate(env):
18
37
  continue
19
38
  line = req.strip()
20
39
 
@@ -29,6 +48,16 @@ def generate():
29
48
  # We don't need a resource block for mdfetch itself
30
49
  if pkg == "mdfetch":
31
50
  continue
51
+
52
+ # Homebrew keeps only the first resource of a given name, so a second
53
+ # one would be dropped silently. Fail instead of guessing.
54
+ if pkg in seen:
55
+ print(
56
+ f"ERROR: {pkg} is pinned twice ({seen[pkg]} and {ver}) for Python {python_version}",
57
+ file=sys.stderr,
58
+ )
59
+ sys.exit(1)
60
+ seen[pkg] = ver
32
61
 
33
62
  url = f"https://pypi.org/pypi/{pkg}/{ver}/json"
34
63
  try:
@@ -56,4 +85,8 @@ def generate():
56
85
  print(f' end\n')
57
86
 
58
87
  if __name__ == "__main__":
59
- generate()
88
+ parser = argparse.ArgumentParser(
89
+ description="Generate Homebrew resource blocks from `uv export` output on stdin."
90
+ )
91
+ parser.add_argument("--python", required=True, help="Python version of the formula, e.g. 3.13")
92
+ generate(parser.parse_args().python)
@@ -14,12 +14,12 @@ jobs:
14
14
  steps:
15
15
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
16
16
  - name: Install uv
17
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
17
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
18
18
  with:
19
19
  enable-cache: true
20
20
  cache-dependency-glob: "uv.lock"
21
21
  - name: Set up Python
22
- run: uv python pin 3.12
22
+ run: uv python pin 3.14
23
23
  - name: Install dependencies
24
24
  run: uv sync --frozen --all-extras
25
25
  - name: Check formatting
@@ -38,7 +38,7 @@ jobs:
38
38
  steps:
39
39
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40
40
  - name: Install uv
41
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
41
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
42
42
  with:
43
43
  enable-cache: true
44
44
  cache-dependency-glob: "uv.lock"
@@ -17,13 +17,13 @@ jobs:
17
17
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
18
18
 
19
19
  - name: Install uv
20
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
20
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
21
21
  with:
22
22
  enable-cache: true
23
23
  cache-dependency-glob: "uv.lock"
24
24
 
25
25
  - name: Set up Python
26
- run: uv python pin 3.12
26
+ run: uv python pin 3.14
27
27
 
28
28
  - name: Install dependencies
29
29
  run: uv sync --frozen --all-extras
@@ -15,9 +15,9 @@ jobs:
15
15
  steps:
16
16
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
17
17
  - name: Install uv
18
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
18
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
19
19
  - name: Set up Python
20
- run: uv python pin 3.12
20
+ run: uv python pin 3.14
21
21
  - name: Validate version
22
22
  run: |
23
23
  TAG_VERSION=${GITHUB_REF_NAME#v}
@@ -29,7 +29,7 @@ jobs:
29
29
  - name: Build package
30
30
  run: make build
31
31
  - name: Store the distribution packages
32
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
32
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
33
33
  with:
34
34
  name: python-package-distributions
35
35
  path: dist/
@@ -45,12 +45,12 @@ jobs:
45
45
  id-token: write
46
46
  steps:
47
47
  - name: Download all the dists
48
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
48
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
49
49
  with:
50
50
  name: python-package-distributions
51
51
  path: dist/
52
52
  - name: Publish package distributions to PyPI
53
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
53
+ uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
54
54
  with:
55
55
  packages-dir: dist/
56
56
 
@@ -68,11 +68,13 @@ jobs:
68
68
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69
69
 
70
70
  - name: Install uv
71
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
71
+ uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
72
72
 
73
73
  - name: Set up Python
74
- run: uv python pin 3.12
75
-
74
+ # Matches `depends_on "python@3.13"` in Formula/md-fetch.rb and the
75
+ # --python 3.13 flag below, not the 3.14 used by the other jobs.
76
+ run: uv python pin 3.13
77
+
76
78
  - name: Fetch sdist info from PyPI (with retry)
77
79
  env:
78
80
  VERSION: ${{ github.ref_name }}
@@ -104,7 +106,11 @@ jobs:
104
106
 
105
107
  - name: Generate PyPI resource blocks
106
108
  run: |
107
- uv export --no-dev --no-hashes --format requirements-txt | python3 .github/scripts/generate_resources.py > /tmp/resources.rb
109
+ # --python must match `depends_on "python@X.Y"` in Formula/md-fetch.rb,
110
+ # so environment markers are evaluated for the Python Homebrew uses.
111
+ uv export --no-dev --no-hashes --format requirements-txt \
112
+ | uv run --no-project --with packaging python .github/scripts/generate_resources.py --python 3.13 \
113
+ > /tmp/resources.rb
108
114
 
109
115
  - name: Clone homebrew-tap and update formula
110
116
  env:
@@ -0,0 +1 @@
1
+ 3.14
@@ -8,7 +8,7 @@ Python library that extracts article content from web platforms and returns it a
8
8
  src/mdfetch/
9
9
  ├── __init__.py # extract() public API + exception re-exports
10
10
  ├── cli.py # Command-line interface and argument parsing
11
- ├── exceptions.py # MdfetchError hierarchy (7 exception classes)
11
+ ├── exceptions.py # MdfetchError hierarchy (8 exception classes)
12
12
  ├── base.py # BaseExtractor ABC
13
13
  ├── router.py # Domain-to-provider routing
14
14
  └── providers/
@@ -76,6 +76,11 @@ make typecheck # type check
76
76
  **Root Cause:** VoxPop is injected outside `div#tns-post-body-content` — confirmed via live DOM inspection across all 5 reference articles.
77
77
  **Prevention Rule:** Do not add VoxPop stripping to `TheNewStackExtractor.clean_html()`. If a future redesign moves VoxPop inside the body container, update `clean_html()` to decompose `div.tns-voxpop-screen`.
78
78
 
79
+ ### ⚠️ thenewstack.io is behind a Cloudflare bot challenge (since 2026-11)
80
+ **Issue:** Every request from md-fetch to thenewstack.io gets HTTP 403 with `cf-mitigated: challenge` and a "Just a moment..." JS page. Weekly integration tests failed on it (issue #50).
81
+ **Root Cause:** Site-side bot protection, not an HTML change. The block is deliberate, so md-fetch must not try to bypass it (no User-Agent tweaks, no headless browser).
82
+ **Prevention Rule:** `BaseExtractor._do_fetch()` raises `AccessBlockedError` (subclass of `HTTPStatusError`) for any provider when `cf-mitigated: challenge` is present, and never retries it. The thenewstack integration tests `pytest.skip` on `AccessBlockedError`, so check the skip reasons in the weekly log to see when the block is lifted.
83
+
79
84
  ### ⚠️ Homebrew formula requires `uses_from_macos` for lxml
80
85
  **Issue:** `brew audit --strict --new Formula/md-fetch.rb` fails with missing system library declarations when `lxml` is a resource block.
81
86
  **Root Cause:** `lxml` requires `libxml2` and `libxslt`, which are macOS system libraries. Homebrew requires these to be declared explicitly via `uses_from_macos`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mdfetch
3
- Version: 0.9.4
3
+ Version: 0.9.6
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
@@ -85,7 +85,8 @@ markdown = extract("https://dzone.com/articles/article-slug")
85
85
  markdown = extract("https://boomi.com/blog/article-slug")
86
86
  markdown = extract("https://konghq.com/blog/category/article-slug")
87
87
 
88
- # Fallback to Tavily API if the primary extractor fails (requires TAVILY_API_KEY env var)
88
+ # Fallback to Tavily API if the primary extractor fails (requires TAVILY_API_KEY env var).
89
+ # This also covers sites that block md-fetch with bot protection (AccessBlockedError).
89
90
  markdown = extract("https://example.com/unsupported-blog", tavily_fallback=True)
90
91
  print(markdown)
91
92
  ```
@@ -100,6 +101,7 @@ from mdfetch import (
100
101
  UnsupportedContentTypeError,
101
102
  FetchError,
102
103
  HTTPStatusError,
104
+ AccessBlockedError,
103
105
  EmptyContentError,
104
106
  MissingAPIKeyError,
105
107
  )
@@ -114,6 +116,8 @@ except UnsupportedPlatformError as e:
114
116
  print(f"Platform not supported: {e.message}")
115
117
  except UnsupportedContentTypeError as e:
116
118
  print(f"Not an article page: {e.message}")
119
+ except AccessBlockedError as e: # subclass of HTTPStatusError, so catch it first
120
+ print(f"Blocked by bot protection: {e.message}")
117
121
  except HTTPStatusError as e:
118
122
  print(f"HTTP {e.status_code}: {e.message}")
119
123
  except FetchError as e:
@@ -131,7 +135,7 @@ except MissingAPIKeyError as e:
131
135
  | Medium | `medium.com`, `*.medium.com` |
132
136
  | dev.to | `dev.to` |
133
137
  | Substack | `substack.com`, `*.substack.com` |
134
- | The New Stack | `thenewstack.io` |
138
+ | The New Stack | `thenewstack.io` (may be blocked by Cloudflare bot protection; raises `AccessBlockedError`) |
135
139
  | DZone | `dzone.com` |
136
140
  | Boomi | `boomi.com` |
137
141
  | Kong | `konghq.com` |
@@ -51,7 +51,8 @@ markdown = extract("https://dzone.com/articles/article-slug")
51
51
  markdown = extract("https://boomi.com/blog/article-slug")
52
52
  markdown = extract("https://konghq.com/blog/category/article-slug")
53
53
 
54
- # Fallback to Tavily API if the primary extractor fails (requires TAVILY_API_KEY env var)
54
+ # Fallback to Tavily API if the primary extractor fails (requires TAVILY_API_KEY env var).
55
+ # This also covers sites that block md-fetch with bot protection (AccessBlockedError).
55
56
  markdown = extract("https://example.com/unsupported-blog", tavily_fallback=True)
56
57
  print(markdown)
57
58
  ```
@@ -66,6 +67,7 @@ from mdfetch import (
66
67
  UnsupportedContentTypeError,
67
68
  FetchError,
68
69
  HTTPStatusError,
70
+ AccessBlockedError,
69
71
  EmptyContentError,
70
72
  MissingAPIKeyError,
71
73
  )
@@ -80,6 +82,8 @@ except UnsupportedPlatformError as e:
80
82
  print(f"Platform not supported: {e.message}")
81
83
  except UnsupportedContentTypeError as e:
82
84
  print(f"Not an article page: {e.message}")
85
+ except AccessBlockedError as e: # subclass of HTTPStatusError, so catch it first
86
+ print(f"Blocked by bot protection: {e.message}")
83
87
  except HTTPStatusError as e:
84
88
  print(f"HTTP {e.status_code}: {e.message}")
85
89
  except FetchError as e:
@@ -97,7 +101,7 @@ except MissingAPIKeyError as e:
97
101
  | Medium | `medium.com`, `*.medium.com` |
98
102
  | dev.to | `dev.to` |
99
103
  | Substack | `substack.com`, `*.substack.com` |
100
- | The New Stack | `thenewstack.io` |
104
+ | The New Stack | `thenewstack.io` (may be blocked by Cloudflare bot protection; raises `AccessBlockedError`) |
101
105
  | DZone | `dzone.com` |
102
106
  | Boomi | `boomi.com` |
103
107
  | Kong | `konghq.com` |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "mdfetch"
7
- version = "0.9.4"
7
+ version = "0.9.6"
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" }
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import os
6
6
 
7
7
  from mdfetch.exceptions import (
8
+ AccessBlockedError,
8
9
  EmptyContentError,
9
10
  FetchError,
10
11
  HTTPStatusError,
@@ -26,6 +27,7 @@ __all__ = [
26
27
  "UnsupportedContentTypeError",
27
28
  "FetchError",
28
29
  "HTTPStatusError",
30
+ "AccessBlockedError",
29
31
  "EmptyContentError",
30
32
  "MissingAPIKeyError",
31
33
  ]
@@ -43,7 +45,8 @@ def extract(
43
45
  On transient network failures (timeouts, connection errors, non-2xx responses) the
44
46
  request is attempted up to *retries* times total (so ``retries=3`` means one initial
45
47
  attempt plus two retries) with a fixed delay of *retry_delay* seconds between attempts.
46
- Set ``retries=1`` to disable retries.
48
+ Set ``retries=1`` to disable retries. A bot-protection block (:class:`AccessBlockedError`)
49
+ is raised at once, without retries.
47
50
  """
48
51
  if tavily_fallback and not os.environ.get("TAVILY_API_KEY"):
49
52
  raise MissingAPIKeyError(
@@ -14,6 +14,7 @@ from bs4.element import Tag
14
14
  from markdownify import markdownify
15
15
 
16
16
  from mdfetch.exceptions import (
17
+ AccessBlockedError,
17
18
  EmptyContentError,
18
19
  FetchError,
19
20
  HTTPStatusError,
@@ -55,9 +56,10 @@ class BaseExtractor(ABC):
55
56
  Makes up to *retries* total attempts on transient :class:`FetchError` (network
56
57
  errors, timeouts, non-2xx responses) with a fixed delay of *retry_delay* seconds
57
58
  between attempts. Status codes listed in :attr:`_no_retry_status_codes` are
58
- raised immediately without any retry or sleep. Pass ``_no_retry_codes`` to
59
- override the class-level set for this call only (used internally for per-call
60
- overrides).
59
+ raised immediately without any retry or sleep, and so is
60
+ :class:`AccessBlockedError` (a bot challenge cannot pass on retry). Pass
61
+ ``_no_retry_codes`` to override the class-level set for this call only (used
62
+ internally for per-call overrides).
61
63
  """
62
64
  no_retry = self._no_retry_status_codes if _no_retry_codes is None else _no_retry_codes
63
65
  last_exc: FetchError | None = None
@@ -66,7 +68,9 @@ class BaseExtractor(ABC):
66
68
  try:
67
69
  return self._do_fetch(url, client)
68
70
  except FetchError as exc:
69
- if isinstance(exc, HTTPStatusError) and exc.status_code in no_retry:
71
+ if isinstance(exc, AccessBlockedError) or (
72
+ isinstance(exc, HTTPStatusError) and exc.status_code in no_retry
73
+ ):
70
74
  raise
71
75
  last_exc = exc
72
76
  if attempt < retries - 1:
@@ -81,6 +85,14 @@ class BaseExtractor(ABC):
81
85
  try:
82
86
  with client.stream("GET", url, headers=headers) as response:
83
87
  if not response.is_success:
88
+ cf_mitigated = (response.headers.get("cf-mitigated") or "").strip().lower()
89
+ if cf_mitigated == "challenge":
90
+ raise AccessBlockedError(
91
+ f"HTTP {response.status_code} fetching {url}: access blocked by "
92
+ "the site's bot protection (Cloudflare challenge)",
93
+ status_code=response.status_code,
94
+ url=url,
95
+ )
84
96
  raise HTTPStatusError(
85
97
  f"HTTP {response.status_code} fetching {url}",
86
98
  status_code=response.status_code,
@@ -36,6 +36,13 @@ class HTTPStatusError(FetchError):
36
36
  self.status_code = status_code
37
37
 
38
38
 
39
+ class AccessBlockedError(HTTPStatusError):
40
+ """Raised when the site's bot protection (e.g. a Cloudflare challenge) blocks the request.
41
+
42
+ Retrying does not help: the site requires a real browser to pass the challenge.
43
+ """
44
+
45
+
39
46
  class EmptyContentError(MdfetchError):
40
47
  """Raised when the article body yields no extractable text content."""
41
48
 
@@ -46,13 +46,15 @@ class MediumExtractor(BaseExtractor):
46
46
  ):
47
47
  button.decompose()
48
48
 
49
- for element in article.find_all(attrs={"data-testid": "post-sidebar"}):
49
+ for element in article.find_all(None, attrs={"data-testid": "post-sidebar"}):
50
50
  element.decompose()
51
51
 
52
- for element in article.find_all(attrs={"data-testid": re.compile(r"share", re.IGNORECASE)}):
52
+ for element in article.find_all(
53
+ None, attrs={"data-testid": re.compile(r"share", re.IGNORECASE)}
54
+ ):
53
55
  element.decompose()
54
56
 
55
- for element in article.find_all(attrs={"data-testid": "post-footer"}):
57
+ for element in article.find_all(None, attrs={"data-testid": "post-footer"}):
56
58
  element.decompose()
57
59
 
58
60
  for section in article.find_all("section"):
@@ -40,7 +40,7 @@ class SubstackExtractor(BaseExtractor):
40
40
  self._replace_embeds_with_links(
41
41
  [
42
42
  e
43
- for e in body.find_all(attrs={"data-component-name": True})
43
+ for e in body.find_all(None, attrs={"data-component-name": True})
44
44
  if e.get("data-component-name") not in _safe_components
45
45
  ],
46
46
  soup,
@@ -12,6 +12,7 @@ def make_stream_mock(
12
12
  body: bytes = b"<html></html>",
13
13
  content_type: str = "text/html; charset=utf-8",
14
14
  stream_side_effect: Exception | None = None,
15
+ headers: dict[str, str] | None = None,
15
16
  ) -> MagicMock:
16
17
  """Build a mock httpx.Client whose .stream() behaves like a real streaming response."""
17
18
  mock_response = MagicMock()
@@ -19,7 +20,7 @@ def make_stream_mock(
19
20
  mock_response.status_code = status_code
20
21
  mock_response.encoding = "utf-8"
21
22
  mock_response.iter_bytes.side_effect = lambda: iter([body])
22
- mock_response.headers = {"content-type": content_type}
23
+ mock_response.headers = {"content-type": content_type, **(headers or {})}
23
24
 
24
25
  mock_stream_ctx = MagicMock()
25
26
  mock_stream_ctx.__enter__ = MagicMock(return_value=mock_response)
@@ -7,10 +7,14 @@ from pathlib import Path
7
7
  import pytest
8
8
 
9
9
  from mdfetch import extract
10
- from mdfetch.exceptions import UnsupportedContentTypeError
10
+ from mdfetch.exceptions import AccessBlockedError, UnsupportedContentTypeError
11
11
 
12
12
  SNAPSHOTS_DIR = Path(__file__).parent / "snapshots"
13
13
 
14
+ # Since 2026-11 thenewstack.io serves a Cloudflare bot challenge to non-browser
15
+ # clients. Skip instead of failing so the weekly run still reports other providers.
16
+ BLOCKED_REASON = "thenewstack.io blocked by Cloudflare bot challenge"
17
+
14
18
  THENEWSTACK_TEST_CASES: list[tuple[str, str]] = [
15
19
  (
16
20
  "https://thenewstack.io/using-a-developer-portal-for-api-management/",
@@ -45,7 +49,10 @@ def test_extract_contains_snapshot(url: str, snapshot: str) -> None:
45
49
  requiring an exact match, making tests resilient to minor structural changes.
46
50
  """
47
51
  expected = (SNAPSHOTS_DIR / snapshot).read_text(encoding="utf-8")
48
- result = extract(url, retries=3, retry_delay=2.0)
52
+ try:
53
+ result = extract(url, retries=3, retry_delay=2.0)
54
+ except AccessBlockedError:
55
+ pytest.skip(f"{BLOCKED_REASON}: {url}")
49
56
  assert expected in result, (
50
57
  f"Extracted content for {snapshot!r} does not contain the stored snapshot body.\n"
51
58
  f"To regenerate the snapshot, run:\n"
@@ -60,5 +67,10 @@ def test_extract_contains_snapshot(url: str, snapshot: str) -> None:
60
67
  @pytest.mark.integration
61
68
  def test_homepage_raises_unsupported_content_type_error() -> None:
62
69
  """US2: A thenewstack.io homepage raises UnsupportedContentTypeError."""
63
- with pytest.raises(UnsupportedContentTypeError):
70
+ try:
64
71
  extract("https://thenewstack.io/", retries=1, retry_delay=0.0)
72
+ except AccessBlockedError:
73
+ pytest.skip(f"{BLOCKED_REASON}: https://thenewstack.io/")
74
+ except UnsupportedContentTypeError:
75
+ return
76
+ pytest.fail("Expected UnsupportedContentTypeError for the thenewstack.io homepage")
@@ -7,7 +7,12 @@ from unittest.mock import MagicMock, patch
7
7
  import pytest
8
8
 
9
9
  from mdfetch import extract
10
- from mdfetch.exceptions import EmptyContentError, FetchError, MissingAPIKeyError
10
+ from mdfetch.exceptions import (
11
+ AccessBlockedError,
12
+ EmptyContentError,
13
+ FetchError,
14
+ MissingAPIKeyError,
15
+ )
11
16
  from mdfetch.fallback import tavily_extract
12
17
 
13
18
 
@@ -121,3 +126,20 @@ class TestExtractRouting:
121
126
 
122
127
  assert result == "# Fallback content"
123
128
  mock_tavily.assert_called_once_with("https://medium.com/test", timeout=30.0)
129
+
130
+ @patch("mdfetch.route")
131
+ @patch("mdfetch.tavily_extract")
132
+ @patch.dict("os.environ", {"TAVILY_API_KEY": "fake-key"})
133
+ def test_access_blocked_routes_to_fallback(
134
+ self, mock_tavily: MagicMock, mock_route: MagicMock
135
+ ) -> None:
136
+ url = "https://thenewstack.io/article"
137
+ mock_tavily.return_value = "# Fallback content"
138
+ mock_route.return_value.extract.side_effect = AccessBlockedError(
139
+ "blocked", status_code=403, url=url
140
+ )
141
+
142
+ result = extract(url, tavily_fallback=True)
143
+
144
+ assert result == "# Fallback content"
145
+ mock_tavily.assert_called_once_with(url, timeout=30.0)
@@ -7,7 +7,12 @@ from unittest.mock import call, patch
7
7
  import httpx
8
8
  import pytest
9
9
 
10
- from mdfetch.exceptions import FetchError, HTTPStatusError, UnsupportedContentTypeError
10
+ from mdfetch.exceptions import (
11
+ AccessBlockedError,
12
+ FetchError,
13
+ HTTPStatusError,
14
+ UnsupportedContentTypeError,
15
+ )
11
16
  from mdfetch.providers.medium import MediumExtractor
12
17
  from tests.conftest import make_stream_mock
13
18
 
@@ -101,6 +106,53 @@ class TestFetchErrors:
101
106
  assert mock_sleep.call_count == 2 # 3 attempts → 2 sleeps
102
107
  assert mock_sleep.call_args_list == [call(1.0), call(1.0)] # fixed delay, not exponential
103
108
 
109
+ def test_cloudflare_challenge_raises_access_blocked_error(
110
+ self, extractor: MediumExtractor
111
+ ) -> None:
112
+ mock = make_stream_mock(
113
+ status_code=403, is_success=False, headers={"cf-mitigated": "challenge"}
114
+ )
115
+ with patch("httpx.Client", return_value=mock):
116
+ with pytest.raises(AccessBlockedError, match="bot protection") as exc_info:
117
+ extractor.fetch_html("https://medium.com/article", retries=1)
118
+
119
+ assert exc_info.value.status_code == 403
120
+ assert isinstance(exc_info.value, HTTPStatusError)
121
+ assert isinstance(exc_info.value, FetchError)
122
+
123
+ def test_cloudflare_challenge_value_is_matched_case_insensitively(
124
+ self, extractor: MediumExtractor
125
+ ) -> None:
126
+ mock = make_stream_mock(
127
+ status_code=403, is_success=False, headers={"cf-mitigated": " Challenge "}
128
+ )
129
+ with patch("httpx.Client", return_value=mock):
130
+ with pytest.raises(AccessBlockedError):
131
+ extractor.fetch_html("https://medium.com/article", retries=1)
132
+
133
+ def test_cloudflare_challenge_is_not_retried(self, extractor: MediumExtractor) -> None:
134
+ """A bot challenge cannot pass on retry, so it must raise without any sleep."""
135
+ mock = make_stream_mock(
136
+ status_code=503, is_success=False, headers={"cf-mitigated": "challenge"}
137
+ )
138
+ with patch("httpx.Client", return_value=mock):
139
+ with patch("mdfetch.base.time.sleep") as mock_sleep:
140
+ with pytest.raises(AccessBlockedError):
141
+ extractor.fetch_html("https://medium.com/article", retries=3)
142
+
143
+ mock_sleep.assert_not_called()
144
+ assert mock.stream.call_count == 1
145
+
146
+ def test_403_without_challenge_header_is_plain_http_status_error(
147
+ self, extractor: MediumExtractor
148
+ ) -> None:
149
+ mock = make_stream_mock(status_code=403, is_success=False)
150
+ with patch("httpx.Client", return_value=mock):
151
+ with pytest.raises(HTTPStatusError) as exc_info:
152
+ extractor.fetch_html("https://medium.com/article", retries=1)
153
+
154
+ assert not isinstance(exc_info.value, AccessBlockedError)
155
+
104
156
  def test_raises_unsupported_content_type_for_json(self, extractor: MediumExtractor) -> None:
105
157
  mock = make_stream_mock(content_type="application/json")
106
158
  with patch("httpx.Client", return_value=mock):