mdfetch 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdfetch-0.2.2/.github/workflows/integration.yml +83 -0
- mdfetch-0.2.2/.specify/feature.json +3 -0
- mdfetch-0.2.2/.specify/memory/changelog.md +96 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/plan.md +27 -10
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/spec.md +76 -4
- {mdfetch-0.2.0 → mdfetch-0.2.2}/CLAUDE.md +14 -10
- {mdfetch-0.2.0 → mdfetch-0.2.2}/PKG-INFO +1 -1
- {mdfetch-0.2.0 → mdfetch-0.2.2}/pyproject.toml +1 -1
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/checklists/requirements.md +34 -0
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/contracts/extract-api.md +41 -0
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/plan.md +187 -0
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/research.md +73 -0
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/spec.md +108 -0
- mdfetch-0.2.2/specs/003-medium-freedium-fallback/tasks.md +174 -0
- mdfetch-0.2.2/specs/004-remove-backoff/checklists/requirements.md +34 -0
- mdfetch-0.2.2/specs/004-remove-backoff/plan.md +146 -0
- mdfetch-0.2.2/specs/004-remove-backoff/research.md +42 -0
- mdfetch-0.2.2/specs/004-remove-backoff/spec.md +92 -0
- mdfetch-0.2.2/specs/004-remove-backoff/tasks.md +147 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/__init__.py +4 -3
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/base.py +18 -3
- mdfetch-0.2.2/src/mdfetch/providers/medium.py +138 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/architecting-the-asynchronous-agent.md +97 -146
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/from-drift-to-parity.md +19 -19
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/integration-digest-december-2025.md +5 -5
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/test_devto_integration.py +1 -4
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/test_medium_integration.py +1 -4
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_fetch_errors.py +39 -1
- mdfetch-0.2.2/tests/unit/test_medium_extractor.py +358 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/uv.lock +1 -1
- mdfetch-0.2.0/.specify/feature.json +0 -3
- mdfetch-0.2.0/.specify/memory/changelog.md +0 -57
- mdfetch-0.2.0/src/mdfetch/providers/medium.py +0 -67
- mdfetch-0.2.0/tests/unit/test_medium_extractor.py +0 -144
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-analyze/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-archive-run/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-checklist/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-clarify/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-constitution/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-commit/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-feature/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-initialize/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-remote/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-git-validate/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-implement/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-plan/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-reconcile-run/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-specify/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-tasks/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.claude/skills/speckit-taskstoissues/SKILL.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.analyze.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.archive.run.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.checklist.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.clarify.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.constitution.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.implement.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.plan.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.reconcile.run.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.specify.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.tasks.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gemini/commands/speckit.taskstoissues.toml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.github/workflows/ci.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.github/workflows/publish.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.gitignore +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.python-version +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/.registry +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/LICENSE +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/README.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/commands/archive.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/archive/extension.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/README.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.commit.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.feature.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.initialize.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.remote.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/commands/speckit.git.validate.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/config-template.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/extension.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/git-config.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/auto-commit.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/git-common.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/bash/initialize-repo.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/auto-commit.ps1 +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/create-new-feature.ps1 +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/git-common.ps1 +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/git/scripts/powershell/initialize-repo.ps1 +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/LICENSE +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/README.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/commands/reconcile.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions/reconcile/extension.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/extensions.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/init-options.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integration.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/claude.manifest.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/gemini.manifest.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/integrations/speckit.manifest.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/memory/constitution.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/check-prerequisites.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/common.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/create-new-feature.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/setup-plan.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/scripts/bash/setup-tasks.sh +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/checklist-template.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/constitution-template.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/plan-template.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/spec-template.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/templates/tasks-template.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/workflows/speckit/workflow.yml +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/.specify/workflows/workflow-registry.json +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/GEMINI.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/LICENSE +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/Makefile +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/README.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/checklists/requirements.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/contracts/api.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/data-model.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/plan.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/quickstart.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/research.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/spec.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/001-mdfetch-medium-extractor/tasks.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/checklists/requirements.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/contracts/public-api.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/data-model.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/plan.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/quickstart.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/research.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/spec.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/specs/002-devto-provider/tasks.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/exceptions.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/providers/__init__.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/providers/devto.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/src/mdfetch/router.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/__init__.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/conftest.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/__init__.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-december-2025.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-july-2025.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/integration/snapshots/devto-integration-digest-march-2026.md +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/__init__.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_devto_extractor.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_router.py +0 -0
- {mdfetch-0.2.0 → mdfetch-0.2.2}/tests/unit/test_silent.py +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
name: Integration Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "30 23 * * 5"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
issues: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
integration:
|
|
14
|
+
runs-on: [self-hosted, Linux, ARM64]
|
|
15
|
+
timeout-minutes: 30
|
|
16
|
+
steps:
|
|
17
|
+
- uses: actions/checkout@v4
|
|
18
|
+
|
|
19
|
+
- name: Install uv
|
|
20
|
+
uses: astral-sh/setup-uv@v4
|
|
21
|
+
with:
|
|
22
|
+
enable-cache: true
|
|
23
|
+
cache-dependency-glob: "uv.lock"
|
|
24
|
+
|
|
25
|
+
- name: Set up Python
|
|
26
|
+
run: uv python pin 3.12
|
|
27
|
+
|
|
28
|
+
- name: Install dependencies
|
|
29
|
+
run: uv sync --frozen --all-extras
|
|
30
|
+
|
|
31
|
+
- name: Run integration tests
|
|
32
|
+
id: integration
|
|
33
|
+
run: make integration
|
|
34
|
+
|
|
35
|
+
- name: Create issue on failure
|
|
36
|
+
if: failure() && steps.integration.outcome == 'failure'
|
|
37
|
+
uses: actions/github-script@v7
|
|
38
|
+
with:
|
|
39
|
+
script: |
|
|
40
|
+
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
|
41
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
42
|
+
|
|
43
|
+
const search = await github.rest.search.issuesAndPullRequests({
|
|
44
|
+
q: `repo:${context.repo.owner}/${context.repo.repo} is:issue is:open label:integration-failure`,
|
|
45
|
+
per_page: 1,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
if (search.data.total_count > 0) {
|
|
49
|
+
await github.rest.issues.createComment({
|
|
50
|
+
owner: context.repo.owner,
|
|
51
|
+
repo: context.repo.repo,
|
|
52
|
+
issue_number: search.data.items[0].number,
|
|
53
|
+
body: `Integration tests failed again on ${date}.\n\n**Failed run:** ${runUrl}`,
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const issueParams = {
|
|
59
|
+
owner: context.repo.owner,
|
|
60
|
+
repo: context.repo.repo,
|
|
61
|
+
title: `Integration tests failed on ${date} — possible HTML structure change`,
|
|
62
|
+
body: [
|
|
63
|
+
"## Integration test failure",
|
|
64
|
+
"",
|
|
65
|
+
"The scheduled integration tests failed. This usually means a provider's",
|
|
66
|
+
"upstream HTML structure has changed and the extractor needs updating.",
|
|
67
|
+
"",
|
|
68
|
+
`**Failed run:** ${runUrl}`,
|
|
69
|
+
"",
|
|
70
|
+
"### Suggested steps",
|
|
71
|
+
"1. Open the failed run above and inspect the test output.",
|
|
72
|
+
"2. Identify which provider is broken (Medium, dev.to, …).",
|
|
73
|
+
"3. Update the relevant extractor in `src/mdfetch/providers/`.",
|
|
74
|
+
"4. Add or update snapshot fixtures in `tests/integration/` if needed.",
|
|
75
|
+
].join("\n"),
|
|
76
|
+
};
|
|
77
|
+
try {
|
|
78
|
+
issueParams.labels = ["bug", "integration-failure"];
|
|
79
|
+
await github.rest.issues.create(issueParams);
|
|
80
|
+
} catch {
|
|
81
|
+
delete issueParams.labels;
|
|
82
|
+
await github.rest.issues.create(issueParams);
|
|
83
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Merged Features Log
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
### mdfetch — Remove Exponential Backoff — 2026-05-15
|
|
6
|
+
|
|
7
|
+
**Branch**: `004-remove-backoff`
|
|
8
|
+
**Spec**: specs/004-remove-backoff
|
|
9
|
+
|
|
10
|
+
**What was added**:
|
|
11
|
+
- Fixed-delay retry behaviour: `fetch_html` now sleeps exactly `retry_delay` seconds between attempts (previously exponential `retry_delay × 2ⁿ`, capped at 60 s). The change makes retry timing predictable for callers and aligns with the Freedium fallback that already absorbs 403/429 errors.
|
|
12
|
+
- Removed `MDFETCH_RETRIES` and `MDFETCH_RETRY_DELAY` env-var support from integration test fixtures (`conftest.py` deleted) and CI workflow (`integration.yml`). Integration tests now hardcode `retries=3, retry_delay=2.0`.
|
|
13
|
+
- Deleted two unit tests (`test_exponential_backoff_sleep_sequence`, `test_exponential_backoff_capped_at_max_delay`) that verified the removed exponential schedule. Added sleep-value assertion to `test_status_code_not_in_no_retry_set_still_retries` to close the FR-029 gap.
|
|
14
|
+
|
|
15
|
+
**New Components**:
|
|
16
|
+
- No new files. Pure removal: `tests/integration/conftest.py` deleted; `_MAX_RETRY_DELAY` constant removed from `src/mdfetch/base.py`.
|
|
17
|
+
|
|
18
|
+
**Tasks Completed**: 16/16
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
### mdfetch — Medium Freedium Fallback — 2026-05-15
|
|
23
|
+
|
|
24
|
+
**Branch**: `003-medium-freedium-fallback`
|
|
25
|
+
**Spec**: specs/003-medium-freedium-fallback
|
|
26
|
+
|
|
27
|
+
**What was added**:
|
|
28
|
+
- Transparent fallback to `https://freedium-mirror.cfd/` when medium.com returns HTTP 403 (paywall) or HTTP 429 (rate limit) — caller sees no difference in the `extract()` interface
|
|
29
|
+
- `_no_retry_status_codes: frozenset[int]` class attribute on `BaseExtractor`; codes in this set skip retry/backoff and raise immediately (defaults to `frozenset()` — safe for all existing providers)
|
|
30
|
+
- `_no_retry_codes: frozenset[int] | None = None` keyword-only parameter on `fetch_html()` for per-call override without instance mutation (thread-safe)
|
|
31
|
+
- `_parse_freedium(soup)` method on `MediumExtractor`: locates `div.main-content`, remaps h4→h3/h5→h4/h6→h5, converts to Markdown; heading remap ensures output is structurally identical to the direct medium.com path
|
|
32
|
+
- `extract()` override on `MediumExtractor`: on 403/429, fetches `freedium_url` with `_no_retry_codes=frozenset()`, routes to `_parse_freedium()`; always sets `exc.url` to the original Medium URL on failure
|
|
33
|
+
- 18 new unit tests across `test_medium_extractor.py` (TestParseFreedium, TestFreediumFallback, TestRateLimitFallback, TestNoFallbackOnSuccess) and `test_fetch_errors.py`
|
|
34
|
+
- Integration test suite now resilient to medium.com 403 responses — paywalled URL included in snapshot tests
|
|
35
|
+
|
|
36
|
+
**New Components**:
|
|
37
|
+
- Changes to `src/mdfetch/base.py` — `_no_retry_status_codes` attribute + `_no_retry_codes` param on `fetch_html()`
|
|
38
|
+
- Changes to `src/mdfetch/providers/medium.py` — `_parse_freedium()` + `extract()` override + Freedium constants
|
|
39
|
+
|
|
40
|
+
**Tasks Completed**: 12/12
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
### mdfetch — dev.to Extractor — 2026-05-14
|
|
45
|
+
|
|
46
|
+
**Branch**: `002-devto-provider`
|
|
47
|
+
**Spec**: specs/002-devto-provider
|
|
48
|
+
|
|
49
|
+
**What was added**:
|
|
50
|
+
- `DevToExtractor` provider for `dev.to` articles, auto-discovered via `@register` decorator
|
|
51
|
+
- Article body isolation from `<div id="article-body">` with cover image extracted from `<header class="crayons-article__header">` and prepended to output
|
|
52
|
+
- `<iframe>` and liquid-tag embed (`ltag__*`) replacement with plain Markdown links (FR-019)
|
|
53
|
+
- `UnsupportedContentTypeError` raised for non-article dev.to pages (profiles, tag listings)
|
|
54
|
+
- 17 new unit tests in `tests/unit/test_devto_extractor.py`
|
|
55
|
+
- 3 dev.to integration tests in `tests/integration/test_devto_integration.py` with snapshot golden files
|
|
56
|
+
- Library version bumped from `0.1.0` to `0.2.0`
|
|
57
|
+
- `"dev.to"` added to `pyproject.toml` keywords (T013)
|
|
58
|
+
|
|
59
|
+
**New Components**:
|
|
60
|
+
- `src/mdfetch/providers/devto.py` — DevToExtractor
|
|
61
|
+
- `tests/unit/test_devto_extractor.py` — 17 unit tests
|
|
62
|
+
- `tests/integration/test_devto_integration.py` — 3 integration tests
|
|
63
|
+
- `tests/integration/snapshots/devto-integration-digest-december-2025.md`
|
|
64
|
+
- `tests/integration/snapshots/devto-integration-digest-july-2025.md`
|
|
65
|
+
- `tests/integration/snapshots/devto-integration-digest-march-2026.md`
|
|
66
|
+
|
|
67
|
+
**Tasks Completed**: 13/13
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### mdfetch — Medium Extractor (Initial Release) — 2026-05-14
|
|
72
|
+
|
|
73
|
+
**Branch**: `feature/first-draft`
|
|
74
|
+
**Spec**: specs/001-mdfetch-medium-extractor
|
|
75
|
+
|
|
76
|
+
**What was added**:
|
|
77
|
+
- `extract(url: str) -> str` public API for converting Medium articles to Markdown
|
|
78
|
+
- Provider pattern with `BaseExtractor` ABC and `MediumExtractor` implementation
|
|
79
|
+
- Auto-discovery routing via `pkgutil.iter_modules` + `@register` decorator (SC-006 compliant)
|
|
80
|
+
- Full typed exception hierarchy: `MdfetchError` → `InvalidURLError`, `UnsupportedPlatformError`, `UnsupportedContentTypeError`, `FetchError` → `HTTPStatusError`, `EmptyContentError`
|
|
81
|
+
- Streaming HTTP fetch with 10 MB response size cap
|
|
82
|
+
- Browser-like User-Agent (FR-014 compliant, no library branding)
|
|
83
|
+
- Snapshot-based integration tests with retry logic (3 retries, 2-second delay)
|
|
84
|
+
- PyPI-ready package: `pyproject.toml` + `src/` layout + `hatchling` build backend
|
|
85
|
+
- `Makefile` with full dev workflow (setup, test, integration, lint, typecheck, format, build, upgrade-deps, clean)
|
|
86
|
+
|
|
87
|
+
**New Components**:
|
|
88
|
+
- `src/mdfetch/__init__.py` — public surface
|
|
89
|
+
- `src/mdfetch/exceptions.py` — typed exception hierarchy
|
|
90
|
+
- `src/mdfetch/base.py` — BaseExtractor ABC + fetch_html + extract template method
|
|
91
|
+
- `src/mdfetch/router.py` — @register, auto-discovery, route()
|
|
92
|
+
- `src/mdfetch/providers/medium.py` — MediumExtractor
|
|
93
|
+
- `tests/unit/` — 30 unit tests (offline)
|
|
94
|
+
- `tests/integration/` — 3 integration tests with 3 snapshot golden files
|
|
95
|
+
|
|
96
|
+
**Tasks Completed**: 32/32
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# mdfetch — Main Implementation Plan
|
|
2
2
|
|
|
3
|
-
**Last Updated**: 2026-05-
|
|
4
|
-
**Sources**: [specs/001-mdfetch-medium-extractor/plan.md], [specs/002-devto-provider/plan.md]
|
|
3
|
+
**Last Updated**: 2026-05-15
|
|
4
|
+
**Sources**: [specs/001-mdfetch-medium-extractor/plan.md], [specs/002-devto-provider/plan.md], [specs/003-medium-freedium-fallback/plan.md], [specs/004-remove-backoff/plan.md]
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -38,15 +38,24 @@
|
|
|
38
38
|
|
|
39
39
|
```
|
|
40
40
|
BaseExtractor (ABC) — src/mdfetch/base.py
|
|
41
|
-
├──
|
|
41
|
+
├── _no_retry_status_codes: frozenset[int] = frozenset() — codes that skip retry; overridden by providers
|
|
42
|
+
├── fetch_html(url, *, retries, retry_delay, _no_retry_codes=None) → str
|
|
43
|
+
│ — streaming HTTP, 30s timeout, 10 MB cap;
|
|
44
|
+
│ fixed delay of retry_delay seconds between attempts (not exponential);
|
|
45
|
+
│ codes in _no_retry_codes (or class attribute) raise immediately
|
|
42
46
|
├── clean_html(soup) → Tag — abstract: platform-specific HTML isolation
|
|
43
47
|
├── convert_to_markdown(tag) → str— abstract: platform-specific Markdown conversion
|
|
44
48
|
└── extract(url) → str — concrete template method (orchestrates the above)
|
|
45
49
|
|
|
46
50
|
MediumExtractor(BaseExtractor) — src/mdfetch/providers/medium.py
|
|
47
51
|
├── DOMAINS = frozenset({"medium.com"})
|
|
52
|
+
├── _FREEDIUM_BASE = "https://freedium-mirror.cfd/"
|
|
53
|
+
├── _no_retry_status_codes = frozenset({403, 429}) — immediate fallback, no medium.com retry
|
|
48
54
|
├── clean_html() → removes nav, clap buttons, sidebars, share elements, post-footer, author bio
|
|
49
|
-
|
|
55
|
+
├── convert_to_markdown() → markdownify with ATX headings, fenced code blocks
|
|
56
|
+
├── _parse_freedium(soup) → remaps h4→h3/h5→h4/h6→h5; finds div.main-content; convert_to_markdown
|
|
57
|
+
└── extract() → override: on 403/429 calls fetch_html(freedium_url, _no_retry_codes=frozenset());
|
|
58
|
+
exc.url always set to original Medium URL on any Freedium failure
|
|
50
59
|
|
|
51
60
|
DevToExtractor(BaseExtractor) — src/mdfetch/providers/devto.py
|
|
52
61
|
├── DOMAINS = frozenset({"dev.to"})
|
|
@@ -131,18 +140,18 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
131
140
|
|
|
132
141
|
## Testing Strategy
|
|
133
142
|
|
|
134
|
-
**Unit tests** (
|
|
143
|
+
**Unit tests** (63 tests, offline):
|
|
135
144
|
- Router: domain routing, subdomain suffix matching, duplicate registration, invalid URLs, unsupported platforms
|
|
136
|
-
- MediumExtractor: clean_html, convert_to_markdown, empty content, non-article pages
|
|
145
|
+
- MediumExtractor: clean_html, convert_to_markdown, empty content, non-article pages, _parse_freedium (heading remap, missing main-content), fallback on 403/429 (URL construction, exc.url contract, no-sleep on 429), no-fallback on 200, UnsupportedContentTypeError.url on Freedium path [003-medium-freedium-fallback]
|
|
137
146
|
- DevToExtractor: clean_html (title/cover/heading/image preservation, iframe/ltag embed→link, anchor stripping, non-article error), convert_to_markdown (headings/code/lists/images, no raw HTML, empty content error) [002-devto-provider]
|
|
138
|
-
- Fetch errors: HTTP 404, 503, timeout, connection error, size limit exceeded
|
|
147
|
+
- Fetch errors: HTTP 404, 503, timeout, connection error, size limit exceeded; `_no_retry_status_codes` immediate-raise + `_no_retry_codes` override [003-medium-freedium-fallback]
|
|
139
148
|
- Silent: no stdout/stderr output, no logging during extraction
|
|
140
149
|
|
|
141
150
|
**Integration tests** (6 tests, network required):
|
|
142
|
-
- Parametrized over 3 real stn1slv.medium.com articles
|
|
151
|
+
- Parametrized over 3 real stn1slv.medium.com articles (including a known paywalled URL that exercises the Freedium fallback when medium.com returns 403) [003-medium-freedium-fallback]
|
|
143
152
|
- Parametrized over 3 real dev.to/stn1slv articles [002-devto-provider]
|
|
144
|
-
- Snapshot-based containment check: `expected_body in extracted_result`
|
|
145
|
-
- 3 retries with 2-second delay on `FetchError` (
|
|
153
|
+
- Snapshot-based containment check: `expected_body in extracted_result` — tests pass regardless of whether medium.com or Freedium served the content (heading normalisation ensures identical output)
|
|
154
|
+
- 3 retries with 2-second **fixed** delay on `FetchError` (hardcoded; not env-var configurable) [004-remove-backoff]
|
|
146
155
|
- Run with: `make integration` or `uv run pytest tests/integration/ --override-ini=addopts=`
|
|
147
156
|
- Excluded from default `pytest` run via `addopts = "-m 'not integration'"` in pyproject.toml
|
|
148
157
|
|
|
@@ -178,6 +187,10 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
178
187
|
| Routing | `pkgutil.iter_modules` auto-discovery + `@register` | SC-006: one new file = one new platform |
|
|
179
188
|
| Integration tests | Snapshot containment + retry | Durable against minor HTML changes; resilient to transient 403s |
|
|
180
189
|
| test_router.py domain example | Changed from `dev.to` to `substack.com` for "unsupported domain" test | Once DevToExtractor registers `dev.to`, those tests would no longer raise UnsupportedPlatformError | [002-devto-provider]
|
|
190
|
+
| Medium 403/429 fallback | Override `extract()` in `MediumExtractor`; `_no_retry_status_codes=frozenset({403,429})` on class | Immediate fallback with no medium.com retries; `BaseExtractor` extended with `_no_retry_codes` param for thread safety | [003-medium-freedium-fallback]
|
|
191
|
+
| Freedium HTML parsing | Dedicated `_parse_freedium()` method; `div.main-content`; h4→h3 remap | Freedium HTML is structurally incompatible with `clean_html()` (no `<article>`); heading remap ensures snapshot tests pass for both paths | [003-medium-freedium-fallback]
|
|
192
|
+
| Freedium exc.url contract | `inner_exc.url = url` unconditionally; error message is source-agnostic ("Fallback page…") | Preserves transparent-fallback contract (FR-028); `exc.url` is the authoritative field; message content is internal | [003-medium-freedium-fallback]
|
|
193
|
+
| Retry strategy | Fixed delay (`retry_delay` seconds per attempt, unchanged between attempts) | Exponential backoff removed (PR #8); Freedium fallback absorbs 403/429 at a higher level making exponential growth unnecessary; integration tests use hardcoded defaults (`retries=3, retry_delay=2.0`) with no env-var override | [004-remove-backoff]
|
|
181
194
|
|
|
182
195
|
---
|
|
183
196
|
|
|
@@ -188,3 +201,7 @@ Makefile # setup / test / integration / lint / typecheck / f
|
|
|
188
201
|
- [x] Coding Standards — PEP 8, strict type hints, `mypy --strict` passes
|
|
189
202
|
- [x] Integration Testing — real Medium URLs, snapshot-based containment assertions
|
|
190
203
|
- [x] Packaging and Distribution — `pyproject.toml` + `src/` layout + `hatchling`; all Makefile targets use `uv run`
|
|
204
|
+
|
|
205
|
+
---
|
|
206
|
+
|
|
207
|
+
*Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/plan.md]*
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# mdfetch — Main Specification
|
|
2
2
|
|
|
3
|
-
**Last Updated**: 2026-05-
|
|
4
|
-
**Sources**: [specs/001-mdfetch-medium-extractor/spec.md], [specs/002-devto-provider/spec.md]
|
|
3
|
+
**Last Updated**: 2026-05-15
|
|
4
|
+
**Sources**: [specs/001-mdfetch-medium-extractor/spec.md], [specs/002-devto-provider/spec.md], [specs/003-medium-freedium-fallback/spec.md], [specs/004-remove-backoff/spec.md]
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
@@ -72,6 +72,51 @@ A developer passing a dev.to URL that points to a profile page, a tag listing, o
|
|
|
72
72
|
|
|
73
73
|
---
|
|
74
74
|
|
|
75
|
+
### US-007 — Transparent Fallback on Blocked Medium Article (P1)
|
|
76
|
+
[Source: specs/003-medium-freedium-fallback]
|
|
77
|
+
|
|
78
|
+
A developer calls the library's extract function with a Medium URL. The article is behind a paywall or the user is geo-blocked, causing Medium to return a 403 error. Without any code changes, the library automatically retrieves the same article via the Freedium mirror and returns clean Markdown content.
|
|
79
|
+
|
|
80
|
+
**Acceptance Scenarios**:
|
|
81
|
+
1. Given a valid Medium article URL that returns 403 from medium.com, when `extract()` is called, then the library returns clean Markdown content retrieved via the Freedium mirror.
|
|
82
|
+
2. Given a valid Medium article URL that returns 403 from medium.com, when the Freedium mirror also fails, then the library raises an appropriate extraction error with `exc.url` set to the original Medium URL.
|
|
83
|
+
3. Given a valid Medium article URL that returns 403 from medium.com, when the library falls back to Freedium, then the caller receives the result without any knowledge of which source was used.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
### US-008 — Automatic Fallback on Rate Limiting (P2)
|
|
88
|
+
[Source: specs/003-medium-freedium-fallback]
|
|
89
|
+
|
|
90
|
+
A developer calls the library's extract function for a Medium URL. Medium responds with 429 Too Many Requests. The library automatically uses the Freedium mirror as a fallback and returns clean Markdown without requiring the caller to retry.
|
|
91
|
+
|
|
92
|
+
**Acceptance Scenarios**:
|
|
93
|
+
1. Given a valid Medium article URL that returns 429 from medium.com, when `extract()` is called, then the library returns clean Markdown content retrieved via the Freedium mirror.
|
|
94
|
+
2. Given a valid Medium article URL that returns 429 from medium.com, when the Freedium mirror also fails, then the library raises an appropriate extraction error.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
### US-009 — No Fallback When Primary Succeeds (P3)
|
|
99
|
+
[Source: specs/003-medium-freedium-fallback]
|
|
100
|
+
|
|
101
|
+
A developer calls the library's extract function for a publicly accessible Medium article. Medium responds successfully. The library returns the content directly without involving the Freedium mirror, preserving the existing happy-path behavior.
|
|
102
|
+
|
|
103
|
+
**Acceptance Scenarios**:
|
|
104
|
+
1. Given a valid Medium article URL that returns a successful response, when `extract()` is called, then the library returns clean Markdown content without making any request to the Freedium mirror.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### US-010 — Predictable Fixed-Delay Retry Behaviour (P1)
|
|
109
|
+
[Source: specs/004-remove-backoff]
|
|
110
|
+
|
|
111
|
+
A developer calling `extract()` encounters a transient network error. The library retries using a simple, predictable fixed delay — exactly `retry_delay` seconds between every attempt — rather than an exponentially growing delay. The retry timing is constant and easy to reason about regardless of which attempt number is being made.
|
|
112
|
+
|
|
113
|
+
**Acceptance Scenarios**:
|
|
114
|
+
1. Given `fetch_html` is called with `retries=3` and `retry_delay=2.0`, when the first two attempts raise a transient error, then the library sleeps exactly `2.0` seconds before each retry (not `2.0` then `4.0`).
|
|
115
|
+
2. Given `fetch_html` is called with `retries=1`, when the attempt fails, then no sleep occurs and the exception is raised immediately.
|
|
116
|
+
3. Given a status code in `_no_retry_status_codes`, when that error is raised, then no sleep or retry occurs.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
75
120
|
### US-006 — Integration Tests Pass Against Real dev.to Article URLs (P3)
|
|
76
121
|
[Source: specs/002-devto-provider]
|
|
77
122
|
|
|
@@ -102,11 +147,23 @@ A developer runs the integration test suite and all dev.to integration tests pas
|
|
|
102
147
|
### Network
|
|
103
148
|
- **FR-006**: The library MUST raise a descriptive error when a network request fails (connection error, timeout, non-2xx HTTP status).
|
|
104
149
|
- **FR-014**: HTTP requests MUST use a standard browser-like User-Agent string so that web servers return readable HTML. The User-Agent MUST NOT identify the library by name or version. The library does not check or respect `robots.txt` in v1.
|
|
150
|
+
- **FR-029**: The `fetch_html` method MUST use a fixed delay of exactly `retry_delay` seconds between retry attempts — no exponential multiplication. The retry delay is constant and does not grow with each attempt number. [Source: specs/004-remove-backoff]
|
|
105
151
|
|
|
106
152
|
### Packaging & Testing
|
|
107
153
|
- **FR-009**: The library MUST be packaged and distributed via PyPI using modern Python packaging best practices, enabling installation through the standard package manager without additional steps.
|
|
108
154
|
- **FR-010**: The test suite MUST include integration tests that supply real article URLs (Medium and dev.to) to the extraction function and assert that the output matches expected Markdown structure and content.
|
|
109
155
|
|
|
156
|
+
### Freedium Fallback (Medium)
|
|
157
|
+
- **FR-020**: When a Medium article extraction results in HTTP 403, the system MUST immediately attempt extraction via the Freedium mirror (`https://freedium-mirror.cfd/{url}`) — no retries against medium.com are made first. [Source: specs/003-medium-freedium-fallback]
|
|
158
|
+
- **FR-021**: When a Medium article extraction results in HTTP 429, the system MUST immediately attempt extraction via the Freedium mirror — no retries against medium.com are made first. [Source: specs/003-medium-freedium-fallback]
|
|
159
|
+
- **FR-022**: When the Freedium mirror is used as a fallback, the system MUST return content in the same clean Markdown format as direct extraction; Freedium's `<h4>` headings are remapped to `<h3>` so both paths produce identical heading-level output. [Source: specs/003-medium-freedium-fallback]
|
|
160
|
+
- **FR-023**: When the primary Medium request succeeds (HTTP 200), the system MUST NOT make any request to the Freedium mirror. [Source: specs/003-medium-freedium-fallback]
|
|
161
|
+
- **FR-024**: When both the primary Medium request and the Freedium fallback fail, the system MUST raise an error consistent with the existing exception hierarchy; `exc.url` MUST be set to the original Medium URL (never the Freedium URL). [Source: specs/003-medium-freedium-fallback]
|
|
162
|
+
- **FR-025**: The Freedium fallback mechanism MUST require no changes to the caller's code — the public `extract()` interface remains unchanged. [Source: specs/003-medium-freedium-fallback]
|
|
163
|
+
- **FR-026**: The Freedium fallback MUST only apply to Medium provider URLs; other providers are unaffected. [Source: specs/003-medium-freedium-fallback]
|
|
164
|
+
- **FR-027**: The Freedium fallback MUST be unconditionally active for all Medium URL extractions — no caller configuration, opt-in flag, or extractor parameter is required or supported. [Source: specs/003-medium-freedium-fallback]
|
|
165
|
+
- **FR-028**: The Freedium fallback MUST be fully transparent to the caller — no warning, signal, metadata, or result field shall indicate which source (medium.com or Freedium) provided the content. [Source: specs/003-medium-freedium-fallback]
|
|
166
|
+
|
|
110
167
|
### dev.to Platform
|
|
111
168
|
- **FR-015**: The library MUST add `dev.to` to the provider router so that any URL with the `dev.to` domain is dispatched to the dev.to provider without any change to the caller's code. [Source: specs/002-devto-provider]
|
|
112
169
|
- **FR-016**: The library MUST include a dev.to provider that fetches the article page, isolates the main article body from `<div id="article-body">`, removes all non-content elements (navigation, social reaction widgets, comments, author sidebar, tag links), and returns the body as Markdown. [Source: specs/002-devto-provider]
|
|
@@ -185,13 +242,16 @@ caller provides URL string
|
|
|
185
242
|
|
|
186
243
|
## Edge Cases and Error Handling
|
|
187
244
|
|
|
188
|
-
- **Paywalled content**:
|
|
245
|
+
- **Paywalled content (HTTP 403)**: When Medium returns HTTP 403 (paywall or geo-block), the library automatically retries via the Freedium mirror (`https://freedium-mirror.cfd/`). If Freedium also fails, the error raised carries `exc.url` set to the original Medium URL. [Source: specs/003-medium-freedium-fallback]
|
|
246
|
+
- **Freedium mirror unreachable**: If the Freedium mirror returns a network error, timeout, or non-2xx status, the fallback fails and the exception is propagated with `exc.url` set to the original Medium URL — Freedium's URL never appears in `exc.url`.
|
|
247
|
+
- **Freedium HTML structure**: Freedium uses `<div class="main-content">` (no `<article>`); if this element is absent, `UnsupportedContentTypeError` is raised with a source-agnostic message ("Fallback page missing main-content element").
|
|
248
|
+
- **Freedium heading levels**: Freedium renders section headings as `<h4>` vs. medium.com's `<h2>`/`<h3>`; `_parse_freedium()` remaps h4→h3, h5→h4, h6→h5 before conversion so snapshot tests pass regardless of which source served the content.
|
|
189
249
|
- **HTML structure changes**: If Medium changes its HTML structure and `<article>` is absent, `UnsupportedContentTypeError` is raised.
|
|
190
250
|
- **Empty article body**: If `<article>` is found but contains no extractable text, `EmptyContentError` is raised.
|
|
191
251
|
- **Network timeouts**: Covered by `FetchError` (30-second fixed timeout).
|
|
192
252
|
- **Oversized responses**: Responses exceeding 10 MB are rejected with `FetchError` to prevent OOM.
|
|
193
253
|
- **Profile/tag pages**: When a `medium.com` URL points to a non-article page, `UnsupportedContentTypeError` is raised (distinct from `UnsupportedPlatformError`).
|
|
194
|
-
- **HTTP 403 / transient failures**: Integration tests use
|
|
254
|
+
- **HTTP 403 / transient failures**: Integration tests use 3 retries with a 2-second fixed delay (hardcoded; not env-var configurable) to handle transient rate limits. [Source: specs/004-remove-backoff]
|
|
195
255
|
- **dev.to profile pages**: When a `dev.to` URL points to an author profile (no `div#article-body`), `UnsupportedContentTypeError` is raised.
|
|
196
256
|
- **dev.to tag listing pages**: When a `dev.to` URL points to a tag page (e.g., `dev.to/t/kafka`), `UnsupportedContentTypeError` is raised.
|
|
197
257
|
- **dev.to liquid-tag embeds**: Embedded third-party widgets (GitHub Gists, CodePen, YouTube) serialised as `<div class="ltag__*" data-url="...">` are replaced with plain Markdown links; they are never silently dropped.
|
|
@@ -226,3 +286,15 @@ caller provides URL string
|
|
|
226
286
|
- The library supports Python 3.12 and later.
|
|
227
287
|
- The library operates on publicly accessible HTML; it does not execute JavaScript or render dynamic content.
|
|
228
288
|
- Network timeouts use a fixed default of 30 seconds (not user-configurable in v1).
|
|
289
|
+
- **SC-018**: `make test` passes with zero failures (all unit tests green) when run without any `MDFETCH_*` environment variables. [Source: specs/004-remove-backoff]
|
|
290
|
+
- **SC-019**: `make integration` passes with zero failures when run without any `MDFETCH_RETRIES` or `MDFETCH_RETRY_DELAY` environment variables — integration tests use hardcoded defaults (3 retries, 2.0 s delay). [Source: specs/004-remove-backoff]
|
|
291
|
+
- **SC-020**: No reference to `MDFETCH_RETRIES`, `MDFETCH_RETRY_DELAY`, or `_MAX_RETRY_DELAY` appears in `src/`, `tests/`, or `.github/` (specification and documentation files excluded). [Source: specs/004-remove-backoff]
|
|
292
|
+
- **SC-013**: Articles that previously failed with a 403 paywall error are successfully extracted in at least 90% of cases where the Freedium mirror has the content available. [Source: specs/003-medium-freedium-fallback]
|
|
293
|
+
- **SC-014**: Articles that previously failed with a 429 rate-limit error are successfully extracted via fallback without requiring the caller to retry. [Source: specs/003-medium-freedium-fallback]
|
|
294
|
+
- **SC-015**: Zero changes are required in existing caller code to benefit from the Freedium fallback — existing integrations continue to work as-is. [Source: specs/003-medium-freedium-fallback]
|
|
295
|
+
- **SC-016**: When the primary Medium request succeeds, there is no additional latency attributable to the fallback mechanism. [Source: specs/003-medium-freedium-fallback]
|
|
296
|
+
- **SC-017**: All existing unit and integration tests for the Medium extractor continue to pass without modification after the fallback is introduced. [Source: specs/003-medium-freedium-fallback]
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
*Last Updated: 2026-05-15 | Sources appended: [specs/004-remove-backoff/spec.md]*
|
|
@@ -11,11 +11,18 @@ src/mdfetch/
|
|
|
11
11
|
├── base.py # BaseExtractor ABC
|
|
12
12
|
├── router.py # Domain-to-provider routing
|
|
13
13
|
└── providers/
|
|
14
|
-
|
|
14
|
+
├── __init__.py
|
|
15
|
+
├── medium.py # MediumExtractor (medium.com + *.medium.com)
|
|
16
|
+
└── devto.py # DevToExtractor (dev.to)
|
|
15
17
|
|
|
16
18
|
tests/
|
|
17
19
|
├── unit/ # pytest unit tests (no network)
|
|
18
|
-
└── integration/ #
|
|
20
|
+
└── integration/ # real network tests (Medium + dev.to URLs + snapshots)
|
|
21
|
+
|
|
22
|
+
.github/workflows/
|
|
23
|
+
├── ci.yml # lint + unit tests on push/PR (Python 3.12–3.14)
|
|
24
|
+
├── integration.yml # scheduled integration tests every Friday 23:30 UTC
|
|
25
|
+
└── publish.yml # PyPI publish on release
|
|
19
26
|
|
|
20
27
|
specs/ # Speckit feature specifications
|
|
21
28
|
pyproject.toml # hatchling build, uv package manager
|
|
@@ -38,16 +45,13 @@ make test # unit tests only
|
|
|
38
45
|
make lint # ruff check
|
|
39
46
|
make format # ruff format
|
|
40
47
|
make build # uv build (wheel + sdist)
|
|
41
|
-
make upgrade-deps # uv sync --upgrade
|
|
42
|
-
|
|
43
|
-
uv run mypy src/
|
|
48
|
+
make upgrade-deps # uv sync --all-extras --upgrade
|
|
49
|
+
make integration # integration tests (network required)
|
|
50
|
+
uv run mypy src/ # type check
|
|
44
51
|
```
|
|
45
52
|
|
|
53
|
+
<!-- SPECKIT START -->
|
|
46
54
|
## Recent Changes
|
|
47
55
|
|
|
48
|
-
-
|
|
49
|
-
- 001-mdfetch-medium-extractor: Initial library release — `extract()` API, Medium provider, typed exceptions, auto-discovery routing, PyPI packaging, snapshot integration tests
|
|
50
|
-
|
|
51
|
-
<!-- SPECKIT START -->
|
|
52
|
-
**Active feature plan**: none
|
|
56
|
+
- **004-remove-backoff**: Removed exponential backoff from `fetch_html`; restored fixed-delay retries (`retry_delay` seconds per attempt, constant between attempts). Deleted `MDFETCH_RETRIES`/`MDFETCH_RETRY_DELAY` env-var support from CI and integration fixtures; hardcoded `retries=3, retry_delay=2.0` in all integration tests. Deleted `tests/integration/conftest.py`.
|
|
53
57
|
<!-- SPECKIT END -->
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mdfetch
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Extract article content from web platforms and return it as clean Markdown.
|
|
5
5
|
Project-URL: Homepage, https://github.com/stn1slv/md-fetch
|
|
6
6
|
Project-URL: Source, https://github.com/stn1slv/md-fetch
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Specification Quality Checklist: Medium Freedium Fallback
|
|
2
|
+
|
|
3
|
+
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
|
4
|
+
**Created**: 2026-05-14
|
|
5
|
+
**Feature**: [spec.md](../spec.md)
|
|
6
|
+
|
|
7
|
+
## Content Quality
|
|
8
|
+
|
|
9
|
+
- [x] No implementation details (languages, frameworks, APIs)
|
|
10
|
+
- [x] Focused on user value and business needs
|
|
11
|
+
- [x] Written for non-technical stakeholders
|
|
12
|
+
- [x] All mandatory sections completed
|
|
13
|
+
|
|
14
|
+
## Requirement Completeness
|
|
15
|
+
|
|
16
|
+
- [x] No [NEEDS CLARIFICATION] markers remain
|
|
17
|
+
- [x] Requirements are testable and unambiguous
|
|
18
|
+
- [x] Success criteria are measurable
|
|
19
|
+
- [x] Success criteria are technology-agnostic (no implementation details)
|
|
20
|
+
- [x] All acceptance scenarios are defined
|
|
21
|
+
- [x] Edge cases are identified
|
|
22
|
+
- [x] Scope is clearly bounded
|
|
23
|
+
- [x] Dependencies and assumptions identified
|
|
24
|
+
|
|
25
|
+
## Feature Readiness
|
|
26
|
+
|
|
27
|
+
- [x] All functional requirements have clear acceptance criteria
|
|
28
|
+
- [x] User scenarios cover primary flows
|
|
29
|
+
- [x] Feature meets measurable outcomes defined in Success Criteria
|
|
30
|
+
- [x] No implementation details leak into specification
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- All items pass. Specification is ready for `/speckit-plan`.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# Contract: `extract()` Public API
|
|
2
|
+
|
|
3
|
+
**Feature**: Medium Freedium Fallback | **Status**: Unchanged
|
|
4
|
+
|
|
5
|
+
## Signature
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
def extract(url: str, *, retries: int = 3, retry_delay: float = 2.0) -> str
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Behaviour (unchanged)
|
|
12
|
+
|
|
13
|
+
| Input | Output |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Valid Medium URL, article accessible | Clean Markdown string |
|
|
16
|
+
| Valid Medium URL, 403 or 429 from medium.com, Freedium succeeds | Clean Markdown string (source is transparent) |
|
|
17
|
+
| Valid Medium URL, 403 or 429 from medium.com, Freedium also fails | Raises `FetchError` or `HTTPStatusError` |
|
|
18
|
+
| Valid Medium URL, other HTTP error (404, 500, …) | Raises `HTTPStatusError` after retries |
|
|
19
|
+
| Valid Medium URL, network timeout/error | Raises `FetchError` after retries |
|
|
20
|
+
| Non-Medium URL | Raises `UnsupportedPlatformError` |
|
|
21
|
+
| Invalid URL | Raises `InvalidURLError` |
|
|
22
|
+
|
|
23
|
+
## Exception Hierarchy (unchanged)
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
MdfetchError
|
|
27
|
+
├── InvalidURLError
|
|
28
|
+
├── UnsupportedPlatformError
|
|
29
|
+
├── UnsupportedContentTypeError
|
|
30
|
+
├── EmptyContentError
|
|
31
|
+
└── FetchError
|
|
32
|
+
└── HTTPStatusError (status_code: int, url: str | None)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Guarantees introduced by this feature
|
|
36
|
+
|
|
37
|
+
- When fallback is invoked, `exc.url` on any raised exception contains the **original Medium URL**, not the Freedium mirror URL.
|
|
38
|
+
- No new exception types are introduced.
|
|
39
|
+
- No new parameters are added to `extract()`.
|
|
40
|
+
- The Freedium mirror URL is never exposed in return values or the `exc.url` field.
|
|
41
|
+
- `exc.url` is the authoritative public URL field and is always set to the original Medium URL when fallback fails. `exc.message` is an internal implementation detail and may contain transport-level information (e.g. the URL that was actually fetched); callers MUST NOT rely on its contents.
|