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.
@@ -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.
@@ -0,0 +1,4 @@
1
+ exclude .gitignore
2
+ exclude uv.lock
3
+ recursive-exclude .clinerules *
4
+ recursive-exclude .github *
@@ -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>&nbsp;
38
+ <a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>&nbsp;
39
+ <a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>&nbsp;
40
+ </p>
41
+
42
+ ## 🤘&nbsp;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
+ ## 🚀&nbsp;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
+ ## 📄&nbsp;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,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>&nbsp;
11
+ <a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>&nbsp;
12
+ <a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>&nbsp;
13
+ </p>
14
+
15
+ ## 🤘&nbsp;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
+ ## 🚀&nbsp;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
+ ## 📄&nbsp;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,2 @@
1
+ WATERMARK = "*Summarized by minigist* ([GitHub](https://github.com/eikendev/minigist))"
2
+ WATERMARK_DETECTOR = "Summarized by minigist"
@@ -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>&nbsp;
38
+ <a href="https://github.com/eikendev/minigist/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/eikendev/minigist"/></a>&nbsp;
39
+ <a href="https://pypi.org/project/minigist/"><img alt="PyPI" src="https://img.shields.io/pypi/v/minigist"/></a>&nbsp;
40
+ </p>
41
+
42
+ ## 🤘&nbsp;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
+ ## 🚀&nbsp;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
+ ## 📄&nbsp;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,2 @@
1
+ [console_scripts]
2
+ minigist = minigist.cli:cli
@@ -0,0 +1,9 @@
1
+ apprise>=1.9.3
2
+ click>=8.1.8
3
+ lxml[html-clean]>=5.4.0
4
+ markdown>=3.8
5
+ miniflux>=1.1.3
6
+ newspaper3k>=0.2.8
7
+ pydantic-ai>=0.1.6
8
+ pyyaml>=6.0.2
9
+ structlog>=25.3.0
@@ -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
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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")