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.
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/scripts/generate_resources.py +40 -7
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/ci.yml +3 -3
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/integration.yml +2 -2
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/workflows/publish.yml +15 -9
- mdfetch-0.9.6/.python-version +1 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/CLAUDE.md +6 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/PKG-INFO +7 -3
- {mdfetch-0.9.4 → mdfetch-0.9.6}/README.md +6 -2
- {mdfetch-0.9.4 → mdfetch-0.9.6}/pyproject.toml +1 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/__init__.py +4 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/base.py +16 -4
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/exceptions.py +7 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/medium.py +5 -3
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/substack.py +1 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/conftest.py +2 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_thenewstack_integration.py +15 -3
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_fallback.py +23 -1
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_fetch_errors.py +53 -1
- mdfetch-0.9.6/uv.lock +945 -0
- mdfetch-0.9.4/.python-version +0 -1
- mdfetch-0.9.4/uv.lock +0 -767
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-analyze/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-checklist/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-clarify/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-constitution/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-converge/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-implement/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-plan/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-specify/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-tasks/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.agents/skills/speckit-taskstoissues/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-analyze/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-checklist/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-clarify/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-constitution/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-converge/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-implement/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-plan/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-specify/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-tasks/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.analyze.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.checklist.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.clarify.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.constitution.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.implement.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.plan.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.specify.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.tasks.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gemini/commands/speckit.taskstoissues.toml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gitattributes +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/copilot-instructions.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.github/dependabot.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.gitignore +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/.registry +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/LICENSE +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/README.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/commands/archive.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/archive/extension.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/README.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/config-template.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/extension.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/git-config.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/LICENSE +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/README.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions/reconcile/extension.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/extensions.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/feature.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/init-options.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integration.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/agy.manifest.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/claude.manifest.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/gemini.manifest.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/integrations/speckit.manifest.json +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/changelog.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/constitution.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/memory/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/check-prerequisites.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/common.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/setup-plan.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/scripts/bash/setup-tasks.sh +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/checklist-template.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/constitution-template.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/plan-template.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/spec-template.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/templates/tasks-template.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/workflows/speckit/workflow.yml +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/.specify/workflows/workflow-registry.json +0 -0
- /mdfetch-0.9.4/GEMINI.md → /mdfetch-0.9.6/AGENTS.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/LICENSE +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/Makefile +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/contracts/public-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/002-devto-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/contracts/extract-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/003-medium-freedium-fallback/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/004-remove-backoff/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/contracts/extractor-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/005-substack-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/contracts/public-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/006-thenewstack-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/contracts/public-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/007-dzone-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/contracts/cli.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/008-mdfetch-cli/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/contracts/formula.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/contracts/tap-update-job.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/009-homebrew-tap-formula/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/contracts/extractor-contract.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/010-boomi-blog-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/contracts/extractor-contract.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/011-konghq-blog-provider/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/contracts/cli.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/012-list-platforms/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/checklists/requirements.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/contracts/api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/contracts/cli.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/data-model.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/plan.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/quickstart.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/research.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/spec.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/specs/013-tavily-fallback/tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/cli.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/fallback.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/__init__.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/boomi.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/devto.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/dzone.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/kong.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/providers/thenewstack.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/src/mdfetch/router.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/__init__.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/__init__.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/conftest.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-data-consistency-saas-on-prem.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-gartner-magic-quadrant-ipaas-2026.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/boomi-real-time-vs-batch-data-integration.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-image-classification-pipeline-camel-djl.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-integration-patterns-fail-production.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/dzone-kiro-feature-to-requirements-design-tasks.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/from-drift-to-parity.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/integration-digest-december-2025.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-ai-gateway-vs-litellm.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-gateway-3-14.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/kong-insomnia-12-6.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/substack-api-trends-2025.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/substack-kafka-topic-types.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-api-mcp-agent.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-async-apis.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-developer-portal-api.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-json-schema-ai.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/snapshots/thenewstack-mcp-api-governance.md +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_boomi_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_cli_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_devto_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_dzone_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_fallback_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_kong_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_medium_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/integration/test_substack_integration.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/__init__.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_boomi_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_cli.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_devto_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_dzone_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_kong_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_medium_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_router.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_silent.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_substack_extractor.py +0 -0
- {mdfetch-0.9.4 → mdfetch-0.9.6}/tests/unit/test_thenewstack_extractor.py +0 -0
|
@@ -1,20 +1,39 @@
|
|
|
1
|
-
import
|
|
1
|
+
import argparse
|
|
2
2
|
import json
|
|
3
|
+
import sys
|
|
3
4
|
import urllib.request
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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@
|
|
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.
|
|
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@
|
|
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@
|
|
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.
|
|
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@
|
|
18
|
+
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
19
19
|
- name: Set up Python
|
|
20
|
-
run: uv python pin 3.
|
|
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@
|
|
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@
|
|
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@
|
|
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@
|
|
71
|
+
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
|
72
72
|
|
|
73
73
|
- name: Set up Python
|
|
74
|
-
|
|
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
|
-
|
|
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 (
|
|
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.
|
|
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` |
|
|
@@ -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
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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):
|