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.
@@ -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"
@@ -0,0 +1,10 @@
1
+ """Enable ``python -m newswatcher`` as an alias for the console script."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from newswatcher.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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()