minigist 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- minigist-0.1.2/.python-version +1 -0
- minigist-0.1.2/LICENSE +7 -0
- minigist-0.1.2/MANIFEST.in +4 -0
- minigist-0.1.2/PKG-INFO +117 -0
- minigist-0.1.2/README.md +90 -0
- minigist-0.1.2/minigist/__init__.py +0 -0
- minigist-0.1.2/minigist/cli.py +73 -0
- minigist-0.1.2/minigist/config.py +122 -0
- minigist-0.1.2/minigist/constants.py +2 -0
- minigist-0.1.2/minigist/exceptions.py +22 -0
- minigist-0.1.2/minigist/logging.py +46 -0
- minigist-0.1.2/minigist/miniflux_client.py +60 -0
- minigist-0.1.2/minigist/models.py +30 -0
- minigist-0.1.2/minigist/notification.py +41 -0
- minigist-0.1.2/minigist/processor.py +74 -0
- minigist-0.1.2/minigist/summarizer.py +78 -0
- minigist-0.1.2/minigist.egg-info/PKG-INFO +117 -0
- minigist-0.1.2/minigist.egg-info/SOURCES.txt +26 -0
- minigist-0.1.2/minigist.egg-info/dependency_links.txt +1 -0
- minigist-0.1.2/minigist.egg-info/entry_points.txt +2 -0
- minigist-0.1.2/minigist.egg-info/requires.txt +9 -0
- minigist-0.1.2/minigist.egg-info/top_level.txt +1 -0
- minigist-0.1.2/pyproject.toml +56 -0
- minigist-0.1.2/setup.cfg +4 -0
- minigist-0.1.2/tests/__init__.py +0 -0
- minigist-0.1.2/tests/conftest.py +46 -0
- minigist-0.1.2/tests/unit/__init__.py +0 -0
- minigist-0.1.2/tests/unit/test_config.py +62 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.13
|
minigist-0.1.2/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2025 eikendev
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
minigist-0.1.2/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minigist
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A tool that generates concise summaries for you Miniflux feeds.
|
|
5
|
+
Author: eikendev
|
|
6
|
+
Maintainer: eikendev
|
|
7
|
+
Project-URL: Homepage, https://github.com/eikendev/minigist
|
|
8
|
+
Keywords: miniflux,rss,feed,ai,summarization,cli
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: apprise>=1.9.3
|
|
18
|
+
Requires-Dist: click>=8.1.8
|
|
19
|
+
Requires-Dist: lxml[html-clean]>=5.4.0
|
|
20
|
+
Requires-Dist: markdown>=3.8
|
|
21
|
+
Requires-Dist: miniflux>=1.1.3
|
|
22
|
+
Requires-Dist: newspaper3k>=0.2.8
|
|
23
|
+
Requires-Dist: pydantic-ai>=0.1.6
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
25
|
+
Requires-Dist: structlog>=25.3.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
<h1>minigist</h1>
|
|
30
|
+
<h4 align="center">
|
|
31
|
+
AI-powered summaries for your <a href="https://miniflux.app/">Miniflux</a> feeds.
|
|
32
|
+
</h4>
|
|
33
|
+
<p>Turn your long Miniflux articles into clear, concise summaries.</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<a href="https://github.com/eikendev/minigist/actions"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/eikendev/minigist/main.yml?branch=main"/></a>
|
|
38
|
+
<a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>
|
|
39
|
+
<a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
## 🤘 Features
|
|
43
|
+
|
|
44
|
+
- **Automatic summarization** of unread Miniflux entries
|
|
45
|
+
- **Configurable filters** to target specific feeds
|
|
46
|
+
- **Notification support** via Apprise for various messaging services
|
|
47
|
+
- **Dry-run mode** to preview changes without modifying entries
|
|
48
|
+
- **Structured logging** for better debugging and monitoring
|
|
49
|
+
|
|
50
|
+
## 🚀 Installation
|
|
51
|
+
|
|
52
|
+
Install minigist using `pip`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install minigist
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Install minigist using `uv`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv tool install minigist
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 📄 Usage
|
|
65
|
+
|
|
66
|
+
### Configuration
|
|
67
|
+
|
|
68
|
+
Create a configuration file at `~/.config/minigist/config.yaml`:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
miniflux:
|
|
72
|
+
url: "https://your-miniflux-instance.com"
|
|
73
|
+
api_key: "your-miniflux-api-key"
|
|
74
|
+
|
|
75
|
+
ai:
|
|
76
|
+
api_key: "your-ai-service-api-key"
|
|
77
|
+
base_url: "https://openrouter.ai/api/v1" # Default
|
|
78
|
+
model: "google/gemini-2.5-flash-preview" # Default
|
|
79
|
+
system_prompt: "Generate an executive summary of the provided article." # Default
|
|
80
|
+
|
|
81
|
+
filters:
|
|
82
|
+
feed_ids: [1, 2, 3] # Optional
|
|
83
|
+
fetch_limit: 100 # Default
|
|
84
|
+
|
|
85
|
+
notifications:
|
|
86
|
+
urls: # Apprise notification URLs (optional)
|
|
87
|
+
- "discord://webhook_id/webhook_token"
|
|
88
|
+
- "telegram://bot_token/chat_id"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
See [Apprise documentation](https://github.com/caronc/apprise) for all supported notification services.
|
|
92
|
+
|
|
93
|
+
### Basic Commands
|
|
94
|
+
|
|
95
|
+
Run minigist to process unread entries:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
minigist run
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run in dry-run mode to see what would happen without making changes:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
minigist run --dry-run
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Increase logging verbosity:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
minigist run --log-level DEBUG
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use a different configuration file:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
minigist run --config-file /path/to/config.yaml
|
|
117
|
+
```
|
minigist-0.1.2/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>minigist</h1>
|
|
3
|
+
<h4 align="center">
|
|
4
|
+
AI-powered summaries for your <a href="https://miniflux.app/">Miniflux</a> feeds.
|
|
5
|
+
</h4>
|
|
6
|
+
<p>Turn your long Miniflux articles into clear, concise summaries.</p>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/eikendev/minigist/actions"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/eikendev/minigist/main.yml?branch=main"/></a>
|
|
11
|
+
<a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>
|
|
12
|
+
<a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
## 🤘 Features
|
|
16
|
+
|
|
17
|
+
- **Automatic summarization** of unread Miniflux entries
|
|
18
|
+
- **Configurable filters** to target specific feeds
|
|
19
|
+
- **Notification support** via Apprise for various messaging services
|
|
20
|
+
- **Dry-run mode** to preview changes without modifying entries
|
|
21
|
+
- **Structured logging** for better debugging and monitoring
|
|
22
|
+
|
|
23
|
+
## 🚀 Installation
|
|
24
|
+
|
|
25
|
+
Install minigist using `pip`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install minigist
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Install minigist using `uv`:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install minigist
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 📄 Usage
|
|
38
|
+
|
|
39
|
+
### Configuration
|
|
40
|
+
|
|
41
|
+
Create a configuration file at `~/.config/minigist/config.yaml`:
|
|
42
|
+
|
|
43
|
+
```yaml
|
|
44
|
+
miniflux:
|
|
45
|
+
url: "https://your-miniflux-instance.com"
|
|
46
|
+
api_key: "your-miniflux-api-key"
|
|
47
|
+
|
|
48
|
+
ai:
|
|
49
|
+
api_key: "your-ai-service-api-key"
|
|
50
|
+
base_url: "https://openrouter.ai/api/v1" # Default
|
|
51
|
+
model: "google/gemini-2.5-flash-preview" # Default
|
|
52
|
+
system_prompt: "Generate an executive summary of the provided article." # Default
|
|
53
|
+
|
|
54
|
+
filters:
|
|
55
|
+
feed_ids: [1, 2, 3] # Optional
|
|
56
|
+
fetch_limit: 100 # Default
|
|
57
|
+
|
|
58
|
+
notifications:
|
|
59
|
+
urls: # Apprise notification URLs (optional)
|
|
60
|
+
- "discord://webhook_id/webhook_token"
|
|
61
|
+
- "telegram://bot_token/chat_id"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
See [Apprise documentation](https://github.com/caronc/apprise) for all supported notification services.
|
|
65
|
+
|
|
66
|
+
### Basic Commands
|
|
67
|
+
|
|
68
|
+
Run minigist to process unread entries:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
minigist run
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run in dry-run mode to see what would happen without making changes:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
minigist run --dry-run
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Increase logging verbosity:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
minigist run --log-level DEBUG
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use a different configuration file:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
minigist run --config-file /path/to/config.yaml
|
|
90
|
+
```
|
|
File without changes
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
from minigist import config, exceptions, notification
|
|
7
|
+
from minigist.logging import configure_logging, get_logger
|
|
8
|
+
from minigist.processor import Processor
|
|
9
|
+
|
|
10
|
+
MINIGIST_ENV_PREFIX = "MINIGIST"
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group(context_settings=dict(auto_envvar_prefix=MINIGIST_ENV_PREFIX))
|
|
16
|
+
def cli():
|
|
17
|
+
"""
|
|
18
|
+
A tool that generates concise summaries for you Miniflux feeds.
|
|
19
|
+
"""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@cli.command()
|
|
24
|
+
@click.option(
|
|
25
|
+
"--config-file",
|
|
26
|
+
type=click.Path(dir_okay=False),
|
|
27
|
+
help="Path to the YAML configuration file.",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"--log-level",
|
|
31
|
+
type=click.Choice(
|
|
32
|
+
["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], case_sensitive=False
|
|
33
|
+
),
|
|
34
|
+
default="INFO",
|
|
35
|
+
show_default=True,
|
|
36
|
+
help="Set the logging level.",
|
|
37
|
+
)
|
|
38
|
+
@click.option(
|
|
39
|
+
"--dry-run",
|
|
40
|
+
is_flag=True,
|
|
41
|
+
default=False,
|
|
42
|
+
help="Perform a dry run without updating Miniflux.",
|
|
43
|
+
)
|
|
44
|
+
def run(
|
|
45
|
+
config_file: Optional[str],
|
|
46
|
+
log_level: str,
|
|
47
|
+
dry_run: bool,
|
|
48
|
+
):
|
|
49
|
+
"""Fetch entries, summarize, and update Miniflux."""
|
|
50
|
+
configure_logging(log_level)
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
app_config = config.load_app_config(config_file)
|
|
54
|
+
except exceptions.ConfigError as e:
|
|
55
|
+
logger.critical("Configuration error", error=str(e))
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
|
|
58
|
+
notifier = notification.AppriseNotifier(app_config.notifications.urls)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
processor = Processor(app_config, dry_run=dry_run)
|
|
62
|
+
processor.run()
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.critical("An error occurred during processing", error=str(e))
|
|
65
|
+
notifier.notify(
|
|
66
|
+
title="Minigist Critical Error",
|
|
67
|
+
body=f"An error occurred during processing: {e}",
|
|
68
|
+
)
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
cli()
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import List, Optional
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from pydantic import BaseModel, Field, HttpUrl, ValidationError
|
|
6
|
+
|
|
7
|
+
from minigist.exceptions import ConfigError
|
|
8
|
+
from minigist.logging import get_logger
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
DEFAULT_CONFIG_PATHS = [
|
|
13
|
+
Path("~/.config/minigist/config.yaml").expanduser(),
|
|
14
|
+
Path("~/.config/minigist/config.yml").expanduser(),
|
|
15
|
+
Path("./config.yaml"),
|
|
16
|
+
Path("./config.yml"),
|
|
17
|
+
Path("/etc/minigist/config.yaml"),
|
|
18
|
+
Path("/etc/minigist/config.yml"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MinifluxConfig(BaseModel):
|
|
23
|
+
url: HttpUrl = Field(..., description="URL of the Miniflux instance.")
|
|
24
|
+
api_key: str = Field(..., description="Miniflux API key.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AIServiceConfig(BaseModel):
|
|
28
|
+
model: str = Field(
|
|
29
|
+
"google/gemini-2.5-flash-preview",
|
|
30
|
+
description="AI model identifier to use for summarization.",
|
|
31
|
+
)
|
|
32
|
+
system_prompt: str = Field(
|
|
33
|
+
"Generate an executive summary of the provided article.",
|
|
34
|
+
description="System prompt to guide the AI summarization.",
|
|
35
|
+
)
|
|
36
|
+
api_key: str = Field(
|
|
37
|
+
...,
|
|
38
|
+
description="API key for the AI service.",
|
|
39
|
+
)
|
|
40
|
+
base_url: Optional[str] = Field(
|
|
41
|
+
"https://openrouter.ai/api/v1",
|
|
42
|
+
description="Base URL for the AI service API.",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class NotificationConfig(BaseModel):
|
|
47
|
+
urls: List[str] = Field(
|
|
48
|
+
default_factory=list, description="List of Apprise notification URLs."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class FilterConfig(BaseModel):
|
|
53
|
+
feed_ids: Optional[List[int]] = Field(
|
|
54
|
+
None, description="List of specific feed IDs to include (fetch all if None)."
|
|
55
|
+
)
|
|
56
|
+
fetch_limit: Optional[int] = Field(
|
|
57
|
+
100, description="Maximum number of entries to fetch."
|
|
58
|
+
)
|
|
59
|
+
fetch_days: Optional[int] = Field(
|
|
60
|
+
None,
|
|
61
|
+
description="Number of past days to fetch entries from.",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class AppConfig(BaseModel):
|
|
66
|
+
miniflux: MinifluxConfig
|
|
67
|
+
ai: AIServiceConfig
|
|
68
|
+
notifications: NotificationConfig = Field(
|
|
69
|
+
default_factory=lambda: NotificationConfig.model_construct()
|
|
70
|
+
)
|
|
71
|
+
filters: FilterConfig = Field(
|
|
72
|
+
default_factory=lambda: FilterConfig.model_construct()
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def find_config_file(config_option: Optional[str] = None) -> Path:
|
|
77
|
+
search_paths = (
|
|
78
|
+
[Path(config_option)] if config_option else []
|
|
79
|
+
) + DEFAULT_CONFIG_PATHS
|
|
80
|
+
|
|
81
|
+
for path in search_paths:
|
|
82
|
+
logger.debug("Checking path for config file", path=str(path))
|
|
83
|
+
if path.is_file():
|
|
84
|
+
logger.debug("Found config file", path=str(path))
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
raise ConfigError("No valid config file found")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def load_config_from_file(file_path: Path) -> dict:
|
|
91
|
+
try:
|
|
92
|
+
with open(file_path, "r") as f:
|
|
93
|
+
config_data = yaml.safe_load(f)
|
|
94
|
+
except FileNotFoundError as e:
|
|
95
|
+
logger.error("Config file not found", path=str(file_path))
|
|
96
|
+
raise ConfigError("Config file not found") from e
|
|
97
|
+
except yaml.YAMLError as e:
|
|
98
|
+
logger.error("Error parsing YAML file", path=str(file_path), error=str(e))
|
|
99
|
+
raise ConfigError("Error parsing YAML file") from e
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error("Error reading config file", path=str(file_path), error=str(e))
|
|
102
|
+
raise ConfigError("Error reading config file") from e
|
|
103
|
+
|
|
104
|
+
if config_data is None:
|
|
105
|
+
logger.warning("Config file is empty", path=str(file_path))
|
|
106
|
+
raise ConfigError("Config file is empty")
|
|
107
|
+
|
|
108
|
+
logger.debug("Loaded configuration", path=str(file_path))
|
|
109
|
+
return config_data
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_app_config(config_path_option: Optional[str] = None) -> AppConfig:
|
|
113
|
+
config_file = find_config_file(config_path_option)
|
|
114
|
+
config_data = load_config_from_file(config_file)
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
app_config = AppConfig(**config_data)
|
|
118
|
+
except ValidationError as e:
|
|
119
|
+
logger.error("Error validating application configuration", error=str(e))
|
|
120
|
+
raise ConfigError("Invalid or incomplete configuration") from e
|
|
121
|
+
|
|
122
|
+
return app_config
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class MinigistError(Exception):
|
|
2
|
+
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigError(MinigistError):
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MinifluxApiError(MinigistError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SummarizationError(MinigistError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ArticleFetchError(SummarizationError):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class AIServiceError(SummarizationError):
|
|
22
|
+
pass
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
import structlog
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def configure_logging(log_level_str: str = "INFO") -> None:
|
|
8
|
+
"""
|
|
9
|
+
Configure structlog for the application.
|
|
10
|
+
"""
|
|
11
|
+
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
|
|
12
|
+
|
|
13
|
+
logging.basicConfig(
|
|
14
|
+
format="%(message)s",
|
|
15
|
+
stream=sys.stderr,
|
|
16
|
+
level=log_level,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
for logger_name in logging.root.manager.loggerDict:
|
|
20
|
+
if not logger_name.startswith("minigist"):
|
|
21
|
+
logging.getLogger(logger_name).setLevel(logging.WARNING)
|
|
22
|
+
|
|
23
|
+
structlog.configure(
|
|
24
|
+
processors=[
|
|
25
|
+
structlog.stdlib.filter_by_level,
|
|
26
|
+
structlog.stdlib.add_logger_name,
|
|
27
|
+
structlog.stdlib.add_log_level,
|
|
28
|
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
|
29
|
+
structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
|
|
30
|
+
structlog.processors.StackInfoRenderer(),
|
|
31
|
+
structlog.processors.format_exc_info,
|
|
32
|
+
structlog.processors.UnicodeDecoder(),
|
|
33
|
+
structlog.dev.ConsoleRenderer(),
|
|
34
|
+
],
|
|
35
|
+
context_class=dict,
|
|
36
|
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
|
37
|
+
wrapper_class=structlog.stdlib.BoundLogger,
|
|
38
|
+
cache_logger_on_first_use=True,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
|
43
|
+
"""
|
|
44
|
+
Get a structured logger with the given name.
|
|
45
|
+
"""
|
|
46
|
+
return structlog.get_logger(name)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
from miniflux import Client # type: ignore
|
|
4
|
+
|
|
5
|
+
from .config import FilterConfig, MinifluxConfig
|
|
6
|
+
from .exceptions import MinifluxApiError
|
|
7
|
+
from .logging import get_logger
|
|
8
|
+
from .models import EntriesResponse, Entry
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MinifluxClient:
|
|
14
|
+
def __init__(self, config: MinifluxConfig, dry_run: bool = False):
|
|
15
|
+
self.client = Client(base_url=str(config.url), api_key=config.api_key)
|
|
16
|
+
self.dry_run = dry_run
|
|
17
|
+
|
|
18
|
+
if dry_run:
|
|
19
|
+
logger.warning("Running in dry run mode; no updates will be made")
|
|
20
|
+
|
|
21
|
+
def get_entries(self, filters: FilterConfig) -> List[Entry]:
|
|
22
|
+
params = {
|
|
23
|
+
"status": "unread",
|
|
24
|
+
"direction": "desc",
|
|
25
|
+
"limit": 1,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
logger.debug("Fetching entries", parameters=params)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
raw_response = self.client.get_entries(**params)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
logger.error("Failed to fetch entries from Miniflux", error=str(e))
|
|
34
|
+
raise MinifluxApiError("Failed to fetch entries") from e
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
response = EntriesResponse.model_validate(raw_response)
|
|
38
|
+
except Exception as e:
|
|
39
|
+
logger.error("Failed to parse entries response", error=str(e))
|
|
40
|
+
raise MinifluxApiError("Failed to parse entries response") from e
|
|
41
|
+
|
|
42
|
+
entries = response.entries
|
|
43
|
+
logger.info("Fetched unread entries", count=len(entries))
|
|
44
|
+
|
|
45
|
+
return entries
|
|
46
|
+
|
|
47
|
+
def update_entry(self, entry_id: int, content: str):
|
|
48
|
+
logger.debug("Updating entry", entry_id=entry_id, content=content)
|
|
49
|
+
|
|
50
|
+
if self.dry_run:
|
|
51
|
+
logger.debug(
|
|
52
|
+
"Would update entry; skipping due to dry run", entry_id=entry_id
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
self.client.update_entry(entry_id=entry_id, content=content)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error("Failed to update entry", entry_id=entry_id, error=str(e))
|
|
60
|
+
raise MinifluxApiError(f"Failed to update entry ID {entry_id}") from e
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Entry(BaseModel):
|
|
8
|
+
id: int
|
|
9
|
+
user_id: int
|
|
10
|
+
feed_id: int
|
|
11
|
+
title: str
|
|
12
|
+
url: str
|
|
13
|
+
comments_url: str = ""
|
|
14
|
+
author: str = ""
|
|
15
|
+
content: str = ""
|
|
16
|
+
hash: str
|
|
17
|
+
published_at: datetime
|
|
18
|
+
created_at: datetime
|
|
19
|
+
status: str
|
|
20
|
+
share_code: str = ""
|
|
21
|
+
starred: bool = False
|
|
22
|
+
reading_time: int = 0
|
|
23
|
+
|
|
24
|
+
class Config:
|
|
25
|
+
arbitrary_types_allowed = True
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EntriesResponse(BaseModel):
|
|
29
|
+
total: int
|
|
30
|
+
entries: List[Entry]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
import apprise
|
|
4
|
+
|
|
5
|
+
from .logging import get_logger
|
|
6
|
+
|
|
7
|
+
logger = get_logger(__name__)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AppriseNotifier:
|
|
11
|
+
def __init__(self, urls: List[str]):
|
|
12
|
+
self.apobj = apprise.Apprise()
|
|
13
|
+
self.has_urls = False
|
|
14
|
+
|
|
15
|
+
if urls:
|
|
16
|
+
for url in urls:
|
|
17
|
+
if self.apobj.add(url):
|
|
18
|
+
logger.info("Added Apprise notification URL", url=url)
|
|
19
|
+
self.has_urls = True
|
|
20
|
+
else:
|
|
21
|
+
logger.error("Failed to add invalid Apprise URL", url=url)
|
|
22
|
+
else:
|
|
23
|
+
logger.warning("No Apprise notification URLs configured")
|
|
24
|
+
|
|
25
|
+
def notify(self, title: str, body: str) -> None:
|
|
26
|
+
if not self.has_urls:
|
|
27
|
+
logger.debug("Skipping notification: No valid Apprise URLs configured")
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
logger.info("Sending notification", title=title)
|
|
32
|
+
sent = self.apobj.notify(body=body, title=title)
|
|
33
|
+
if sent:
|
|
34
|
+
logger.debug("Notification sent successfully")
|
|
35
|
+
else:
|
|
36
|
+
logger.error(
|
|
37
|
+
"Failed to send notification to any configured Apprise URL"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error("Error sending notification via Apprise", error=str(e))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
|
|
3
|
+
import markdown
|
|
4
|
+
|
|
5
|
+
from .config import AppConfig
|
|
6
|
+
from .constants import WATERMARK, WATERMARK_DETECTOR
|
|
7
|
+
from .logging import get_logger
|
|
8
|
+
from .miniflux_client import MinifluxClient
|
|
9
|
+
from .models import Entry
|
|
10
|
+
from .summarizer import Summarizer
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Processor:
|
|
16
|
+
def __init__(self, config: AppConfig, dry_run: bool = False):
|
|
17
|
+
self.config = config
|
|
18
|
+
self.client = MinifluxClient(config.miniflux, dry_run=dry_run)
|
|
19
|
+
self.summarizer = Summarizer(config.ai)
|
|
20
|
+
logger.debug("Processor initialized", dry_run=dry_run)
|
|
21
|
+
|
|
22
|
+
def _filter_unsummarized_entries(self, entries: List[Entry]) -> List[Entry]:
|
|
23
|
+
unsummarized = [
|
|
24
|
+
entry for entry in entries if WATERMARK_DETECTOR not in entry.content
|
|
25
|
+
]
|
|
26
|
+
logger.debug(
|
|
27
|
+
"Filtered entries",
|
|
28
|
+
total=len(entries),
|
|
29
|
+
unsummarized=len(unsummarized),
|
|
30
|
+
filtered=len(entries) - len(unsummarized),
|
|
31
|
+
)
|
|
32
|
+
return unsummarized
|
|
33
|
+
|
|
34
|
+
def _process_single_entry(self, entry: Entry) -> bool:
|
|
35
|
+
logger.debug("Processing entry", entry_id=entry.id, title=entry.title)
|
|
36
|
+
|
|
37
|
+
article_text = self.summarizer.fetch_and_parse_article(entry.url)
|
|
38
|
+
if not article_text:
|
|
39
|
+
logger.warning(
|
|
40
|
+
"No article text extracted", entry_id=entry.id, url=entry.url
|
|
41
|
+
)
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
summary = self.summarizer.generate_summary(article_text)
|
|
45
|
+
|
|
46
|
+
markdown_content = f"{summary}\n\n---\n\n{WATERMARK}"
|
|
47
|
+
new_content = markdown.markdown(markdown_content)
|
|
48
|
+
|
|
49
|
+
self.client.update_entry(entry_id=entry.id, content=new_content)
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
def run(self) -> None:
|
|
53
|
+
logger.debug("Starting minigist processor")
|
|
54
|
+
|
|
55
|
+
entries = self.client.get_entries(self.config.filters)
|
|
56
|
+
|
|
57
|
+
if not entries:
|
|
58
|
+
logger.info("No matching unread entries found")
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
logger.debug("Fetched entries", count=len(entries))
|
|
62
|
+
|
|
63
|
+
entries = self._filter_unsummarized_entries(entries)
|
|
64
|
+
|
|
65
|
+
if not entries:
|
|
66
|
+
logger.info("All entries have already been summarized")
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
logger.info("Processing entries", count=len(entries))
|
|
70
|
+
|
|
71
|
+
for entry in entries:
|
|
72
|
+
self._process_single_entry(entry)
|
|
73
|
+
|
|
74
|
+
logger.info("Successfully processed entries", count=len(entries))
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
from newspaper import Article, ArticleException # type: ignore
|
|
4
|
+
from pydantic_ai import Agent
|
|
5
|
+
from pydantic_ai.models.openai import OpenAIModel
|
|
6
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
7
|
+
|
|
8
|
+
from .config import AIServiceConfig
|
|
9
|
+
from .exceptions import AIServiceError, ArticleFetchError
|
|
10
|
+
from .logging import get_logger
|
|
11
|
+
|
|
12
|
+
logger = get_logger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Summarizer:
|
|
16
|
+
def __init__(self, config: AIServiceConfig):
|
|
17
|
+
logger.debug(
|
|
18
|
+
"Using custom API configuration",
|
|
19
|
+
has_api_key=bool(config.api_key),
|
|
20
|
+
has_base_url=bool(config.base_url),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
model = OpenAIModel(
|
|
24
|
+
config.model,
|
|
25
|
+
provider=OpenAIProvider(
|
|
26
|
+
base_url=config.base_url,
|
|
27
|
+
api_key=config.api_key,
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
self.agent = Agent(
|
|
31
|
+
model,
|
|
32
|
+
system_prompt=config.system_prompt,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def fetch_and_parse_article(self, url: str) -> Optional[str]:
|
|
36
|
+
logger.debug("Fetching article content", url=url)
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
article = Article(url)
|
|
40
|
+
article.download()
|
|
41
|
+
article.parse()
|
|
42
|
+
except ArticleException as e:
|
|
43
|
+
logger.error("Newspaper3k failed to process article", url=url, error=str(e))
|
|
44
|
+
raise ArticleFetchError(f"Failed to fetch/parse article {url}") from e
|
|
45
|
+
except Exception as e:
|
|
46
|
+
logger.error(
|
|
47
|
+
"Unexpected error fetching article",
|
|
48
|
+
url=url,
|
|
49
|
+
error=str(e),
|
|
50
|
+
)
|
|
51
|
+
raise ArticleFetchError(f"Unexpected error fetching article {url}") from e
|
|
52
|
+
|
|
53
|
+
text = article.text
|
|
54
|
+
|
|
55
|
+
if not text:
|
|
56
|
+
logger.warning("No text content extracted from article", url=url)
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
logger.debug("Successfully extracted text", url=url, length=len(text))
|
|
60
|
+
return text
|
|
61
|
+
|
|
62
|
+
def generate_summary(self, article_text: str) -> str:
|
|
63
|
+
logger.debug("Generating article summary", length=len(article_text))
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
result = self.agent.run_sync(article_text)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
logger.error("Unexpected error during summarization", error=str(e))
|
|
69
|
+
raise AIServiceError("Unexpected error during summarization") from e
|
|
70
|
+
|
|
71
|
+
if not result or not result.output:
|
|
72
|
+
logger.error("AI service returned an empty result")
|
|
73
|
+
raise AIServiceError("AI service returned an empty result")
|
|
74
|
+
|
|
75
|
+
summary = result.output
|
|
76
|
+
logger.debug("Successfully generated summary", length=len(summary))
|
|
77
|
+
|
|
78
|
+
return summary
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: minigist
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: A tool that generates concise summaries for you Miniflux feeds.
|
|
5
|
+
Author: eikendev
|
|
6
|
+
Maintainer: eikendev
|
|
7
|
+
Project-URL: Homepage, https://github.com/eikendev/minigist
|
|
8
|
+
Keywords: miniflux,rss,feed,ai,summarization,cli
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
|
|
12
|
+
Classifier: Topic :: Text Processing :: Markup
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: apprise>=1.9.3
|
|
18
|
+
Requires-Dist: click>=8.1.8
|
|
19
|
+
Requires-Dist: lxml[html-clean]>=5.4.0
|
|
20
|
+
Requires-Dist: markdown>=3.8
|
|
21
|
+
Requires-Dist: miniflux>=1.1.3
|
|
22
|
+
Requires-Dist: newspaper3k>=0.2.8
|
|
23
|
+
Requires-Dist: pydantic-ai>=0.1.6
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.2
|
|
25
|
+
Requires-Dist: structlog>=25.3.0
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
<h1>minigist</h1>
|
|
30
|
+
<h4 align="center">
|
|
31
|
+
AI-powered summaries for your <a href="https://miniflux.app/">Miniflux</a> feeds.
|
|
32
|
+
</h4>
|
|
33
|
+
<p>Turn your long Miniflux articles into clear, concise summaries.</p>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<a href="https://github.com/eikendev/minigist/actions"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/eikendev/minigist/main.yml?branch=main"/></a>
|
|
38
|
+
<a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>
|
|
39
|
+
<a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
## 🤘 Features
|
|
43
|
+
|
|
44
|
+
- **Automatic summarization** of unread Miniflux entries
|
|
45
|
+
- **Configurable filters** to target specific feeds
|
|
46
|
+
- **Notification support** via Apprise for various messaging services
|
|
47
|
+
- **Dry-run mode** to preview changes without modifying entries
|
|
48
|
+
- **Structured logging** for better debugging and monitoring
|
|
49
|
+
|
|
50
|
+
## 🚀 Installation
|
|
51
|
+
|
|
52
|
+
Install minigist using `pip`:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install minigist
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Install minigist using `uv`:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv tool install minigist
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 📄 Usage
|
|
65
|
+
|
|
66
|
+
### Configuration
|
|
67
|
+
|
|
68
|
+
Create a configuration file at `~/.config/minigist/config.yaml`:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
miniflux:
|
|
72
|
+
url: "https://your-miniflux-instance.com"
|
|
73
|
+
api_key: "your-miniflux-api-key"
|
|
74
|
+
|
|
75
|
+
ai:
|
|
76
|
+
api_key: "your-ai-service-api-key"
|
|
77
|
+
base_url: "https://openrouter.ai/api/v1" # Default
|
|
78
|
+
model: "google/gemini-2.5-flash-preview" # Default
|
|
79
|
+
system_prompt: "Generate an executive summary of the provided article." # Default
|
|
80
|
+
|
|
81
|
+
filters:
|
|
82
|
+
feed_ids: [1, 2, 3] # Optional
|
|
83
|
+
fetch_limit: 100 # Default
|
|
84
|
+
|
|
85
|
+
notifications:
|
|
86
|
+
urls: # Apprise notification URLs (optional)
|
|
87
|
+
- "discord://webhook_id/webhook_token"
|
|
88
|
+
- "telegram://bot_token/chat_id"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
See [Apprise documentation](https://github.com/caronc/apprise) for all supported notification services.
|
|
92
|
+
|
|
93
|
+
### Basic Commands
|
|
94
|
+
|
|
95
|
+
Run minigist to process unread entries:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
minigist run
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Run in dry-run mode to see what would happen without making changes:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
minigist run --dry-run
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Increase logging verbosity:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
minigist run --log-level DEBUG
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use a different configuration file:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
minigist run --config-file /path/to/config.yaml
|
|
117
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
.python-version
|
|
2
|
+
LICENSE
|
|
3
|
+
MANIFEST.in
|
|
4
|
+
README.md
|
|
5
|
+
pyproject.toml
|
|
6
|
+
minigist/__init__.py
|
|
7
|
+
minigist/cli.py
|
|
8
|
+
minigist/config.py
|
|
9
|
+
minigist/constants.py
|
|
10
|
+
minigist/exceptions.py
|
|
11
|
+
minigist/logging.py
|
|
12
|
+
minigist/miniflux_client.py
|
|
13
|
+
minigist/models.py
|
|
14
|
+
minigist/notification.py
|
|
15
|
+
minigist/processor.py
|
|
16
|
+
minigist/summarizer.py
|
|
17
|
+
minigist.egg-info/PKG-INFO
|
|
18
|
+
minigist.egg-info/SOURCES.txt
|
|
19
|
+
minigist.egg-info/dependency_links.txt
|
|
20
|
+
minigist.egg-info/entry_points.txt
|
|
21
|
+
minigist.egg-info/requires.txt
|
|
22
|
+
minigist.egg-info/top_level.txt
|
|
23
|
+
tests/__init__.py
|
|
24
|
+
tests/conftest.py
|
|
25
|
+
tests/unit/__init__.py
|
|
26
|
+
tests/unit/test_config.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
minigist
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "minigist"
|
|
3
|
+
description = "A tool that generates concise summaries for you Miniflux feeds."
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
license-files = ["LICENSE"]
|
|
6
|
+
requires-python = ">=3.13"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "eikendev"},
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{name = "eikendev"},
|
|
13
|
+
]
|
|
14
|
+
keywords = ["miniflux", "rss", "feed", "ai", "summarization", "cli"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary",
|
|
19
|
+
"Topic :: Text Processing :: Markup",
|
|
20
|
+
"Topic :: Utilities",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"apprise>=1.9.3",
|
|
24
|
+
"click>=8.1.8",
|
|
25
|
+
"lxml[html-clean]>=5.4.0",
|
|
26
|
+
"markdown>=3.8",
|
|
27
|
+
"miniflux>=1.1.3",
|
|
28
|
+
"newspaper3k>=0.2.8",
|
|
29
|
+
"pydantic-ai>=0.1.6",
|
|
30
|
+
"pyyaml>=6.0.2",
|
|
31
|
+
"structlog>=25.3.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/eikendev/minigist"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
minigist = "minigist.cli:cli"
|
|
39
|
+
|
|
40
|
+
[build-system]
|
|
41
|
+
requires = ["setuptools>=64", "setuptools-scm"]
|
|
42
|
+
build-backend = "setuptools.build_meta"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools_scm]
|
|
45
|
+
version_scheme = "post-release"
|
|
46
|
+
local_scheme = "no-local-version"
|
|
47
|
+
|
|
48
|
+
[dependency-groups]
|
|
49
|
+
dev = [
|
|
50
|
+
"isort>=6.0.1",
|
|
51
|
+
"mypy>=1.15.0",
|
|
52
|
+
"pytest>=8.3.5",
|
|
53
|
+
"ruff>=0.11.7",
|
|
54
|
+
"types-markdown>=3.8.0.20250415",
|
|
55
|
+
"types-pyyaml>=6.0.12.20250402",
|
|
56
|
+
]
|
minigist-0.1.2/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import mock_open
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def valid_config_dict():
|
|
10
|
+
"""Fixture providing a valid configuration dictionary."""
|
|
11
|
+
return {
|
|
12
|
+
"miniflux": {"url": "https://example.com", "api_key": "test_miniflux_key"},
|
|
13
|
+
"ai": {
|
|
14
|
+
"api_key": "test_ai_key",
|
|
15
|
+
"model": "test-model",
|
|
16
|
+
"system_prompt": "Test prompt",
|
|
17
|
+
"base_url": "https://api.test.com",
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def invalid_config_dict():
|
|
24
|
+
"""Fixture providing an invalid configuration dictionary (missing required fields)."""
|
|
25
|
+
return {
|
|
26
|
+
"miniflux": {
|
|
27
|
+
"url": "https://example.com"
|
|
28
|
+
# Missing api_key
|
|
29
|
+
},
|
|
30
|
+
"ai": {
|
|
31
|
+
# Missing api_key
|
|
32
|
+
"model": "test-model"
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.fixture
|
|
38
|
+
def mock_config_file(valid_config_dict):
|
|
39
|
+
"""Fixture providing a mock file with valid YAML config content."""
|
|
40
|
+
return mock_open(read_data=yaml.dump(valid_config_dict))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.fixture
|
|
44
|
+
def mock_config_path():
|
|
45
|
+
"""Fixture providing a mock config file path."""
|
|
46
|
+
return Path("/mock/path/config.yaml")
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from unittest.mock import mock_open, patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
from minigist.config import AppConfig, load_app_config, load_config_from_file
|
|
8
|
+
from minigist.exceptions import ConfigError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_load_config_from_file_success(valid_config_dict):
|
|
12
|
+
mock_yaml_content = yaml.dump(valid_config_dict)
|
|
13
|
+
with patch("builtins.open", mock_open(read_data=mock_yaml_content)):
|
|
14
|
+
result = load_config_from_file(Path("fake_path.yaml"))
|
|
15
|
+
|
|
16
|
+
assert result == valid_config_dict
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_load_config_from_file_empty():
|
|
20
|
+
with patch("builtins.open", mock_open(read_data="")):
|
|
21
|
+
with pytest.raises(ConfigError, match="Config file is empty"):
|
|
22
|
+
load_config_from_file(Path("fake_path.yaml"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_load_config_from_file_not_found():
|
|
26
|
+
with patch("builtins.open", side_effect=FileNotFoundError()):
|
|
27
|
+
with pytest.raises(ConfigError, match="Config file not found"):
|
|
28
|
+
load_config_from_file(Path("nonexistent.yaml"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_load_app_config_success(valid_config_dict, mock_config_path):
|
|
32
|
+
with patch("minigist.config.find_config_file", return_value=mock_config_path):
|
|
33
|
+
with patch(
|
|
34
|
+
"minigist.config.load_config_from_file", return_value=valid_config_dict
|
|
35
|
+
):
|
|
36
|
+
result = load_app_config("some/path")
|
|
37
|
+
|
|
38
|
+
assert isinstance(result, AppConfig)
|
|
39
|
+
assert str(result.miniflux.url) == "https://example.com/"
|
|
40
|
+
assert result.miniflux.api_key == "test_miniflux_key"
|
|
41
|
+
assert result.ai.api_key == "test_ai_key"
|
|
42
|
+
assert result.ai.model == "test-model"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_load_app_config_validation_error(invalid_config_dict, mock_config_path):
|
|
46
|
+
with patch("minigist.config.find_config_file", return_value=mock_config_path):
|
|
47
|
+
with patch(
|
|
48
|
+
"minigist.config.load_config_from_file", return_value=invalid_config_dict
|
|
49
|
+
):
|
|
50
|
+
with pytest.raises(
|
|
51
|
+
ConfigError, match="Invalid or incomplete configuration"
|
|
52
|
+
):
|
|
53
|
+
load_app_config("some/path")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_load_app_config_config_error(mock_config_path):
|
|
57
|
+
with patch(
|
|
58
|
+
"minigist.config.find_config_file",
|
|
59
|
+
side_effect=ConfigError("No valid config file found"),
|
|
60
|
+
):
|
|
61
|
+
with pytest.raises(ConfigError, match="No valid config file found"):
|
|
62
|
+
load_app_config("some/path")
|