rss-retriever 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. rss_retriever-0.1.0/.github/workflows/publish.yml +63 -0
  2. rss_retriever-0.1.0/.github/workflows/release-please.yml +22 -0
  3. rss_retriever-0.1.0/.github/workflows/unit-tests.yml +51 -0
  4. rss_retriever-0.1.0/.gitignore +29 -0
  5. rss_retriever-0.1.0/CHANGELOG.md +13 -0
  6. rss_retriever-0.1.0/CONTRIBUTING.md +65 -0
  7. rss_retriever-0.1.0/Dockerfile +35 -0
  8. rss_retriever-0.1.0/Justfile +72 -0
  9. rss_retriever-0.1.0/LICENSE +21 -0
  10. rss_retriever-0.1.0/PKG-INFO +195 -0
  11. rss_retriever-0.1.0/README.md +156 -0
  12. rss_retriever-0.1.0/pyproject.toml +125 -0
  13. rss_retriever-0.1.0/rss_retriever/__init__.py +26 -0
  14. rss_retriever-0.1.0/rss_retriever/__main__.py +7 -0
  15. rss_retriever-0.1.0/rss_retriever/adapters/__init__.py +1 -0
  16. rss_retriever-0.1.0/rss_retriever/adapters/content.py +91 -0
  17. rss_retriever-0.1.0/rss_retriever/adapters/rss.py +163 -0
  18. rss_retriever-0.1.0/rss_retriever/adapters/storage.py +364 -0
  19. rss_retriever-0.1.0/rss_retriever/config.py +112 -0
  20. rss_retriever-0.1.0/rss_retriever/domain/__init__.py +1 -0
  21. rss_retriever-0.1.0/rss_retriever/domain/article.py +110 -0
  22. rss_retriever-0.1.0/rss_retriever/domain/ports.py +91 -0
  23. rss_retriever-0.1.0/rss_retriever/main.py +67 -0
  24. rss_retriever-0.1.0/rss_retriever/service/__init__.py +1 -0
  25. rss_retriever-0.1.0/rss_retriever/service/news.py +101 -0
  26. rss_retriever-0.1.0/rss_retriever/telemetry.py +123 -0
  27. rss_retriever-0.1.0/tests/conftest.py +80 -0
  28. rss_retriever-0.1.0/tests/test_config.py +71 -0
  29. rss_retriever-0.1.0/tests/test_content_extractor.py +132 -0
  30. rss_retriever-0.1.0/tests/test_news_service.py +126 -0
  31. rss_retriever-0.1.0/tests/test_rss_adapter.py +108 -0
  32. rss_retriever-0.1.0/tests/test_storage.py +177 -0
  33. rss_retriever-0.1.0/uv.lock +1241 -0
