filters.py
Rama master · réplica de lectura del repositorio original.
"""Keyword filters for echo delivery.
Filters are comma-separated substrings, matched case-insensitively against an
item's title and summary/description. Two modes:
- exclude (default): item is skipped when ANY keyword matches
- include: item is skipped UNLESS at least one keyword matches
(an empty include filter matches everything)
"""
from __future__ import annotations
import re
# Field-scoped clause: "tag:posse", "author:jane", "url:/notes/", "link:...",
# "content:...". Case-insensitive on the PREFIX (users type Tag:/URL:/Author:
# freely); the VALUE keeps its original case and is compared casefolded
# downstream. Unknown prefixes (e.g. a legacy keyword like "release:2") stay
# bare keywords; a recognized prefix with an empty value ("tag:") also falls
# back to a bare keyword (legacy safety).
_FIELD_CLAUSE_RE = re.compile(r"^(tag|author|url|link|content):(.+)$", re.IGNORECASE)
def split_clause(part: str) -> tuple[str | None, str]:
"""Split a parsed keyword into a (field, value) clause or a bare keyword.
Returns ``(field, value)`` for a recognized field-scoped clause (with a
non-empty value; the field name is lowercased), or ``(None, part)`` for
a bare keyword.
"""
m = _FIELD_CLAUSE_RE.match(part)
if m and m.group(2).strip():
return m.group(1).lower(), m.group(2).strip()
return None, part
def parse_keywords(raw: str | None) -> list[str]:
"""Split a comma-separated filter string into trimmed, non-empty keywords."""
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _item_text(item: dict) -> str:
parts = [
item.get("title") or "",
item.get("summary") or "",
item.get("description") or "",
]
return " ".join(parts).casefold()
def _field_text(item: dict, field: str) -> str:
"""Casefolded haystack for a field-scoped clause.
NOTE: ``content:`` searches ``content`` and ``content_text`` only;
``content_html`` is deliberately NOT searched — its sanitized markup
(tag names, attribute soup) can false-positive on ordinary keywords.
"""
if field == "tag":
tags_raw = item.get("tags") or []
if isinstance(tags_raw, str):
# Defensive: a malformed feed can deliver tags as a bare string;
# treat it as one tag rather than iterating its characters.
tags_raw = [tags_raw]
tags = [str(t) for t in tags_raw if t]
categories = item.get("categories")
if isinstance(categories, dict):
tags.extend(str(v) for v in categories.values() if v)
return " ".join(tags).casefold()
if field == "author":
return (item.get("author") or "").casefold()
if field in ("url", "link"):
return (item.get("link") or "").casefold()
# content
parts = [item.get("content") or "", item.get("content_text") or ""]
return " ".join(parts).casefold()
def _clause_matched(item: dict, field: str | None, value: str, bare_text: str) -> bool:
if field is None:
return value.casefold() in bare_text
return value.casefold() in _field_text(item, field)
def match_reason(item: dict, keywords: str | None, mode: str | None) -> str | None:
"""Return a human-readable explanation if the item should be filtered, or None if kept.
``keywords`` is the raw comma-separated string stored on the echo.
``mode`` is 'exclude' or 'include' (anything else is treated as exclude).
Keywords may be field-scoped (``tag:``, ``author:``, ``url:``/``link:``,
``content:``); anything else matches the item's title/summary/description.
"""
parsed = parse_keywords(keywords)
if not parsed:
return None
text = _item_text(item)
clauses = [split_clause(kw) for kw in parsed]
matched = [clause for clause in clauses if _clause_matched(item, clause[0], clause[1], text)]
if mode == "include":
if not matched:
return "no include keyword matched"
return None
if matched:
field, value = matched[0]
if field:
return f'matched {field} "{value}"'
return f'matched "{value}"'
return None
def is_filtered(item: dict, keywords: str | None, mode: str | None) -> bool:
"""Return True when the item should NOT be delivered for this filter.
``keywords`` is the raw comma-separated string stored on the echo.
``mode`` is 'exclude' or 'include' (anything else is treated as exclude).
"""
return match_reason(item, keywords, mode) is not None