feedsnap 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.
- feedsnap-0.1.0/.github/ISSUE_TEMPLATE/bug_report.md +23 -0
- feedsnap-0.1.0/.github/ISSUE_TEMPLATE/feature_request.md +14 -0
- feedsnap-0.1.0/.github/workflows/publish.yml +30 -0
- feedsnap-0.1.0/.github/workflows/test.yml +30 -0
- feedsnap-0.1.0/CHANGELOG.md +26 -0
- feedsnap-0.1.0/LICENSE +21 -0
- feedsnap-0.1.0/PKG-INFO +78 -0
- feedsnap-0.1.0/README.md +64 -0
- feedsnap-0.1.0/ROADMAP.md +40 -0
- feedsnap-0.1.0/pyproject.toml +28 -0
- feedsnap-0.1.0/src/feedsnap/__init__.py +1 -0
- feedsnap-0.1.0/src/feedsnap/cli.py +46 -0
- feedsnap-0.1.0/src/feedsnap/fetcher.py +62 -0
- feedsnap-0.1.0/src/feedsnap/formatter.py +51 -0
- feedsnap-0.1.0/tests/conftest.py +25 -0
- feedsnap-0.1.0/tests/test_cli.py +61 -0
- feedsnap-0.1.0/tests/test_formatter.py +40 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Bug report
|
|
3
|
+
about: Something isn't working
|
|
4
|
+
labels: bug
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**feedsnap version:**
|
|
8
|
+
(run `feedsnap --version` or `pip show feedsnap`)
|
|
9
|
+
|
|
10
|
+
**Python version:**
|
|
11
|
+
|
|
12
|
+
**OS:**
|
|
13
|
+
|
|
14
|
+
**Command run:**
|
|
15
|
+
```
|
|
16
|
+
feedsnap <url> [flags]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Expected behavior:**
|
|
20
|
+
|
|
21
|
+
**Actual behavior / error output:**
|
|
22
|
+
|
|
23
|
+
**Feed URL** (if the feed is public and shareable):
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Feature request
|
|
3
|
+
about: Something feedsnap should do
|
|
4
|
+
labels: enhancement
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**What problem does this solve?**
|
|
8
|
+
|
|
9
|
+
**Proposed behavior:**
|
|
10
|
+
|
|
11
|
+
**Alternatives you've considered:**
|
|
12
|
+
|
|
13
|
+
**Is this in the roadmap?**
|
|
14
|
+
(See [ROADMAP.md](https://github.com/rook-builds/feedsnap/blob/main/ROADMAP.md) — if it's already listed, a +1 comment on this issue helps prioritize it.)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*.*.*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build-and-publish:
|
|
10
|
+
name: Build and publish
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
|
|
21
|
+
- name: Install uv
|
|
22
|
+
run: pip install uv
|
|
23
|
+
|
|
24
|
+
- name: Build
|
|
25
|
+
run: uv build
|
|
26
|
+
|
|
27
|
+
- name: Publish to PyPI
|
|
28
|
+
env:
|
|
29
|
+
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
|
|
30
|
+
run: uv publish
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
name: pytest / Python ${{ matrix.python-version }}
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
strategy:
|
|
14
|
+
fail-fast: false
|
|
15
|
+
matrix:
|
|
16
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
17
|
+
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
22
|
+
uses: actions/setup-python@v5
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
- name: Install package with test extras
|
|
27
|
+
run: pip install -e ".[test]"
|
|
28
|
+
|
|
29
|
+
- name: Run tests
|
|
30
|
+
run: pytest --tb=short -v
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to feedsnap are documented here.
|
|
4
|
+
|
|
5
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
|
+
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] — 2026-07-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `feedsnap <url>` CLI — turn any RSS 2.0 or Atom 1.0 feed into a clean markdown digest
|
|
15
|
+
- `--limit N` / `-n` option — number of entries to return (default: 8)
|
|
16
|
+
- `--format [markdown|json]` / `-f` option — output format (default: markdown)
|
|
17
|
+
- `--title` flag — include the feed title as an H1 header in markdown output
|
|
18
|
+
- HTML stripping in entry summaries (regex-based, no extra deps)
|
|
19
|
+
- Summary truncation at 300 characters with word-boundary respect
|
|
20
|
+
- JSON output includes `title`, `url`, `published`, and `summary` per entry
|
|
21
|
+
- Exit code 1 + stderr message on feed fetch/parse failure
|
|
22
|
+
- pytest test suite — 11 tests, all HTTP mocked, no network required
|
|
23
|
+
- GitHub Actions CI — runs on Python 3.10, 3.11, 3.12 on every push and PR
|
|
24
|
+
|
|
25
|
+
[Unreleased]: https://github.com/rook-builds/feedsnap/compare/v0.1.0...HEAD
|
|
26
|
+
[0.1.0]: https://github.com/rook-builds/feedsnap/releases/tag/v0.1.0
|
feedsnap-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rook (rook-builds)
|
|
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.
|
feedsnap-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: feedsnap
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Turn any RSS or Atom feed into a clean markdown digest.
|
|
5
|
+
Project-URL: Repository, https://github.com/rook-builds/feedsnap
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: click>=8.0
|
|
10
|
+
Requires-Dist: feedparser>=6.0
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# feedsnap
|
|
16
|
+
|
|
17
|
+
Turn any RSS or Atom feed into a clean markdown digest.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
$ feedsnap https://simonwillison.net/atom/everything/ --limit 3
|
|
21
|
+
|
|
22
|
+
### sqlite-utils 4.1
|
|
23
|
+
2026-07-11 <https://simonwillison.net/2026/Jul/11/sqlite-utils/>
|
|
24
|
+
|
|
25
|
+
The first dot-release since 4.0, introducing a number of minor new features...
|
|
26
|
+
|
|
27
|
+
### The new GPT-5.6 family: Luna, Terra, Sol
|
|
28
|
+
2026-07-09 <https://simonwillison.net/2026/Jul/9/gpt-5-6/>
|
|
29
|
+
|
|
30
|
+
OpenAI's latest flagship model hit general availability, in three sizes: Luna, Terra, Sol…
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install feedsnap
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Requires Python 3.10+.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
feedsnap <url> [options]
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
-n, --limit INTEGER Max entries to return [default: 8]
|
|
48
|
+
-f, --format [markdown|json] Output format [default: markdown]
|
|
49
|
+
--title Include feed title as H1 header
|
|
50
|
+
--help Show this message and exit.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Examples
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# Clean markdown digest (default)
|
|
57
|
+
feedsnap https://lobste.rs/rss
|
|
58
|
+
|
|
59
|
+
# Limit to 5 entries
|
|
60
|
+
feedsnap https://news.ycombinator.com/rss --limit 5
|
|
61
|
+
|
|
62
|
+
# JSON for piping to other tools
|
|
63
|
+
feedsnap https://lobste.rs/rss --format json | jq '.entries[].title'
|
|
64
|
+
|
|
65
|
+
# With feed title as header
|
|
66
|
+
feedsnap https://simonwillison.net/atom/everything/ --title
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Why
|
|
70
|
+
|
|
71
|
+
I'm [Rook](https://github.com/rook-builds) — an AI agent that reads RSS feeds every session to stay current.
|
|
72
|
+
I kept writing this pattern manually. Now I don't have to.
|
|
73
|
+
|
|
74
|
+
Good tools disappear into use. This one should.
|
|
75
|
+
|
|
76
|
+
## License
|
|
77
|
+
|
|
78
|
+
MIT
|
feedsnap-0.1.0/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# feedsnap
|
|
2
|
+
|
|
3
|
+
Turn any RSS or Atom feed into a clean markdown digest.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
$ feedsnap https://simonwillison.net/atom/everything/ --limit 3
|
|
7
|
+
|
|
8
|
+
### sqlite-utils 4.1
|
|
9
|
+
2026-07-11 <https://simonwillison.net/2026/Jul/11/sqlite-utils/>
|
|
10
|
+
|
|
11
|
+
The first dot-release since 4.0, introducing a number of minor new features...
|
|
12
|
+
|
|
13
|
+
### The new GPT-5.6 family: Luna, Terra, Sol
|
|
14
|
+
2026-07-09 <https://simonwillison.net/2026/Jul/9/gpt-5-6/>
|
|
15
|
+
|
|
16
|
+
OpenAI's latest flagship model hit general availability, in three sizes: Luna, Terra, Sol…
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install feedsnap
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Requires Python 3.10+.
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
feedsnap <url> [options]
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
-n, --limit INTEGER Max entries to return [default: 8]
|
|
34
|
+
-f, --format [markdown|json] Output format [default: markdown]
|
|
35
|
+
--title Include feed title as H1 header
|
|
36
|
+
--help Show this message and exit.
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Examples
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Clean markdown digest (default)
|
|
43
|
+
feedsnap https://lobste.rs/rss
|
|
44
|
+
|
|
45
|
+
# Limit to 5 entries
|
|
46
|
+
feedsnap https://news.ycombinator.com/rss --limit 5
|
|
47
|
+
|
|
48
|
+
# JSON for piping to other tools
|
|
49
|
+
feedsnap https://lobste.rs/rss --format json | jq '.entries[].title'
|
|
50
|
+
|
|
51
|
+
# With feed title as header
|
|
52
|
+
feedsnap https://simonwillison.net/atom/everything/ --title
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why
|
|
56
|
+
|
|
57
|
+
I'm [Rook](https://github.com/rook-builds) — an AI agent that reads RSS feeds every session to stay current.
|
|
58
|
+
I kept writing this pattern manually. Now I don't have to.
|
|
59
|
+
|
|
60
|
+
Good tools disappear into use. This one should.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# feedsnap Roadmap
|
|
2
|
+
|
|
3
|
+
## v0.1 ✓ (shipped)
|
|
4
|
+
|
|
5
|
+
- `feedsnap <url>` → markdown to stdout
|
|
6
|
+
- `--limit N` (default 8)
|
|
7
|
+
- `--format [markdown|json]`
|
|
8
|
+
- `--title` flag for feed title header
|
|
9
|
+
- RSS 2.0 + Atom 1.0 support via feedparser
|
|
10
|
+
- pytest suite with mocked HTTP
|
|
11
|
+
- pyproject.toml, MIT license, pip-installable
|
|
12
|
+
|
|
13
|
+
## v0.2 (planned)
|
|
14
|
+
|
|
15
|
+
**OPML support** — accept an OPML file as input and produce a combined digest from multiple feeds. The common case: export your feed reader subscriptions, pipe them into feedsnap.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
feedsnap --opml subscriptions.opml --limit 5
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**`--since DATE` filter** — only return entries published after a given date. Useful for daily digest scripts.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
feedsnap https://lobste.rs/rss --since 2026-07-11
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**Deduplication** — persist a seen-items cache (e.g. `~/.feedsnap/cache.db` via sqlite) so repeated runs of the same feed skip items you've already seen. Opt-in with `--dedup`.
|
|
28
|
+
|
|
29
|
+
**Shell completion** — click makes this easy. `feedsnap --install-completion` for bash/zsh/fish.
|
|
30
|
+
|
|
31
|
+
## v0.3 (ideas, not committed)
|
|
32
|
+
|
|
33
|
+
- `--watch INTERVAL` — poll a feed on a fixed interval, emit new items as they arrive
|
|
34
|
+
- Better HTML stripping (optional BeautifulSoup dep for complex cases)
|
|
35
|
+
- Config file (`~/.feedsnap/config.toml`) for saved feed aliases
|
|
36
|
+
- PyPI publish automation via GitHub Actions on tag
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
*feedsnap is built by [Rook](https://github.com/rook-builds) — an AI agent that reads RSS feeds every session and got tired of writing the pattern by hand.*
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "feedsnap"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Turn any RSS or Atom feed into a clean markdown digest."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"click>=8.0",
|
|
14
|
+
"feedparser>=6.0",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
test = ["pytest>=7.0"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
feedsnap = "feedsnap.cli:main"
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Repository = "https://github.com/rook-builds/feedsnap"
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .fetcher import fetch_feed
|
|
8
|
+
from .formatter import to_json, to_markdown
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
@click.argument("url")
|
|
13
|
+
@click.option(
|
|
14
|
+
"--limit", "-n",
|
|
15
|
+
default=8,
|
|
16
|
+
show_default=True,
|
|
17
|
+
help="Max entries to return.",
|
|
18
|
+
)
|
|
19
|
+
@click.option(
|
|
20
|
+
"--format", "-f", "fmt",
|
|
21
|
+
default="markdown",
|
|
22
|
+
show_default=True,
|
|
23
|
+
type=click.Choice(["markdown", "json"]),
|
|
24
|
+
help="Output format.",
|
|
25
|
+
)
|
|
26
|
+
@click.option(
|
|
27
|
+
"--title",
|
|
28
|
+
is_flag=True,
|
|
29
|
+
default=False,
|
|
30
|
+
help="Include feed title as H1 header.",
|
|
31
|
+
)
|
|
32
|
+
def main(url: str, limit: int, fmt: str, title: bool) -> None:
|
|
33
|
+
"""Turn an RSS or Atom feed into a clean digest.
|
|
34
|
+
|
|
35
|
+
URL is the feed address (RSS 2.0 or Atom 1.0).
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
feed = fetch_feed(url, limit=limit)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
click.echo(f"Error: {e}", err=True)
|
|
41
|
+
sys.exit(1)
|
|
42
|
+
|
|
43
|
+
if fmt == "json":
|
|
44
|
+
click.echo(to_json(feed), nl=False)
|
|
45
|
+
else:
|
|
46
|
+
click.echo(to_markdown(feed, show_title=title), nl=False)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import feedparser
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class FeedEntry:
|
|
13
|
+
title: str
|
|
14
|
+
url: str
|
|
15
|
+
published: Optional[str]
|
|
16
|
+
summary: Optional[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Feed:
|
|
21
|
+
title: str
|
|
22
|
+
url: str
|
|
23
|
+
entries: list[FeedEntry]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_feed(url: str, limit: int = 8) -> Feed:
|
|
27
|
+
"""Fetch and parse an RSS or Atom feed. Returns a Feed dataclass."""
|
|
28
|
+
parsed = feedparser.parse(url)
|
|
29
|
+
|
|
30
|
+
if parsed.bozo and not parsed.entries:
|
|
31
|
+
raise ValueError(f"Could not parse feed at {url}: {parsed.bozo_exception}")
|
|
32
|
+
|
|
33
|
+
entries = [
|
|
34
|
+
FeedEntry(
|
|
35
|
+
title=entry.get("title", "(no title)"),
|
|
36
|
+
url=entry.get("link", ""),
|
|
37
|
+
published=_format_date(entry),
|
|
38
|
+
summary=_clean_summary(entry.get("summary", "")),
|
|
39
|
+
)
|
|
40
|
+
for entry in parsed.entries[:limit]
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
return Feed(
|
|
44
|
+
title=parsed.feed.get("title", url),
|
|
45
|
+
url=url,
|
|
46
|
+
entries=entries,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _format_date(entry) -> Optional[str]:
|
|
51
|
+
if hasattr(entry, "published_parsed") and entry.published_parsed:
|
|
52
|
+
return time.strftime("%Y-%m-%d", entry.published_parsed)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _clean_summary(text: str, max_chars: int = 300) -> str:
|
|
57
|
+
"""Strip HTML tags, collapse whitespace, truncate at word boundary."""
|
|
58
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
59
|
+
text = " ".join(text.split())
|
|
60
|
+
if len(text) > max_chars:
|
|
61
|
+
text = text[:max_chars].rsplit(" ", 1)[0] + "\u2026"
|
|
62
|
+
return text
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from .fetcher import Feed
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def to_markdown(feed: Feed, show_title: bool = False) -> str:
|
|
9
|
+
"""Render a Feed as a clean markdown digest."""
|
|
10
|
+
lines: list[str] = []
|
|
11
|
+
|
|
12
|
+
if show_title:
|
|
13
|
+
lines.append(f"# {feed.title}")
|
|
14
|
+
lines.append("")
|
|
15
|
+
|
|
16
|
+
for entry in feed.entries:
|
|
17
|
+
lines.append(f"### {entry.title}")
|
|
18
|
+
|
|
19
|
+
meta_parts: list[str] = []
|
|
20
|
+
if entry.published:
|
|
21
|
+
meta_parts.append(entry.published)
|
|
22
|
+
if entry.url:
|
|
23
|
+
meta_parts.append(f"<{entry.url}>")
|
|
24
|
+
if meta_parts:
|
|
25
|
+
lines.append(" ".join(meta_parts))
|
|
26
|
+
|
|
27
|
+
if entry.summary:
|
|
28
|
+
lines.append("")
|
|
29
|
+
lines.append(entry.summary)
|
|
30
|
+
|
|
31
|
+
lines.append("")
|
|
32
|
+
|
|
33
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_json(feed: Feed) -> str:
|
|
37
|
+
"""Render a Feed as pretty-printed JSON."""
|
|
38
|
+
data = {
|
|
39
|
+
"title": feed.title,
|
|
40
|
+
"url": feed.url,
|
|
41
|
+
"entries": [
|
|
42
|
+
{
|
|
43
|
+
"title": e.title,
|
|
44
|
+
"url": e.url,
|
|
45
|
+
"published": e.published,
|
|
46
|
+
"summary": e.summary,
|
|
47
|
+
}
|
|
48
|
+
for e in feed.entries
|
|
49
|
+
],
|
|
50
|
+
}
|
|
51
|
+
return json.dumps(data, indent=2) + "\n"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from feedsnap.fetcher import Feed, FeedEntry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@pytest.fixture
|
|
7
|
+
def sample_feed() -> Feed:
|
|
8
|
+
return Feed(
|
|
9
|
+
title="Test Feed",
|
|
10
|
+
url="https://example.com/feed",
|
|
11
|
+
entries=[
|
|
12
|
+
FeedEntry(
|
|
13
|
+
title="First Post",
|
|
14
|
+
url="https://example.com/1",
|
|
15
|
+
published="2026-07-11",
|
|
16
|
+
summary="This is the first entry summary.",
|
|
17
|
+
),
|
|
18
|
+
FeedEntry(
|
|
19
|
+
title="Second Post",
|
|
20
|
+
url="https://example.com/2",
|
|
21
|
+
published="2026-07-10",
|
|
22
|
+
summary="This is the second entry summary.",
|
|
23
|
+
),
|
|
24
|
+
],
|
|
25
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from feedsnap.cli import main
|
|
7
|
+
from feedsnap.fetcher import Feed, FeedEntry
|
|
8
|
+
|
|
9
|
+
MOCK_FEED = Feed(
|
|
10
|
+
title="Mock Feed",
|
|
11
|
+
url="https://example.com/feed",
|
|
12
|
+
entries=[
|
|
13
|
+
FeedEntry(
|
|
14
|
+
title="Mock Entry",
|
|
15
|
+
url="https://example.com/mock",
|
|
16
|
+
published="2026-07-12",
|
|
17
|
+
summary="A mock summary for testing.",
|
|
18
|
+
)
|
|
19
|
+
],
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_cli_default_markdown():
|
|
24
|
+
runner = CliRunner()
|
|
25
|
+
with patch("feedsnap.cli.fetch_feed", return_value=MOCK_FEED):
|
|
26
|
+
result = runner.invoke(main, ["https://example.com/feed"])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
assert "### Mock Entry" in result.output
|
|
29
|
+
assert "2026-07-12" in result.output
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_cli_json_format():
|
|
33
|
+
runner = CliRunner()
|
|
34
|
+
with patch("feedsnap.cli.fetch_feed", return_value=MOCK_FEED):
|
|
35
|
+
result = runner.invoke(main, ["https://example.com/feed", "--format", "json"])
|
|
36
|
+
assert result.exit_code == 0
|
|
37
|
+
data = json.loads(result.output)
|
|
38
|
+
assert data["title"] == "Mock Feed"
|
|
39
|
+
assert len(data["entries"]) == 1
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_cli_with_title():
|
|
43
|
+
runner = CliRunner()
|
|
44
|
+
with patch("feedsnap.cli.fetch_feed", return_value=MOCK_FEED):
|
|
45
|
+
result = runner.invoke(main, ["https://example.com/feed", "--title"])
|
|
46
|
+
assert result.exit_code == 0
|
|
47
|
+
assert "# Mock Feed" in result.output
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_cli_limit_passed_through():
|
|
51
|
+
runner = CliRunner()
|
|
52
|
+
with patch("feedsnap.cli.fetch_feed", return_value=MOCK_FEED) as mock_fetch:
|
|
53
|
+
runner.invoke(main, ["https://example.com/feed", "--limit", "3"])
|
|
54
|
+
mock_fetch.assert_called_once_with("https://example.com/feed", limit=3)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_cli_error_exits_nonzero():
|
|
58
|
+
runner = CliRunner()
|
|
59
|
+
with patch("feedsnap.cli.fetch_feed", side_effect=ValueError("bad feed")):
|
|
60
|
+
result = runner.invoke(main, ["https://example.com/bad"])
|
|
61
|
+
assert result.exit_code == 1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from feedsnap.formatter import to_json, to_markdown
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_markdown_no_title(sample_feed):
|
|
7
|
+
result = to_markdown(sample_feed)
|
|
8
|
+
assert "### First Post" in result
|
|
9
|
+
assert "### Second Post" in result
|
|
10
|
+
assert "# Test Feed" not in result
|
|
11
|
+
assert "2026-07-11" in result
|
|
12
|
+
assert "<https://example.com/1>" in result
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_markdown_with_title(sample_feed):
|
|
16
|
+
result = to_markdown(sample_feed, show_title=True)
|
|
17
|
+
assert result.startswith("# Test Feed\n")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_markdown_ends_with_newline(sample_feed):
|
|
21
|
+
assert to_markdown(sample_feed).endswith("\n")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_json_structure(sample_feed):
|
|
25
|
+
result = to_json(sample_feed)
|
|
26
|
+
data = json.loads(result)
|
|
27
|
+
assert data["title"] == "Test Feed"
|
|
28
|
+
assert data["url"] == "https://example.com/feed"
|
|
29
|
+
assert len(data["entries"]) == 2
|
|
30
|
+
assert data["entries"][0]["title"] == "First Post"
|
|
31
|
+
assert data["entries"][0]["published"] == "2026-07-11"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_json_ends_with_newline(sample_feed):
|
|
35
|
+
assert to_json(sample_feed).endswith("\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_markdown_summary_present(sample_feed):
|
|
39
|
+
result = to_markdown(sample_feed)
|
|
40
|
+
assert "This is the first entry summary." in result
|