@@ -0,0 +1,63 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [created]
6
+ workflow_dispatch:
7
+ inputs:
8
+ target:
9
+ description: Index to publish to
10
+ required: true
11
+ default: testpypi
12
+ type: choice
13
+ options:
14
+ - testpypi
15
+ - pypi
16
+
17
+ jobs:
18
+ build:
19
+ runs-on: ubuntu-latest
20
+ steps:
21
+ - uses: actions/checkout@v7
22
+
23
+ - name: Install uv
24
+ uses: astral-sh/setup-uv@v7
25
+
26
+ - name: Build sdist and wheel
27
+ run: uv build
28
+
29
+ # Catches malformed metadata before it reaches an immutable index.
30
+ - name: Check distribution metadata
31
+ run: uvx twine check dist/*
32
+
33
+ - uses: actions/upload-artifact@v7
34
+ with:
35
+ name: dist
36
+ path: dist/
37
+
38
+ testpypi:
39
+ needs: build
40
+ if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi'
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/download-artifact@v8
44
+ with:
45
+ name: dist
46
+ path: dist/
47
+ - uses: pypa/gh-action-pypi-publish@release/v1
48
+ with:
49
+ repository-url: https://test.pypi.org/legacy/
50
+ password: ${{ secrets.TEST_PYPI_TOKEN || secrets.PYPI_TOKEN }}
51
+
52
+ pypi:
53
+ needs: build
54
+ if: github.event_name == 'release' || inputs.target == 'pypi'
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/download-artifact@v8
58
+ with:
59
+ name: dist
60
+ path: dist/
61
+ - uses: pypa/gh-action-pypi-publish@release/v1
62
+ with:
63
+ password: ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,22 @@
1
+ name: release-please
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ permissions:
9
+ contents: write
10
+ pull-requests: write
11
+
12
+ jobs:
13
+ release-please:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: googleapis/release-please-action@v5
17
+ with:
18
+ release-type: python
19
+ # A PAT, not the default GITHUB_TOKEN: releases created by
20
+ # github-actions[bot] cannot trigger further workflows, so the publish
21
+ # job would never fire on `on: release`.
22
+ token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
@@ -0,0 +1,51 @@
1
+ name: Run Unit Tests
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ unit-tests:
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ python-version: ["3.12", "3.13"]
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+
19
+ - name: Install uv
20
+ uses: astral-sh/setup-uv@v7
21
+ with:
22
+ enable-cache: true
23
+
24
+ - name: Set up Python ${{ matrix.python-version }}
25
+ run: uv python install ${{ matrix.python-version }}
26
+
27
+ - name: Install dependencies
28
+ run: uv sync --extra dev
29
+
30
+ # Network-marked tests hit live sites and would make CI flaky.
31
+ - name: Run tests
32
+ run: uv run pytest -m "not network"
33
+
34
+ lint:
35
+ runs-on: ubuntu-latest
36
+ steps:
37
+ - uses: actions/checkout@v7
38
+
39
+ - name: Install uv
40
+ uses: astral-sh/setup-uv@v7
41
+ with:
42
+ enable-cache: true
43
+
44
+ - name: Install dependencies
45
+ run: uv sync --extra dev
46
+
47
+ - name: Ruff
48
+ run: uv run ruff check .
49
+
50
+ - name: mypy
51
+ run: uv run mypy rss_retriever/
@@ -0,0 +1,29 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ *.egg
6
+
7
+ # Build artifacts
8
+ build/
9
+ dist/
10
+
11
+ # Virtual environments
12
+ .venv/
13
+ venv/
14
+
15
+ # Test and tooling caches
16
+ .pytest_cache/
17
+ .ruff_cache/
18
+ .mypy_cache/
19
+ .coverage
20
+ coverage.xml
21
+ htmlcov/
22
+
23
+ # Runtime output
24
+ article_storage/
25
+
26
+ # Editors and OS
27
+ .DS_Store
28
+ .idea/
29
+ .vscode/
@@ -0,0 +1,13 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-07-31)
4
+
5
+
6
+ ### Features
7
+
8
+ * rss-retriever ([c367e94](https://github.com/OscillateLabsLLC/rss-retriever/commit/c367e9443e50ffc81e7ff8a50fe7ee2253bf0a1d))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **ci:** pin setup-uv to v7 ([d88192d](https://github.com/OscillateLabsLLC/rss-retriever/commit/d88192d3ba1c97f6859b4ac846da6d6b321acc84))
@@ -0,0 +1,65 @@
1
+ # Contributing to rss-retriever
2
+
3
+ Thanks for your interest in contributing!
4
+
5
+ ## Development Setup
6
+
7
+ ### Prerequisites
8
+
9
+ - Python 3.12+
10
+ - [uv](https://docs.astral.sh/uv/)
11
+
12
+ ### Getting Started
13
+
14
+ ```bash
15
+ git clone https://github.com/OscillateLabsLLC/rss-retriever
16
+ cd rss-retriever
17
+
18
+ # Install dependencies (including dev extras)
19
+ uv sync --extra dev
20
+ ```
21
+
22
+ ## Common Commands
23
+
24
+ This repo uses [just](https://github.com/casey/just) as its task runner. `just --list` shows
25
+ everything available; the `uv` equivalents are given below in case you'd rather not install it.
26
+
27
+ ```bash
28
+ just test # unit tests, no network access required
29
+ just test-all # everything, including tests that hit live sites
30
+ just check # lint, format and type-check
31
+ just validate # check + test
32
+ ```
33
+
34
+ Without `just`:
35
+
36
+ ```bash
37
+ uv run pytest -m "not network"
38
+ uv run pytest
39
+ uv run ruff check .
40
+ uv run mypy rss_retriever/
41
+ ```
42
+
43
+ Tests that reach the network are marked with `@pytest.mark.network` so they can be deselected in
44
+ CI and offline development. Prefer fixtures over live requests when adding tests.
45
+
46
+ ## Pull Requests
47
+
48
+ 1. Create a feature branch: `git checkout -b feat/my-feature`
49
+ 2. Make your changes and add tests where applicable
50
+ 3. Run `uv run pytest -m "not network"` to ensure everything passes
51
+ 4. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (e.g., `feat:`, `fix:`, `docs:`)
52
+ 5. Open a pull request
53
+
54
+ **PR Guidelines:**
55
+
56
+ - Keep PRs focused on a single concern
57
+ - Include tests for new functionality
58
+ - Ensure all CI checks pass
59
+
60
+ Releases are automated with release-please: merged conventional commits drive the version bump and
61
+ publish to PyPI, so commit messages matter.
62
+
63
+ ## License
64
+
65
+ By contributing, you agree that your contributions will be licensed under the MIT License.
@@ -0,0 +1,35 @@
1
+ # Use lightweight Python base image
2
+ FROM python:3.12-slim
3
+
4
+ ENV PYTHONUNBUFFERED=1 \
5
+ PYTHONDONTWRITEBYTECODE=1 \
6
+ DEBIAN_FRONTEND=noninteractive \
7
+ UV_PROJECT_ENVIRONMENT=/usr/local \
8
+ RSS_RETRIEVER_STORAGE_DIR=/app/article_storage \
9
+ RSS_RETRIEVER_LOG_LEVEL=INFO \
10
+ RSS_RETRIEVER_ARTICLES_PER_SOURCE=5 \
11
+ RSS_RETRIEVER_RECENT_ARTICLES_LIMIT=10 \
12
+ RSS_RETRIEVER_PREVIEW_IMAGE_COUNT=3 \
13
+ RSS_RETRIEVER_REQUEST_TIMEOUT=10 \
14
+ RSS_RETRIEVER_CHUNK_SIZE=8192
15
+
16
+ RUN apt-get update && apt-get install -y --no-install-recommends \
17
+ ca-certificates \
18
+ && apt-get clean \
19
+ && rm -rf /var/lib/apt/lists/*
20
+
21
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
22
+
23
+ WORKDIR /app
24
+
25
+ COPY pyproject.toml README.md LICENSE /app/
26
+ COPY rss_retriever /app/rss_retriever
27
+
28
+ # Tracing is opt-in; build with --build-arg EXTRAS='[otel]' to include it.
29
+ ARG EXTRAS=""
30
+ RUN uv pip install --system ".${EXTRAS}"
31
+
32
+ RUN mkdir -p ${RSS_RETRIEVER_STORAGE_DIR} \
33
+ && chmod -R 755 /app
34
+
35
+ CMD ["python3", "-m", "rss_retriever"]
@@ -0,0 +1,72 @@
1
+ # List all recipes
2
+ default:
3
+ @just --list
4
+
5
+ # Install dependencies from the lockfile
6
+ install:
7
+ uv sync
8
+
9
+ install-all:
10
+ uv sync --all-extras
11
+
12
+ # Generate lockfile from pyproject.toml
13
+ lock:
14
+ uv lock
15
+
16
+ # Update dependencies and regenerate lockfile
17
+ upgrade:
18
+ uv lock --upgrade
19
+
20
+ # Run Ruff linter
21
+ lint:
22
+ uv run ruff check .
23
+
24
+ # Run Ruff formatter
25
+ fmt:
26
+ uv run ruff format .
27
+
28
+ # Run the type checker
29
+ typecheck:
30
+ uv run mypy rss_retriever/
31
+
32
+ # Run linting, formatting, and type checking
33
+ check: lint fmt typecheck
34
+
35
+ # Run the unit tests (no network required)
36
+ test:
37
+ uv run pytest -m "not network"
38
+
39
+ # Run every test, including those that hit live sites
40
+ test-all:
41
+ uv run pytest
42
+
43
+ # Run both tests and checks
44
+ validate: check test
45
+
46
+ # Run the RSS retriever
47
+ run:
48
+ uv run python -m rss_retriever
49
+
50
+ # Run with console tracing for debugging
51
+ run-debug:
52
+ OTEL_EXPORTER=console uv run python -m rss_retriever
53
+
54
+ # Run Phoenix locally for tracing
55
+ phoenix:
56
+ docker run -p 6006:6006 ghcr.io/arize-ai/phoenix:latest
57
+
58
+ # Build Docker image
59
+ docker-build:
60
+ docker build -t rss-retriever .
61
+
62
+ # Clean up Python cache and build files
63
+ clean:
64
+ find . -type d -name "__pycache__" -exec rm -rf {} +
65
+ find . -type f -name "*.pyc" -delete
66
+ find . -type f -name "*.pyo" -delete
67
+ find . -type f -name "*.pyd" -delete
68
+ find . -type f -name ".coverage" -delete
69
+ find . -type d -name "*.egg-info" -exec rm -rf {} +
70
+ find . -type d -name "*.egg" -exec rm -rf {} +
71
+ find . -type d -name ".pytest_cache" -exec rm -rf {} +
72
+ find . -type d -name ".ruff_cache" -exec rm -rf {} +
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 OscillateLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: rss-retriever
3
+ Version: 0.1.0
4
+ Summary: RSS feed retrieval and storage system with content extraction and image downloading capabilities
5
+ Project-URL: Repository, https://github.com/OscillateLabsLLC/rss-retriever
6
+ Project-URL: Issues, https://github.com/OscillateLabsLLC/rss-retriever/issues
7
+ Author-email: Mike Gray <mike@oscillatelabs.net>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: content-extraction,feed,image-download,news,rss
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: aiohttp>=3.9.3
19
+ Requires-Dist: feedparser>=6.0.11
20
+ Requires-Dist: lxml[html-clean]>=5.3.1
21
+ Requires-Dist: newspaper4k>=0.9.3
22
+ Requires-Dist: opentelemetry-api>=1.30.0
23
+ Requires-Dist: python-json-logger>=3.3.0
24
+ Requires-Dist: requests>=2.32.3
25
+ Provides-Extra: dev
26
+ Requires-Dist: mypy; extra == 'dev'
27
+ Requires-Dist: pytest-cov; extra == 'dev'
28
+ Requires-Dist: pytest>=7.0; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Requires-Dist: types-requests; extra == 'dev'
31
+ Provides-Extra: otel
32
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.30.0; extra == 'otel'
33
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.30.0; extra == 'otel'
34
+ Requires-Dist: opentelemetry-instrumentation-aiohttp-client>=0.51b0; extra == 'otel'
35
+ Requires-Dist: opentelemetry-instrumentation-requests>=0.51b0; extra == 'otel'
36
+ Requires-Dist: opentelemetry-instrumentation>=0.51b0; extra == 'otel'
37
+ Requires-Dist: opentelemetry-sdk>=1.30.0; extra == 'otel'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # rss-retriever
41
+
42
+ [![Status: Active](https://img.shields.io/badge/status-active-brightgreen)](https://github.com/OscillateLabsLLC/.github/blob/main/SUPPORT_STATUS.md)
43
+ [![Run Unit Tests](https://github.com/OscillateLabsLLC/rss-retriever/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/OscillateLabsLLC/rss-retriever/actions/workflows/unit-tests.yml)
44
+ [![PyPI](https://img.shields.io/pypi/v/rss-retriever)](https://pypi.org/project/rss-retriever/)
45
+
46
+ Fetch articles from RSS feeds, extract their full text and images, and store them on disk.
47
+
48
+ Built around ports and adapters, so the pieces are usable independently: take the feed adapter
49
+ without the storage layer, swap in your own storage backend, or drive the whole pipeline from the
50
+ bundled CLI.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install rss-retriever
56
+ ```
57
+
58
+ Optional tracing support (OpenTelemetry SDK, OTLP exporters, and Arize Phoenix):
59
+
60
+ ```bash
61
+ pip install "rss-retriever[otel]"
62
+ ```
63
+
64
+ The base install deliberately stays light. `opentelemetry-api` is included because it is a no-op
65
+ without an SDK, so instrumented code paths cost nothing when tracing is not configured.
66
+
67
+ Requires Python 3.12+.
68
+
69
+ ## Library usage
70
+
71
+ ```python
72
+ from rss_retriever import ContentExtractor, FileSystemStorage, NewsService, RSSFeedAdapter
73
+
74
+ service = NewsService(
75
+ RSSFeedAdapter({"Phys.org": "https://phys.org/rss-feed/"}),
76
+ FileSystemStorage("article_storage"),
77
+ ContentExtractor(),
78
+ )
79
+
80
+ articles = service.fetch_and_store_articles(limit_per_source=5)
81
+ for article in service.get_recent_articles(limit=10):
82
+ print(article.source_name, article.title)
83
+ ```
84
+
85
+ Articles already present in storage are skipped before the expensive extraction step, so re-running
86
+ against the same feeds is cheap.
87
+
88
+ ### Bulk imports
89
+
90
+ `save_article` rewrites both index files on every call so a crash cannot orphan an article. That is
91
+ the right default for a nightly run but makes a large backfill quadratic in corpus size. Wrap bulk
92
+ ingestion in `batch_writes()` to pay the index cost once:
93
+
94
+ ```python
95
+ with storage.batch_writes():
96
+ for article in many_articles:
97
+ storage.save_article(article)
98
+ ```
99
+
100
+ Article payloads are still written immediately; only the indexes are deferred, and they are flushed
101
+ even if the block raises. On a 5,000-article import this is roughly 12x faster.
102
+
103
+ ### Public API
104
+
105
+ | Object | Role |
106
+ | ------------------------- | ----------------------------------------------- |
107
+ | `NewsService` | Orchestrates fetch, extract, and store |
108
+ | `RSSFeedAdapter` | `NewsPort` implementation backed by feedparser |
109
+ | `ContentExtractor` | Full-text and image extraction via newspaper4k |
110
+ | `FileSystemStorage` | `StoragePort` implementation writing to disk |
111
+ | `NewsPort`, `StoragePort` | Abstract ports for custom implementations |
112
+ | `Article`, `ArticleImage` | Domain models, with `to_dict()` / `from_dict()` |
113
+
114
+ ### Custom backends
115
+
116
+ Implement `StoragePort` to store somewhere other than the filesystem:
117
+
118
+ ```python
119
+ from rss_retriever import StoragePort
120
+
121
+ class S3Storage(StoragePort):
122
+ def save_article(self, article): ...
123
+ def get_article(self, article_id): ...
124
+ def get_recent_articles(self, limit=50): ...
125
+ def get_unread_articles(self, limit=50): ...
126
+ def article_exists(self, url) -> bool: ...
127
+ ```
128
+
129
+ ## CLI
130
+
131
+ ```bash
132
+ export RSS_RETRIEVER_RSS_FEEDS='{"Phys.org": "https://phys.org/rss-feed/"}'
133
+ export RSS_RETRIEVER_STORAGE_DIR=./article_storage
134
+ rss-retriever
135
+ ```
136
+
137
+ ## Configuration
138
+
139
+ Read from the environment by `Config.from_env()`. Construct `Config` directly to bypass the
140
+ environment entirely.
141
+
142
+ | Variable | Default | Meaning |
143
+ | ------------------------------------- | ----------------- | ----------------------------------------------- |
144
+ | `RSS_RETRIEVER_RSS_FEEDS` | `{}` | JSON object mapping source name to feed URL |
145
+ | `RSS_RETRIEVER_STORAGE_DIR` | `article_storage` | Where articles are written |
146
+ | `RSS_RETRIEVER_ARTICLES_PER_SOURCE` | `5` | Max articles fetched per feed per run |
147
+ | `RSS_RETRIEVER_RECENT_ARTICLES_LIMIT` | `10` | Max articles returned by recent-article queries |
148
+ | `RSS_RETRIEVER_PREVIEW_IMAGE_COUNT` | `3` | Images summarised per article in CLI output |
149
+ | `RSS_RETRIEVER_LOG_LEVEL` | `INFO` | Root log level |
150
+ | `RSS_RETRIEVER_REQUEST_TIMEOUT` | `10` | Per-request timeout in seconds |
151
+ | `RSS_RETRIEVER_CHUNK_SIZE` | `8192` | Download chunk size in bytes |
152
+
153
+ There are no default feeds: a library should not fetch anything the caller did not ask for.
154
+ `rss_retriever.config.EXAMPLE_FEEDS` holds a starting set if you want one.
155
+
156
+ ## Storage layout
157
+
158
+ ```
159
+ article_storage/
160
+ ├── index.json # article_id -> metadata, for recency queries
161
+ ├── url_index.json # url -> article_id, for deduplication
162
+ └── <source>_<url_hash>/
163
+ ├── content.txt
164
+ ├── content.html
165
+ ├── metadata.json
166
+ └── images/
167
+ ```
168
+
169
+ Article IDs are `<source_slug>_<first 10 hex of md5(url)>` and are stable across runs.
170
+
171
+ ## Tracing
172
+
173
+ ```python
174
+ from rss_retriever.telemetry import setup_telemetry
175
+
176
+ setup_telemetry(service_name="my-service")
177
+ ```
178
+
179
+ Configured through the standard `OTEL_*` environment variables. Requires the `otel` extra. Note
180
+ that this installs a global tracer provider and sets OTLP defaults, so call it from an application
181
+ entry point rather than from library code.
182
+
183
+ ## Development
184
+
185
+ ```bash
186
+ uv sync --extra dev
187
+ uv run pytest -m "not network" # unit tests, no network
188
+ uv run pytest # includes live-network tests
189
+ uv run ruff check .
190
+ uv run mypy rss_retriever/
191
+ ```
192
+
193
+ ## License
194
+
195
+ MIT