Ctrl K
ComunidadGuías…

filters.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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