Rewrite Miniflux plugin to use requests, add filter and bookmark tools

Drop the miniflux pip client in favour of requests (already in the
container). Add update_feed_filters (keeplist/blocklist regex),
toggle_bookmark, get_entry (full content), and category filtering.
Remove the pip install step from Ansible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Louis Simoneau
2026-04-10 16:45:46 +10:00
parent 9b83d56932
commit 66b0588f52
5 changed files with 209 additions and 25 deletions

View File

@@ -399,6 +399,7 @@
chdir: /opt/hermes chdir: /opt/hermes
tags: hermes tags: hermes
# ── GoatCounter ───────────────────────────────────────────────────────── # ── GoatCounter ─────────────────────────────────────────────────────────
- name: Create goatcounter system user - name: Create goatcounter system user

View File

@@ -14,3 +14,27 @@ def register(ctx):
schema=schemas.GET_UNREAD_ENTRIES, schema=schemas.GET_UNREAD_ENTRIES,
handler=tools.get_unread_entries, handler=tools.get_unread_entries,
) )
ctx.register_tool(
name="get_entry",
toolset="miniflux",
schema=schemas.GET_ENTRY,
handler=tools.get_entry,
)
ctx.register_tool(
name="toggle_bookmark",
toolset="miniflux",
schema=schemas.TOGGLE_BOOKMARK,
handler=tools.toggle_bookmark,
)
ctx.register_tool(
name="update_feed_filters",
toolset="miniflux",
schema=schemas.UPDATE_FEED_FILTERS,
handler=tools.update_feed_filters,
)
ctx.register_tool(
name="mark_as_read",
toolset="miniflux",
schema=schemas.MARK_AS_READ,
handler=tools.mark_as_read,
)

View File

@@ -1,6 +1,10 @@
name: miniflux name: miniflux
version: 1.0.0 version: 2.0.0
description: Read feeds and entries from the local Miniflux RSS reader description: Read and manage feeds and entries from the local Miniflux RSS reader
provides_tools: provides_tools:
- list_feeds - list_feeds
- get_unread_entries - get_unread_entries
- get_entry
- toggle_bookmark
- update_feed_filters
- mark_as_read

View File

@@ -24,6 +24,10 @@ GET_UNREAD_ENTRIES = {
"type": "integer", "type": "integer",
"description": "Filter to a specific feed. Omit for all feeds.", "description": "Filter to a specific feed. Omit for all feeds.",
}, },
"category_id": {
"type": "integer",
"description": "Filter to a specific category. Omit for all categories.",
},
"limit": { "limit": {
"type": "integer", "type": "integer",
"description": "Maximum number of entries to return. Defaults to 20.", "description": "Maximum number of entries to return. Defaults to 20.",
@@ -32,3 +36,81 @@ GET_UNREAD_ENTRIES = {
"required": [], "required": [],
}, },
} }
GET_ENTRY = {
"name": "get_entry",
"description": (
"Get a single entry from Miniflux by ID, including its full content. "
"Use this to read an article's text."
),
"parameters": {
"type": "object",
"properties": {
"entry_id": {
"type": "integer",
"description": "The entry ID to retrieve.",
},
},
"required": ["entry_id"],
},
}
TOGGLE_BOOKMARK = {
"name": "toggle_bookmark",
"description": "Toggle the bookmark/star status of a Miniflux entry.",
"parameters": {
"type": "object",
"properties": {
"entry_id": {
"type": "integer",
"description": "The entry ID to bookmark or unbookmark.",
},
},
"required": ["entry_id"],
},
}
UPDATE_FEED_FILTERS = {
"name": "update_feed_filters",
"description": (
"Update the keep or block filter rules on a Miniflux feed. "
"Rules are case-insensitive regexes matched against entry titles and URLs. "
"keeplist_rules: only entries matching are kept. "
"blocklist_rules: entries matching are excluded. "
"Pass an empty string to clear a rule."
),
"parameters": {
"type": "object",
"properties": {
"feed_id": {
"type": "integer",
"description": "The feed ID to update.",
},
"keeplist_rules": {
"type": "string",
"description": "Regex pattern. Only matching entries are kept. Omit to leave unchanged.",
},
"blocklist_rules": {
"type": "string",
"description": "Regex pattern. Matching entries are excluded. Omit to leave unchanged.",
},
},
"required": ["feed_id"],
},
}
MARK_AS_READ = {
"name": "mark_as_read",
"description": "Mark one or more Miniflux entries as read.",
"parameters": {
"type": "object",
"properties": {
"entry_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "List of entry IDs to mark as read.",
},
},
"required": ["entry_ids"],
},
}

View File

