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