medium-no-bait-cli 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.
- medium_no_bait_cli-0.1.0/LICENSE +21 -0
- medium_no_bait_cli-0.1.0/PKG-INFO +65 -0
- medium_no_bait_cli-0.1.0/README.md +45 -0
- medium_no_bait_cli-0.1.0/pyproject.toml +35 -0
- medium_no_bait_cli-0.1.0/setup.cfg +4 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/__init__.py +0 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/author_updates/__init__.py +0 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/author_updates/tracker.py +52 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/main.py +238 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/shared/__init__.py +0 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/shared/models.py +17 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/shared/scraper.py +59 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait/shared/storage.py +95 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/PKG-INFO +65 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/SOURCES.txt +17 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/dependency_links.txt +1 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/entry_points.txt +2 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/requires.txt +4 -0
- medium_no_bait_cli-0.1.0/src/medium_no_bait_cli.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shashwat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medium-no-bait-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A distillation-first, no-bait Medium reader for the terminal
|
|
5
|
+
Author-email: Shashwat <your-email@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/your-username/best-medium-reader
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/your-username/best-medium-reader/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.31.0
|
|
16
|
+
Requires-Dist: rich>=13.7.0
|
|
17
|
+
Requires-Dist: beautifulsoup4>=4.12.3
|
|
18
|
+
Requires-Dist: lxml>=5.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Medium No-Bait CLI 🚀
|
|
22
|
+
|
|
23
|
+
A high-performance, terminal-based Medium reader designed for developers who value signal over noise. Track your favorite authors and publications, and catch the most relevant stories without the "clap-bait" and social distractions.
|
|
24
|
+
|
|
25
|
+
## Why it exists?
|
|
26
|
+
On Medium, the home feed is an algorithm designed to keep you scrolling. **Medium No-Bait CLI** gives you a strictly curated, high-signal experience that only shows you what YOU chose to follow.
|
|
27
|
+
|
|
28
|
+
- **Zero Distractions**: No ads, no claps, no social pressure.
|
|
29
|
+
- **Keyword Hits**: Catch the most relevant stories using custom filters (e.g., AI, Python).
|
|
30
|
+
- **RSS-Powered**: Lightning-fast update checks.
|
|
31
|
+
- **Terminal First**: Clean, full links that are 100% clickable for your browser.
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Installation
|
|
36
|
+
1. Clone the repository:
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/your-username/best-medium-reader.git
|
|
39
|
+
cd best-medium-reader
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
2. Install the package locally:
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Running the App
|
|
48
|
+
Once installed, you can simply type:
|
|
49
|
+
```bash
|
|
50
|
+
mnb
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
1. **Manage Favorites**: Add authors (use `@username`) and publications (use the pub name from the URL, e.g., `the-startup`).
|
|
56
|
+
2. **Setup Keywords**: Add specific keywords you want to track across your favorites.
|
|
57
|
+
3. **Enjoy Signal**: Use the **Keyword Hits** view to see exactly what you care about.
|
|
58
|
+
|
|
59
|
+
## Project Structure
|
|
60
|
+
- `src/medium_no_bait/main.py`: The central terminal interface.
|
|
61
|
+
- `src/medium_no_bait/author_updates/`: Logic for tracking and filtering updates.
|
|
62
|
+
- `src/medium_no_bait/shared/`: Shared tools (RSS Scraper, Storage).
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
Built with 💙 for the Medium developer community.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Medium No-Bait CLI 🚀
|
|
2
|
+
|
|
3
|
+
A high-performance, terminal-based Medium reader designed for developers who value signal over noise. Track your favorite authors and publications, and catch the most relevant stories without the "clap-bait" and social distractions.
|
|
4
|
+
|
|
5
|
+
## Why it exists?
|
|
6
|
+
On Medium, the home feed is an algorithm designed to keep you scrolling. **Medium No-Bait CLI** gives you a strictly curated, high-signal experience that only shows you what YOU chose to follow.
|
|
7
|
+
|
|
8
|
+
- **Zero Distractions**: No ads, no claps, no social pressure.
|
|
9
|
+
- **Keyword Hits**: Catch the most relevant stories using custom filters (e.g., AI, Python).
|
|
10
|
+
- **RSS-Powered**: Lightning-fast update checks.
|
|
11
|
+
- **Terminal First**: Clean, full links that are 100% clickable for your browser.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### Installation
|
|
16
|
+
1. Clone the repository:
|
|
17
|
+
```bash
|
|
18
|
+
git clone https://github.com/your-username/best-medium-reader.git
|
|
19
|
+
cd best-medium-reader
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
2. Install the package locally:
|
|
23
|
+
```bash
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Running the App
|
|
28
|
+
Once installed, you can simply type:
|
|
29
|
+
```bash
|
|
30
|
+
mnb
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## How It Works
|
|
34
|
+
|
|
35
|
+
1. **Manage Favorites**: Add authors (use `@username`) and publications (use the pub name from the URL, e.g., `the-startup`).
|
|
36
|
+
2. **Setup Keywords**: Add specific keywords you want to track across your favorites.
|
|
37
|
+
3. **Enjoy Signal**: Use the **Keyword Hits** view to see exactly what you care about.
|
|
38
|
+
|
|
39
|
+
## Project Structure
|
|
40
|
+
- `src/medium_no_bait/main.py`: The central terminal interface.
|
|
41
|
+
- `src/medium_no_bait/author_updates/`: Logic for tracking and filtering updates.
|
|
42
|
+
- `src/medium_no_bait/shared/`: Shared tools (RSS Scraper, Storage).
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
Built with 💙 for the Medium developer community.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "medium-no-bait-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Shashwat", email="your-email@example.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "A distillation-first, no-bait Medium reader for the terminal"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.8"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Environment :: Console",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"requests>=2.31.0",
|
|
22
|
+
"rich>=13.7.0",
|
|
23
|
+
"beautifulsoup4>=4.12.3",
|
|
24
|
+
"lxml>=5.1.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
"Homepage" = "https://github.com/your-username/best-medium-reader"
|
|
29
|
+
"Bug Tracker" = "https://github.com/your-username/best-medium-reader/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
mnb = "medium_no_bait.main:main"
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
from ..shared.scraper import MediumScraper
|
|
7
|
+
from ..shared.models import Article
|
|
8
|
+
|
|
9
|
+
class UpdatesTracker:
|
|
10
|
+
def __init__(self, targets: List[str], target_type: str = "authors"):
|
|
11
|
+
self.targets = [t.lstrip("@") for t in targets]
|
|
12
|
+
self.target_type = target_type
|
|
13
|
+
self.scraper = MediumScraper()
|
|
14
|
+
|
|
15
|
+
def get_updates_after(self, date_limit: datetime, update_timestamp: bool = False, limit: int = 10, keywords: List[str] = None) -> List[Article]:
|
|
16
|
+
all_updates = []
|
|
17
|
+
from shared.storage import Storage
|
|
18
|
+
storage = Storage()
|
|
19
|
+
|
|
20
|
+
for target in self.targets:
|
|
21
|
+
if self.target_type == "authors":
|
|
22
|
+
articles = self.scraper.fetch_articles_from_author(target, limit=limit)
|
|
23
|
+
else:
|
|
24
|
+
articles = self.scraper.fetch_articles_from_publication(target, limit=limit)
|
|
25
|
+
|
|
26
|
+
updates = [a for a in articles if a.pub_date > date_limit]
|
|
27
|
+
|
|
28
|
+
# Filter by keywords if provided
|
|
29
|
+
if keywords:
|
|
30
|
+
filtered_updates = []
|
|
31
|
+
for art in updates:
|
|
32
|
+
content_to_check = (art.title + " " + getattr(art, "summary", "")).lower()
|
|
33
|
+
if any(kw.lower() in content_to_check for kw in keywords):
|
|
34
|
+
filtered_updates.append(art)
|
|
35
|
+
updates = filtered_updates
|
|
36
|
+
|
|
37
|
+
all_updates.extend(updates)
|
|
38
|
+
|
|
39
|
+
if update_timestamp and updates:
|
|
40
|
+
storage.update_last_access(target, type=self.target_type, dt=datetime.now())
|
|
41
|
+
|
|
42
|
+
all_updates.sort(key=lambda x: x.pub_date, reverse=True)
|
|
43
|
+
return all_updates
|
|
44
|
+
|
|
45
|
+
if __name__ == "__main__":
|
|
46
|
+
# Test
|
|
47
|
+
tracker = UpdatesTracker(["@intellizab", "curiosai"])
|
|
48
|
+
limit = datetime(2026, 2, 20)
|
|
49
|
+
print(f"Checking updates after {limit.date()}...")
|
|
50
|
+
updates = tracker.get_updates_after(limit)
|
|
51
|
+
for art in updates:
|
|
52
|
+
print(f"[{art.pub_date.date()}] {art.title} by {art.author}")
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.panel import Panel
|
|
6
|
+
from rich.prompt import Prompt, IntPrompt
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich import box
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
# Ensure shared and feature folders are in path
|
|
12
|
+
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
|
13
|
+
|
|
14
|
+
from .author_updates.tracker import UpdatesTracker
|
|
15
|
+
from .shared.models import Article
|
|
16
|
+
from .shared.storage import Storage
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
storage = Storage()
|
|
20
|
+
|
|
21
|
+
def show_home():
|
|
22
|
+
console.clear()
|
|
23
|
+
console.print(Panel.fit(
|
|
24
|
+
"[bold cyan]Medium No-Bait CLI[/bold cyan]\n"
|
|
25
|
+
"[white]Your distraction-free terminal reader[/white]",
|
|
26
|
+
box=box.DOUBLE,
|
|
27
|
+
border_style="bright_blue"
|
|
28
|
+
))
|
|
29
|
+
|
|
30
|
+
# Show Summary
|
|
31
|
+
authors = storage.get_authors()
|
|
32
|
+
pubs = storage.get_publications()
|
|
33
|
+
keywords = storage.get_keywords()
|
|
34
|
+
|
|
35
|
+
summary_table = Table(box=box.SIMPLE, show_header=False)
|
|
36
|
+
summary_table.add_column("Key", style="bold cyan")
|
|
37
|
+
summary_table.add_column("Value")
|
|
38
|
+
summary_table.add_row("Authors", f"{len(authors)} favorites")
|
|
39
|
+
summary_table.add_row("Publications", f"{len(pubs)} followed")
|
|
40
|
+
summary_table.add_row("Keywords", f"{len(keywords)} active" if keywords else "None")
|
|
41
|
+
|
|
42
|
+
# Calculate most recent check across all targets
|
|
43
|
+
all_dates = []
|
|
44
|
+
for a in authors:
|
|
45
|
+
dt = storage.get_last_access(a, type="authors")
|
|
46
|
+
if dt: all_dates.append(dt)
|
|
47
|
+
for p in pubs:
|
|
48
|
+
dt = storage.get_last_access(p, type="publications")
|
|
49
|
+
if dt: all_dates.append(dt)
|
|
50
|
+
|
|
51
|
+
if all_dates:
|
|
52
|
+
latest = max(all_dates).strftime("%b %d, %H:%M")
|
|
53
|
+
summary_table.add_row("Last Check", f"[dim]{latest}[/dim]")
|
|
54
|
+
else:
|
|
55
|
+
summary_table.add_row("Last Check", "[dim]Never[/dim]")
|
|
56
|
+
|
|
57
|
+
console.print(Panel(summary_table, title="[bold green]Configuration Summary[/bold green]", expand=False))
|
|
58
|
+
|
|
59
|
+
def manage_favorites():
|
|
60
|
+
while True:
|
|
61
|
+
console.clear()
|
|
62
|
+
console.print(Panel("[bold magenta]Manage Favorites & Filters[/bold magenta]", border_style="magenta"))
|
|
63
|
+
console.print("1. [cyan]Manage Authors[/cyan]")
|
|
64
|
+
console.print("2. [green]Manage Publications[/green]")
|
|
65
|
+
console.print("3. [yellow]Manage Keywords[/yellow]")
|
|
66
|
+
console.print("4. [white]Back to Main Menu[/white]")
|
|
67
|
+
|
|
68
|
+
choice = Prompt.ask("Select category", choices=["1", "2", "3", "4"])
|
|
69
|
+
|
|
70
|
+
if choice == "1":
|
|
71
|
+
manage_list("authors")
|
|
72
|
+
elif choice == "2":
|
|
73
|
+
manage_list("publications")
|
|
74
|
+
elif choice == "3":
|
|
75
|
+
manage_keywords()
|
|
76
|
+
else:
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
def manage_list(type_name):
|
|
80
|
+
while True:
|
|
81
|
+
console.clear()
|
|
82
|
+
console.print(Panel(f"[bold]Manage {type_name.title()}[/bold]", border_style="blue"))
|
|
83
|
+
|
|
84
|
+
items = storage.get_authors() if type_name == "authors" else storage.get_publications()
|
|
85
|
+
if items:
|
|
86
|
+
for idx, item in enumerate(items, 1):
|
|
87
|
+
last_date = storage.get_last_access(item, type=type_name)
|
|
88
|
+
date_str = last_date.strftime("%Y-%m-%d") if last_date else "Never"
|
|
89
|
+
console.print(f"{idx}. {item} [dim](Last: {date_str})[/dim]")
|
|
90
|
+
else:
|
|
91
|
+
console.print("[dim]Empty list.[/dim]")
|
|
92
|
+
|
|
93
|
+
console.print("\na. [green]Add[/green] | r. [red]Remove[/red] | b. [yellow]Back[/yellow]")
|
|
94
|
+
action = Prompt.ask("Action", choices=["a", "r", "b"])
|
|
95
|
+
|
|
96
|
+
if action == "a":
|
|
97
|
+
new_item = Prompt.ask(f"Enter {type_name} name")
|
|
98
|
+
if type_name == "authors":
|
|
99
|
+
storage.add_author(new_item)
|
|
100
|
+
else:
|
|
101
|
+
storage.add_publication(new_item)
|
|
102
|
+
console.print(f"[green]Added {new_item}[/green]")
|
|
103
|
+
elif action == "r":
|
|
104
|
+
if not items: continue
|
|
105
|
+
idx_to_rem = IntPrompt.ask("Enter number to remove", choices=[str(i) for i in range(1, len(items)+1)])
|
|
106
|
+
item_to_rem = items[idx_to_rem-1]
|
|
107
|
+
if type_name == "authors":
|
|
108
|
+
storage.remove_author(item_to_rem)
|
|
109
|
+
else:
|
|
110
|
+
storage.remove_publication(item_to_rem)
|
|
111
|
+
console.print(f"[red]Removed {item_to_rem}[/red]")
|
|
112
|
+
else:
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
def manage_keywords():
|
|
116
|
+
while True:
|
|
117
|
+
console.clear()
|
|
118
|
+
console.print(Panel("[bold yellow]Manage Keyword Filters[/bold yellow]", border_style="yellow"))
|
|
119
|
+
|
|
120
|
+
keywords = storage.get_keywords()
|
|
121
|
+
if keywords:
|
|
122
|
+
for idx, kw in enumerate(keywords, 1):
|
|
123
|
+
console.print(f"{idx}. {kw}")
|
|
124
|
+
else:
|
|
125
|
+
console.print("[dim]No keywords defined. Results will not be filtered.[/dim]")
|
|
126
|
+
|
|
127
|
+
console.print("\na. [green]Add[/green] | r. [red]Remove[/red] | b. [yellow]Back[/yellow]")
|
|
128
|
+
action = Prompt.ask("Action", choices=["a", "r", "b"])
|
|
129
|
+
|
|
130
|
+
if action == "a":
|
|
131
|
+
new_kw = Prompt.ask("Enter keyword to track")
|
|
132
|
+
storage.add_keyword(new_kw)
|
|
133
|
+
elif action == "r":
|
|
134
|
+
if not keywords: continue
|
|
135
|
+
idx_to_rem = IntPrompt.ask("Enter number to remove", choices=[str(i) for i in range(1, len(keywords)+1)])
|
|
136
|
+
storage.remove_keyword(keywords[idx_to_rem-1])
|
|
137
|
+
else:
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
def handle_updates(apply_keywords=False):
|
|
141
|
+
authors = storage.get_authors()
|
|
142
|
+
pubs = storage.get_publications()
|
|
143
|
+
keywords = storage.get_keywords() if apply_keywords else []
|
|
144
|
+
|
|
145
|
+
if not authors and not pubs:
|
|
146
|
+
console.print("[yellow]Please add some favorite authors or publications first![/yellow]")
|
|
147
|
+
input("\nPress Enter...")
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
if apply_keywords and not keywords:
|
|
151
|
+
console.print("[yellow]No keywords defined. Please add keywords in 'Manage Favorites' first.[/yellow]")
|
|
152
|
+
input("\nPress Enter...")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
console.clear()
|
|
156
|
+
title_text = "Keyword Hits" if apply_keywords else "Full Updates Feed"
|
|
157
|
+
console.print(Panel(f"[bold green]{title_text}[/bold green]", border_style="green"))
|
|
158
|
+
|
|
159
|
+
use_last = Prompt.ask("Check since last visit?", choices=["y", "n"], default="y")
|
|
160
|
+
fetch_limit = IntPrompt.ask("History depth (1-10 stories per source)", default=5)
|
|
161
|
+
fetch_limit = max(1, min(10, fetch_limit))
|
|
162
|
+
|
|
163
|
+
limit_date = None
|
|
164
|
+
if use_last == "n":
|
|
165
|
+
from datetime import timedelta
|
|
166
|
+
default_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
|
|
167
|
+
date_input = Prompt.ask(f"Check stories after date (YYYY-MM-DD)", default=default_date)
|
|
168
|
+
try:
|
|
169
|
+
limit_date = datetime.strptime(date_input, "%Y-%m-%d")
|
|
170
|
+
except ValueError:
|
|
171
|
+
limit_date = datetime.now() - timedelta(days=7)
|
|
172
|
+
|
|
173
|
+
all_updates = []
|
|
174
|
+
|
|
175
|
+
with console.status("[bold green]Fetching new stories..."):
|
|
176
|
+
# Authors
|
|
177
|
+
for auth in authors:
|
|
178
|
+
l_date = limit_date or storage.get_last_access(auth, type="authors") or datetime(2026, 1, 1)
|
|
179
|
+
tracker = UpdatesTracker([auth], target_type="authors")
|
|
180
|
+
all_updates.extend(tracker.get_updates_after(l_date, update_timestamp=True, limit=fetch_limit, keywords=keywords))
|
|
181
|
+
|
|
182
|
+
# Publications
|
|
183
|
+
for pub in pubs:
|
|
184
|
+
l_date = limit_date or storage.get_last_access(pub, type="publications") or datetime(2026, 1, 1)
|
|
185
|
+
tracker = UpdatesTracker([pub], target_type="publications")
|
|
186
|
+
all_updates.extend(tracker.get_updates_after(l_date, update_timestamp=True, limit=fetch_limit, keywords=keywords))
|
|
187
|
+
|
|
188
|
+
if not all_updates:
|
|
189
|
+
console.print(f"\n[yellow]No new stories found matching your {('keyword ' if apply_keywords else '')}criteria.[/yellow]")
|
|
190
|
+
input("\nPress Enter to return to menu...")
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
all_updates.sort(key=lambda x: x.pub_date, reverse=True)
|
|
194
|
+
|
|
195
|
+
console.print(f"\n[bold green]{title_text} Results:[/bold green]\n")
|
|
196
|
+
|
|
197
|
+
for idx, art in enumerate(all_updates, 1):
|
|
198
|
+
# Header line: Date | Source | Title
|
|
199
|
+
header = f"[blue]{art.pub_date.strftime('%b %d')}[/blue] | [green]{art.author[:15]}[/green] | [white bold]{art.title}[/white bold]"
|
|
200
|
+
|
|
201
|
+
# Link line: Printed clearly for terminal clickability
|
|
202
|
+
link_line = f"[cyan underline]{art.link}[/cyan underline]"
|
|
203
|
+
|
|
204
|
+
# We use a simple vertical layout which is MUCH safer for links than a table
|
|
205
|
+
console.print(header)
|
|
206
|
+
console.print(link_line, soft_wrap=True)
|
|
207
|
+
console.print("") # Spacer
|
|
208
|
+
|
|
209
|
+
console.print(f"[dim]Found {len(all_updates)} stories. Timestamps updated.[/dim]")
|
|
210
|
+
input("\nPress Enter to return to menu...")
|
|
211
|
+
|
|
212
|
+
def main():
|
|
213
|
+
while True:
|
|
214
|
+
show_home()
|
|
215
|
+
console.print("\n[bold]Choose an option:[/bold]")
|
|
216
|
+
console.print("1. [green]Full Updates Feed[/green] (Check All Favorites)")
|
|
217
|
+
console.print("2. [bold yellow]Keyword Hits[/bold yellow] (High-Signal Filtered Feed)")
|
|
218
|
+
console.print("3. [magenta]Manage Favorites[/magenta] (Authors, Pubs, Keywords)")
|
|
219
|
+
console.print("4. [dim]Exit[/dim]")
|
|
220
|
+
|
|
221
|
+
choice = Prompt.ask("Choice", choices=["1", "2", "3", "4"])
|
|
222
|
+
|
|
223
|
+
if choice == "1":
|
|
224
|
+
handle_updates(apply_keywords=False)
|
|
225
|
+
elif choice == "2":
|
|
226
|
+
handle_updates(apply_keywords=True)
|
|
227
|
+
elif choice == "3":
|
|
228
|
+
manage_favorites()
|
|
229
|
+
elif choice == "4":
|
|
230
|
+
console.print("[yellow]Goodbye![/yellow]")
|
|
231
|
+
break
|
|
232
|
+
|
|
233
|
+
if __name__ == "__main__":
|
|
234
|
+
try:
|
|
235
|
+
main()
|
|
236
|
+
except KeyboardInterrupt:
|
|
237
|
+
console.print("\n[yellow]Interrupted. Exiting...[/yellow]")
|
|
238
|
+
sys.exit(0)
|
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Article:
|
|
7
|
+
title: str
|
|
8
|
+
link: str
|
|
9
|
+
author: str
|
|
10
|
+
pub_date: datetime
|
|
11
|
+
claps: int = 0
|
|
12
|
+
reading_time: float = 0.0
|
|
13
|
+
content: str = ""
|
|
14
|
+
summary: str = ""
|
|
15
|
+
|
|
16
|
+
def __str__(self):
|
|
17
|
+
return f"{self.title} by {self.author} ({self.claps} claps, {self.reading_time:.1f} min read)"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import List
|
|
4
|
+
import xml.etree.ElementTree as ET
|
|
5
|
+
from shared.models import Article
|
|
6
|
+
|
|
7
|
+
class MediumScraper:
|
|
8
|
+
AUTHOR_RSS_TEMPLATE = "https://medium.com/feed/@{author}"
|
|
9
|
+
PUBLICATION_RSS_TEMPLATE = "https://medium.com/feed/{pub}"
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.session = requests.Session()
|
|
13
|
+
self.session.headers.update({
|
|
14
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
def fetch_articles_from_author(self, author: str, limit: int = 10) -> List[Article]:
|
|
18
|
+
author = author.lstrip("@")
|
|
19
|
+
url = self.AUTHOR_RSS_TEMPLATE.format(author=author)
|
|
20
|
+
return self._fetch_from_rss(url, limit)
|
|
21
|
+
|
|
22
|
+
def fetch_articles_from_publication(self, pub: str, limit: int = 10) -> List[Article]:
|
|
23
|
+
url = self.PUBLICATION_RSS_TEMPLATE.format(pub=pub)
|
|
24
|
+
return self._fetch_from_rss(url, limit)
|
|
25
|
+
|
|
26
|
+
def _fetch_from_rss(self, url: str, limit: int = 10) -> List[Article]:
|
|
27
|
+
try:
|
|
28
|
+
response = self.session.get(url, timeout=10)
|
|
29
|
+
if response.status_code != 200:
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
root = ET.fromstring(response.content)
|
|
33
|
+
articles = []
|
|
34
|
+
for item in root.findall(".//item")[:limit]:
|
|
35
|
+
title_elem = item.find("title")
|
|
36
|
+
link_elem = item.find("link")
|
|
37
|
+
if title_elem is None or link_elem is None: continue
|
|
38
|
+
|
|
39
|
+
title = title_elem.text
|
|
40
|
+
link = link_elem.text
|
|
41
|
+
|
|
42
|
+
author_elem = item.find("{http://purl.org/dc/elements/1.1/}creator")
|
|
43
|
+
author_name = author_elem.text if author_elem is not None else "Unknown"
|
|
44
|
+
|
|
45
|
+
pub_date_str = item.find("pubDate").text
|
|
46
|
+
try:
|
|
47
|
+
pub_date = datetime.strptime(pub_date_str, "%a, %d %b %Y %H:%M:%S %Z")
|
|
48
|
+
except ValueError:
|
|
49
|
+
pub_date = datetime.now()
|
|
50
|
+
|
|
51
|
+
articles.append(Article(
|
|
52
|
+
title=title,
|
|
53
|
+
link=link,
|
|
54
|
+
author=author_name,
|
|
55
|
+
pub_date=pub_date
|
|
56
|
+
))
|
|
57
|
+
return articles
|
|
58
|
+
except Exception:
|
|
59
|
+
return []
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
class Storage:
|
|
7
|
+
def __init__(self, filename: str = "favorites.json"):
|
|
8
|
+
# Put it in a persistent user directory
|
|
9
|
+
config_dir = os.path.expanduser("~/.medium_no_bait")
|
|
10
|
+
if not os.path.exists(config_dir):
|
|
11
|
+
os.makedirs(config_dir)
|
|
12
|
+
self.filepath = os.path.join(config_dir, filename)
|
|
13
|
+
self.data = self._load()
|
|
14
|
+
|
|
15
|
+
def _load(self) -> Dict:
|
|
16
|
+
if os.path.exists(self.filepath):
|
|
17
|
+
try:
|
|
18
|
+
with open(self.filepath, "r") as f:
|
|
19
|
+
data = json.load(f)
|
|
20
|
+
# Ensure all keys exist for older versions
|
|
21
|
+
if "authors" not in data: data["authors"] = {}
|
|
22
|
+
if "publications" not in data: data["publications"] = {}
|
|
23
|
+
if "keywords" not in data: data["keywords"] = []
|
|
24
|
+
return data
|
|
25
|
+
except Exception:
|
|
26
|
+
return {"authors": {}, "publications": {}, "keywords": []}
|
|
27
|
+
return {"authors": {}, "publications": {}, "keywords": []}
|
|
28
|
+
|
|
29
|
+
def save(self):
|
|
30
|
+
with open(self.filepath, "w") as f:
|
|
31
|
+
json.dump(self.data, f, indent=4)
|
|
32
|
+
|
|
33
|
+
# Authors
|
|
34
|
+
def get_authors(self) -> List[str]:
|
|
35
|
+
return list(self.data.get("authors", {}).keys())
|
|
36
|
+
|
|
37
|
+
def get_last_access(self, target: str, type: str = "authors") -> Optional[datetime]:
|
|
38
|
+
target = target.lstrip("@")
|
|
39
|
+
ts = self.data.get(type, {}).get(target)
|
|
40
|
+
if ts:
|
|
41
|
+
return datetime.fromisoformat(ts)
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def add_author(self, author: str):
|
|
45
|
+
author = author.lstrip("@")
|
|
46
|
+
if author not in self.data["authors"]:
|
|
47
|
+
self.data["authors"][author] = datetime(2026, 2, 1).isoformat()
|
|
48
|
+
self.save()
|
|
49
|
+
|
|
50
|
+
def remove_author(self, author: str):
|
|
51
|
+
author = author.lstrip("@")
|
|
52
|
+
if author in self.data["authors"]:
|
|
53
|
+
del self.data["authors"][author]
|
|
54
|
+
self.save()
|
|
55
|
+
|
|
56
|
+
# Publications
|
|
57
|
+
def get_publications(self) -> List[str]:
|
|
58
|
+
return list(self.data.get("publications", {}).keys())
|
|
59
|
+
|
|
60
|
+
def add_publication(self, pub: str):
|
|
61
|
+
if pub not in self.data["publications"]:
|
|
62
|
+
self.data["publications"][pub] = datetime(2026, 2, 1).isoformat()
|
|
63
|
+
self.save()
|
|
64
|
+
|
|
65
|
+
def remove_publication(self, pub: str):
|
|
66
|
+
if pub in self.data["publications"]:
|
|
67
|
+
del self.data["publications"][pub]
|
|
68
|
+
self.save()
|
|
69
|
+
|
|
70
|
+
# Keywords
|
|
71
|
+
def get_keywords(self) -> List[str]:
|
|
72
|
+
return self.data.get("keywords", [])
|
|
73
|
+
|
|
74
|
+
def add_keyword(self, word: str):
|
|
75
|
+
if word not in self.data["keywords"]:
|
|
76
|
+
self.data["keywords"].append(word)
|
|
77
|
+
self.save()
|
|
78
|
+
|
|
79
|
+
def remove_keyword(self, word: str):
|
|
80
|
+
if word in self.data["keywords"]:
|
|
81
|
+
self.data["keywords"].remove(word)
|
|
82
|
+
self.save()
|
|
83
|
+
|
|
84
|
+
def update_last_access(self, target: str, type: str = "authors", dt: Optional[datetime] = None):
|
|
85
|
+
target = target.lstrip("@")
|
|
86
|
+
if target in self.data.get(type, {}):
|
|
87
|
+
dt = dt or datetime.now()
|
|
88
|
+
self.data[type][target] = dt.isoformat()
|
|
89
|
+
self.save()
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
s = Storage()
|
|
93
|
+
s.add_author("@shashwatwrites")
|
|
94
|
+
print(f"Authors: {s.get_authors()}")
|
|
95
|
+
print(f"Last access: {s.get_last_access('shashwatwrites')}")
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: medium-no-bait-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A distillation-first, no-bait Medium reader for the terminal
|
|
5
|
+
Author-email: Shashwat <your-email@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/your-username/best-medium-reader
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/your-username/best-medium-reader/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Requires-Python: >=3.8
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: requests>=2.31.0
|
|
16
|
+
Requires-Dist: rich>=13.7.0
|
|
17
|
+
Requires-Dist: beautifulsoup4>=4.12.3
|
|
18
|
+
Requires-Dist: lxml>=5.1.0
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# Medium No-Bait CLI 🚀
|
|
22
|
+
|
|
23
|
+
A high-performance, terminal-based Medium reader designed for developers who value signal over noise. Track your favorite authors and publications, and catch the most relevant stories without the "clap-bait" and social distractions.
|
|
24
|
+
|
|
25
|
+
## Why it exists?
|
|
26
|
+
On Medium, the home feed is an algorithm designed to keep you scrolling. **Medium No-Bait CLI** gives you a strictly curated, high-signal experience that only shows you what YOU chose to follow.
|
|
27
|
+
|
|
28
|
+
- **Zero Distractions**: No ads, no claps, no social pressure.
|
|
29
|
+
- **Keyword Hits**: Catch the most relevant stories using custom filters (e.g., AI, Python).
|
|
30
|
+
- **RSS-Powered**: Lightning-fast update checks.
|
|
31
|
+
- **Terminal First**: Clean, full links that are 100% clickable for your browser.
|
|
32
|
+
|
|
33
|
+
## Quick Start
|
|
34
|
+
|
|
35
|
+
### Installation
|
|
36
|
+
1. Clone the repository:
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/your-username/best-medium-reader.git
|
|
39
|
+
cd best-medium-reader
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
2. Install the package locally:
|
|
43
|
+
```bash
|
|
44
|
+
pip install -e .
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Running the App
|
|
48
|
+
Once installed, you can simply type:
|
|
49
|
+
```bash
|
|
50
|
+
mnb
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## How It Works
|
|
54
|
+
|
|
55
|
+
1. **Manage Favorites**: Add authors (use `@username`) and publications (use the pub name from the URL, e.g., `the-startup`).
|
|
56
|
+
2. **Setup Keywords**: Add specific keywords you want to track across your favorites.
|
|
57
|
+
3. **Enjoy Signal**: Use the **Keyword Hits** view to see exactly what you care about.
|
|
58
|
+
|
|
59
|
+
## Project Structure
|
|
60
|
+
- `src/medium_no_bait/main.py`: The central terminal interface.
|
|
61
|
+
- `src/medium_no_bait/author_updates/`: Logic for tracking and filtering updates.
|
|
62
|
+
- `src/medium_no_bait/shared/`: Shared tools (RSS Scraper, Storage).
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
Built with 💙 for the Medium developer community.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/medium_no_bait/__init__.py
|
|
5
|
+
src/medium_no_bait/main.py
|
|
6
|
+
src/medium_no_bait/author_updates/__init__.py
|
|
7
|
+
src/medium_no_bait/author_updates/tracker.py
|
|
8
|
+
src/medium_no_bait/shared/__init__.py
|
|
9
|
+
src/medium_no_bait/shared/models.py
|
|
10
|
+
src/medium_no_bait/shared/scraper.py
|
|
11
|
+
src/medium_no_bait/shared/storage.py
|
|
12
|
+
src/medium_no_bait_cli.egg-info/PKG-INFO
|
|
13
|
+
src/medium_no_bait_cli.egg-info/SOURCES.txt
|
|
14
|
+
src/medium_no_bait_cli.egg-info/dependency_links.txt
|
|
15
|
+
src/medium_no_bait_cli.egg-info/entry_points.txt
|
|
16
|
+
src/medium_no_bait_cli.egg-info/requires.txt
|
|
17
|
+
src/medium_no_bait_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
medium_no_bait
|