newswatcher 0.1.0__py3-none-any.whl
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.
- newswatcher/__init__.py +13 -0
- newswatcher/__main__.py +10 -0
- newswatcher/_atomic.py +36 -0
- newswatcher/_llm.py +82 -0
- newswatcher/_select.py +36 -0
- newswatcher/_toml.py +54 -0
- newswatcher/body.py +47 -0
- newswatcher/cli.py +551 -0
- newswatcher/config.py +217 -0
- newswatcher/crawl.py +104 -0
- newswatcher/credentials.py +286 -0
- newswatcher/digest.py +257 -0
- newswatcher/errors.py +68 -0
- newswatcher/feed.py +142 -0
- newswatcher/heal.py +195 -0
- newswatcher/http.py +130 -0
- newswatcher/lock.py +78 -0
- newswatcher/match.py +68 -0
- newswatcher/poll.py +124 -0
- newswatcher/py.typed +0 -0
- newswatcher/robots.py +88 -0
- newswatcher/schedule.py +330 -0
- newswatcher/sources.py +197 -0
- newswatcher/state.py +148 -0
- newswatcher/store.py +173 -0
- newswatcher/stories.py +110 -0
- newswatcher/summarize.py +61 -0
- newswatcher/topics.py +105 -0
- newswatcher-0.1.0.dist-info/METADATA +367 -0
- newswatcher-0.1.0.dist-info/RECORD +33 -0
- newswatcher-0.1.0.dist-info/WHEEL +4 -0
- newswatcher-0.1.0.dist-info/entry_points.txt +2 -0
- newswatcher-0.1.0.dist-info/licenses/LICENSE +21 -0
newswatcher/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""newswatcher: watch news sources (RSS or robots-permitted crawl), match topics,
|
|
2
|
+
summarize with an LLM, and mail a digest."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
|
|
8
|
+
__all__ = ["__version__"]
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("newswatcher")
|
|
12
|
+
except PackageNotFoundError: # not installed (e.g. run from a source tree)
|
|
13
|
+
__version__ = "0.0.0+unknown"
|
newswatcher/__main__.py
ADDED
newswatcher/_atomic.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Atomic file writes: temp file in the target directory, then rename over the
|
|
2
|
+
target, so a crash or a concurrent reader never sees a half-written file, and two
|
|
3
|
+
overlapping writers do not share a temp path."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
__all__ = ["write_text_atomic", "write_bytes_atomic"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_text_atomic(path: Path, text: str, error_cls: type[Exception]) -> None:
|
|
15
|
+
"""Write ``text`` to ``path`` atomically; wrap any I/O failure in ``error_cls``."""
|
|
16
|
+
write_bytes_atomic(path, text.encode("utf-8"), error_cls)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def write_bytes_atomic(path: Path, data: bytes, error_cls: type[Exception]) -> None:
|
|
20
|
+
"""Write ``data`` to ``path`` atomically; wrap any I/O failure in ``error_cls``."""
|
|
21
|
+
try:
|
|
22
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
|
|
24
|
+
tmp = Path(tmp_name)
|
|
25
|
+
try:
|
|
26
|
+
with os.fdopen(fd, "wb") as handle:
|
|
27
|
+
handle.write(data)
|
|
28
|
+
handle.flush()
|
|
29
|
+
os.fsync(handle.fileno()) # flush to disk before the rename, so a crash
|
|
30
|
+
# after os.replace cannot leave a half-written file
|
|
31
|
+
os.replace(tmp, path)
|
|
32
|
+
except OSError:
|
|
33
|
+
tmp.unlink(missing_ok=True)
|
|
34
|
+
raise
|
|
35
|
+
except OSError as err:
|
|
36
|
+
raise error_cls(f"could not write {path}: {err}") from err
|
newswatcher/_llm.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Build a thinchat LLM client, letting thinchat resolve the provider's key.
|
|
2
|
+
|
|
3
|
+
newswatcher's LLM tasks -- summarizing an article, repairing a broken crawl selector -- run on
|
|
4
|
+
the thinchat library, which speaks to every provider behind one interface. The key is thinchat's
|
|
5
|
+
to resolve: an explicit ``api_key`` override wins, then the provider's standard environment
|
|
6
|
+
variable, then thinchat's own store (``thinchat set <provider>``, which ``newswatcher setup``
|
|
7
|
+
drives). newswatcher keeps no key of its own -- summarizing is newswatcher's job, but the LLM
|
|
8
|
+
credential belongs with thinchat, the tool that speaks to the provider. Gemini's free tier is the
|
|
9
|
+
default backend for the light summary task.
|
|
10
|
+
|
|
11
|
+
thinchat scrubs any provider key from its own error messages and the exception chain beneath
|
|
12
|
+
them, so newswatcher interpolates a ``ThinchatError`` directly without re-redacting it."""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from thinchat import PROVIDERS, Client, get_api_key, make_client
|
|
17
|
+
from thinchat.errors import ThinchatError
|
|
18
|
+
from thinchat.keys import ENV_BY_PROVIDER
|
|
19
|
+
|
|
20
|
+
from newswatcher.errors import LLMError
|
|
21
|
+
|
|
22
|
+
__all__ = ["DEFAULT_PROVIDER", "PROVIDERS", "make_llm_client", "provider_key_name",
|
|
23
|
+
"validate_provider"]
|
|
24
|
+
|
|
25
|
+
DEFAULT_PROVIDER = "gemini"
|
|
26
|
+
_MAX_RETRIES = 6
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate_provider(provider: str) -> None:
|
|
30
|
+
"""Check that ``provider`` is a backend thinchat knows -- the single home for this check, so
|
|
31
|
+
the CLI's early validation and ``make_llm_client`` give the same message.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
LLMError: the provider is not a known backend.
|
|
35
|
+
"""
|
|
36
|
+
if provider not in PROVIDERS:
|
|
37
|
+
raise LLMError(
|
|
38
|
+
f"unknown LLM provider {provider!r}; choose one of {', '.join(sorted(PROVIDERS))}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def provider_key_name(provider: str) -> str | None:
|
|
42
|
+
"""The environment-variable / store name that holds ``provider``'s API key
|
|
43
|
+
(``GEMINI_API_KEY`` ...), or ``None`` for a keyless provider (ollama runs locally and needs
|
|
44
|
+
none). The single home for this mapping, so the CLI's ``set-key`` and ``make_llm_client``
|
|
45
|
+
agree on the name a key is stored under."""
|
|
46
|
+
return ENV_BY_PROVIDER.get(provider)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_llm_client(
|
|
50
|
+
provider: str = DEFAULT_PROVIDER, *, model: str | None = None,
|
|
51
|
+
api_key: str | None = None, max_tokens: int, action: str,
|
|
52
|
+
) -> Client:
|
|
53
|
+
"""Build the thinchat client for ``provider``. The key is resolved by thinchat -- ``api_key``
|
|
54
|
+
when given, else the provider's standard env var, else thinchat's own store; ``model``
|
|
55
|
+
overrides the default; ``max_tokens`` caps the reply; ``action`` names the caller in the
|
|
56
|
+
missing-key message. A keyless provider (ollama) needs no key.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
LLMError: unknown provider, no API key available for a keyed provider, thinchat's store
|
|
60
|
+
was unreadable, or the client could not be constructed.
|
|
61
|
+
"""
|
|
62
|
+
validate_provider(provider)
|
|
63
|
+
env_name = provider_key_name(provider)
|
|
64
|
+
if env_name is None:
|
|
65
|
+
key = api_key # keyless (ollama): honor an explicit key if given, else none
|
|
66
|
+
else:
|
|
67
|
+
# thinchat owns the key (override > env > its own store). Resolve it here only to raise a
|
|
68
|
+
# friendly, action-named error before constructing the client; the store is thinchat's.
|
|
69
|
+
try:
|
|
70
|
+
resolved_secret = get_api_key(provider, override=api_key)
|
|
71
|
+
except ThinchatError as err:
|
|
72
|
+
raise LLMError(f"{action} could not read the stored key for {provider}: {err}") from err
|
|
73
|
+
key = resolved_secret.reveal() if resolved_secret is not None else None
|
|
74
|
+
if not key:
|
|
75
|
+
raise LLMError(
|
|
76
|
+
f"{action} needs an API key for {provider}; set {env_name} "
|
|
77
|
+
f"or run 'newswatcher setup' (it stores the key with thinchat)")
|
|
78
|
+
try:
|
|
79
|
+
return make_client(provider, model=model, api_key=key,
|
|
80
|
+
max_tokens=max_tokens, max_retries=_MAX_RETRIES)
|
|
81
|
+
except ThinchatError as err:
|
|
82
|
+
raise LLMError(f"{action} could not start {provider}: {err}") from err
|
newswatcher/_select.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Run a CSS selector, turning a malformed-selector error into a domain ``SourceError``.
|
|
2
|
+
|
|
3
|
+
soupsieve raises two unrelated classes for a bad selector: ``SelectorSyntaxError`` for a
|
|
4
|
+
syntax error (``>>bad``, ``a[``), and a bare ``NotImplementedError`` for an unsupported
|
|
5
|
+
pseudo-element (``a::attr(href)``, ``a::text`` -- the Scrapy idiom an LLM healer readily
|
|
6
|
+
emits). Both are caught here, in one place used by both the crawl adapter and the body
|
|
7
|
+
extractor, so one bad selector -- hand-typed in ``sources.toml`` or proposed by the healer
|
|
8
|
+
-- skips its source (the poll catches ``SourceError`` per source) instead of aborting the
|
|
9
|
+
whole pass with a raw error the pipeline does not expect."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from bs4 import BeautifulSoup, Tag
|
|
14
|
+
from soupsieve import SelectorSyntaxError
|
|
15
|
+
|
|
16
|
+
from newswatcher.errors import SourceError
|
|
17
|
+
|
|
18
|
+
__all__ = ["select_all", "select_one"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def select_all(node: Tag | BeautifulSoup, selector: str, source_name: str) -> list[Tag]:
|
|
22
|
+
"""``node.select(selector)`` with a malformed selector reported as ``SourceError``."""
|
|
23
|
+
try:
|
|
24
|
+
return node.select(selector)
|
|
25
|
+
except (SelectorSyntaxError, NotImplementedError) as err:
|
|
26
|
+
raise SourceError(
|
|
27
|
+
f"source {source_name!r}: invalid CSS selector {selector!r}: {err}") from err
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def select_one(node: Tag | BeautifulSoup, selector: str, source_name: str) -> Tag | None:
|
|
31
|
+
"""``node.select_one(selector)`` with a malformed selector reported as ``SourceError``."""
|
|
32
|
+
try:
|
|
33
|
+
return node.select_one(selector)
|
|
34
|
+
except (SelectorSyntaxError, NotImplementedError) as err:
|
|
35
|
+
raise SourceError(
|
|
36
|
+
f"source {source_name!r}: invalid CSS selector {selector!r}: {err}") from err
|
newswatcher/_toml.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""The small TOML pieces the ``topics`` and ``sources`` registries both need: encode a
|
|
2
|
+
string or a list of strings as a TOML value, and read a ``[[key]]`` table array back.
|
|
3
|
+
|
|
4
|
+
The read side is the mirror of ``_atomic`` on the write side -- both registries store a
|
|
5
|
+
table array of records in ``config_dir()`` and were repeating the same parse-and-validate
|
|
6
|
+
boilerplate. Each registry still renders its own record blocks (their fields differ); only
|
|
7
|
+
these primitives, which do not, live here. Python's ``tomllib`` reads TOML but cannot write
|
|
8
|
+
it, so the encoders are hand-rolled -- a TOML basic string is spelled exactly like a JSON
|
|
9
|
+
string, so ``json.dumps`` produces one (with the right escaping) for free.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
__all__ = ["array", "quote", "read_table_array"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def quote(value: str) -> str:
|
|
22
|
+
"""Encode ``value`` as a TOML basic string, quoted and escaped (``na"me`` ->
|
|
23
|
+
``"na\\"me"``). Non-ASCII is kept verbatim, matching the human-edited files."""
|
|
24
|
+
return json.dumps(value, ensure_ascii=False)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def array(words: tuple[str, ...]) -> str:
|
|
28
|
+
"""Encode ``words`` as a TOML inline array of basic strings (``["a", "b"]``)."""
|
|
29
|
+
return "[" + ", ".join(quote(word) for word in words) + "]"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def read_table_array(
|
|
33
|
+
path: Path, key: str, error_cls: type[Exception]
|
|
34
|
+
) -> list[dict[str, object]]:
|
|
35
|
+
"""Read ``path`` and return the entries of its ``[[key]]`` table array (each a dict);
|
|
36
|
+
empty when the key is absent. Non-dict entries are skipped.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
error_cls: the file is unreadable or not valid TOML, or ``key`` holds something
|
|
40
|
+
other than a table array (a scalar ``key = ...`` instead of ``[[key]]``). The
|
|
41
|
+
caller passes its own domain error type so the message names the right file.
|
|
42
|
+
"""
|
|
43
|
+
try:
|
|
44
|
+
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except (tomllib.TOMLDecodeError, OSError, UnicodeDecodeError) as err:
|
|
46
|
+
# UnicodeDecodeError is a ValueError, not an OSError, so a non-UTF-8 file must be
|
|
47
|
+
# named explicitly or it escapes this boundary as a bare traceback.
|
|
48
|
+
raise error_cls(f"could not read {path}: {err}") from err
|
|
49
|
+
entries = parsed.get(key)
|
|
50
|
+
if entries is None:
|
|
51
|
+
return []
|
|
52
|
+
if not isinstance(entries, list):
|
|
53
|
+
raise error_cls(f"{path}: [{key}] must be a table array ([[{key}]])")
|
|
54
|
+
return [entry for entry in entries if isinstance(entry, dict)]
|
newswatcher/body.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Fetch an article page and extract its main text — the raw material an LLM summary
|
|
2
|
+
is written from. The body is transient: it is never stored in the archive nor placed
|
|
3
|
+
in the outbound email (which carry only our summary plus the link). Extraction uses
|
|
4
|
+
the source's ``body_selector`` when it defines one, else the generic extractor
|
|
5
|
+
(trafilatura)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import trafilatura
|
|
12
|
+
from bs4 import BeautifulSoup
|
|
13
|
+
|
|
14
|
+
from newswatcher._select import select_one
|
|
15
|
+
from newswatcher.feed import FeedItem
|
|
16
|
+
from newswatcher.http import get
|
|
17
|
+
from newswatcher.robots import RobotsGate
|
|
18
|
+
from newswatcher.sources import Source
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
__all__ = ["extract_body", "fetch_body"]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fetch_body(item: FeedItem, source: Source, gate: RobotsGate, *,
|
|
27
|
+
session: requests.Session | None = None) -> str:
|
|
28
|
+
"""Fetch ``item``'s article page (robots-gated) and extract its body text, or ""
|
|
29
|
+
when nothing could be extracted.
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
FetchError: robots disallows the article URL or the fetch failed (propagated
|
|
33
|
+
from ``http.get``).
|
|
34
|
+
"""
|
|
35
|
+
return extract_body(get(item.link, gate, session=session), source)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extract_body(html: str, source: Source) -> str:
|
|
39
|
+
"""Extract the article body from ``html``. With ``source.body_selector`` set, the
|
|
40
|
+
text of the first matching node; otherwise trafilatura's main-content extraction.
|
|
41
|
+
Returns "" when neither yields text (a summary step then falls back to the feed
|
|
42
|
+
title/summary)."""
|
|
43
|
+
if source.body_selector:
|
|
44
|
+
node = select_one(BeautifulSoup(html, "lxml"), source.body_selector, source.name)
|
|
45
|
+
return node.get_text(" ", strip=True) if node is not None else ""
|
|
46
|
+
extracted = trafilatura.extract(html, include_comments=False, include_tables=False)
|
|
47
|
+
return (extracted or "").strip()
|