@@ -1,39 +1,42 @@
import json import json
import urllib.request
import urllib.error
from pathlib import Path from pathlib import Path
import requests
_PLUGIN_DIR = Path(__file__).parent _PLUGIN_DIR = Path(__file__).parent
with open(_PLUGIN_DIR / "config.json") as _f: with open(_PLUGIN_DIR / "config.json") as _f:
_CONFIG = json.loads(_f.read()) _CONFIG = json.loads(_f.read())
MINIFLUX_BASE = _CONFIG.get("base_url", "http://miniflux:8080") _BASE = _CONFIG.get("base_url", "http://miniflux:8080").rstrip("/")
MINIFLUX_KEY = _CONFIG.get("api_key", "") _HEADERS = {"X-Auth-Token": _CONFIG.get("api_key", "")}
def _request(path): def _get(path, **params):
"""Make an authenticated GET request to the Miniflux API.""" resp = requests.get(f"{_BASE}/v1{path}", headers=_HEADERS, params=params, timeout=10)
url = f"{MINIFLUX_BASE}/v1{path}" resp.raise_for_status()
req = urllib.request.Request(url, headers={"X-Auth-Token": MINIFLUX_KEY}) return resp.json()
with urllib.request.urlopen(req, timeout=10) as resp:
return json.loads(resp.read())
def _put(path, body):
resp = requests.put(f"{_BASE}/v1{path}", headers=_HEADERS, json=body, timeout=10)
resp.raise_for_status()
return resp
def list_feeds(args: dict, **kwargs) -> str: def list_feeds(args: dict, **kwargs) -> str:
try: try:
feeds = _request("/feeds") feeds = _get("/feeds")
counters = _request("/feeds/counters") counters = _get("/feeds/counters")
unreads = counters.get("reads", {}) # keyed by feed id unreads = counters.get("unreads", {})
unread_map = counters.get("unreads", {})
result = [] result = []
for f in feeds: for f in feeds:
fid = str(f["id"])
result.append({ result.append({
"id": f["id"], "id": f["id"],
"title": f["title"], "title": f["title"],
"site_url": f.get("site_url", ""), "site_url": f.get("site_url", ""),
"unread": unread_map.get(fid, 0), "category": f.get("category", {}).get("title", ""),
"unread": unreads.get(str(f["id"]), 0),
}) })
result.sort(key=lambda x: x["unread"], reverse=True) result.sort(key=lambda x: x["unread"], reverse=True)
return json.dumps({"feeds": result, "total": len(result)}) return json.dumps({"feeds": result, "total": len(result)})
@@ -43,15 +46,20 @@ def list_feeds(args: dict, **kwargs) -> str:
def get_unread_entries(args: dict, **kwargs) -> str: def get_unread_entries(args: dict, **kwargs) -> str:
try: try:
limit = args.get("limit", 20) params = {
feed_id = args.get("feed_id") "status": "unread",
"limit": args.get("limit", 20),
if feed_id: "direction": "desc",
path = f"/feeds/{feed_id}/entries?status=unread&limit={limit}&direction=desc&order=published_at" "order": "published_at",
}
if args.get("feed_id"):
path = f"/feeds/{args['feed_id']}/entries"
elif args.get("category_id"):
path = f"/categories/{args['category_id']}/entries"
else: else:
path = f"/entries?status=unread&limit={limit}&direction=desc&order=published_at" path = "/entries"
data = _request(path) data = _get(path, **params)
entries = [] entries = []
for e in data.get("entries", []): for e in data.get("entries", []):
entries.append({ entries.append({
@@ -59,6 +67,7 @@ def get_unread_entries(args: dict, **kwargs) -> str:
"title": e["title"], "title": e["title"],
"url": e.get("url", ""), "url": e.get("url", ""),
"feed": e.get("feed", {}).get("title", ""), "feed": e.get("feed", {}).get("title", ""),
"category": e.get("feed", {}).get("category", {}).get("title", ""),
"author": e.get("author", ""), "author": e.get("author", ""),
"published_at": e.get("published_at", ""), "published_at": e.get("published_at", ""),
"reading_time": e.get("reading_time", 0), "reading_time": e.get("reading_time", 0),
@@ -69,3 +78,67 @@ def get_unread_entries(args: dict, **kwargs) -> str:
}) })
except Exception as e: except Exception as e:
return json.dumps({"error": str(e)}) return json.dumps({"error": str(e)})
def get_entry(args: dict, **kwargs) -> str:
try:
entry = _get(f"/entries/{args['entry_id']}")
return json.dumps({
"id": entry["id"],
"title": entry["title"],
"url": entry.get("url", ""),
"author": entry.get("author", ""),
"feed": entry.get("feed", {}).get("title", ""),
"category": entry.get("feed", {}).get("category", {}).get("title", ""),
"published_at": entry.get("published_at", ""),
"reading_time": entry.get("reading_time", 0),
"content": entry.get("content", ""),
})
except Exception as e:
return json.dumps({"error": str(e)})
def toggle_bookmark(args: dict, **kwargs) -> str:
try:
_put(f"/entries/{args['entry_id']}/bookmark", {})
return json.dumps({"ok": True, "entry_id": args["entry_id"]})
except Exception as e:
return json.dumps({"error": str(e)})
def update_feed_filters(args: dict, **kwargs) -> str:
try:
feed_id = args["feed_id"]
body = {}
if "keeplist_rules" in args:
body["keeplist_rules"] = args["keeplist_rules"]
if "blocklist_rules" in args:
body["blocklist_rules"] = args["blocklist_rules"]
if not body:
return json.dumps({"error": "Provide keeplist_rules and/or blocklist_rules"})
resp = requests.put(
f"{_BASE}/v1/feeds/{feed_id}",
headers=_HEADERS, json=body, timeout=10,
)
resp.raise_for_status()
feed = resp.json()
return json.dumps({
"ok": True,
"feed_id": feed["id"],
"title": feed["title"],
"keeplist_rules": feed.get("keeplist_rules", ""),
"blocklist_rules": feed.get("blocklist_rules", ""),
})
except Exception as e:
return json.dumps({"error": str(e)})
def mark_as_read(args: dict, **kwargs) -> str:
try:
entry_ids = args.get("entry_ids", [])
if not entry_ids:
return json.dumps({"error": "No entry_ids provided"})
_put("/entries", {"entry_ids": entry_ids, "status": "read"})
return json.dumps({"ok": True, "marked_read": entry_ids})
except Exception as e:
return json.dumps({"error": str(e)})