feed-sentiment 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,58 @@
1
+ from feed_sentiment._metadata import package_version
2
+ from feed_sentiment.exceptions import (
3
+ AnalysisError,
4
+ ContentTypeError,
5
+ FeedFormatError,
6
+ FeedSentimentError,
7
+ InputError,
8
+ ResponseSizeError,
9
+ RetrievalError,
10
+ UnsafeUrlError,
11
+ )
12
+ from feed_sentiment.feeds import FeedRetriever, RetrievalPolicy
13
+ from feed_sentiment.models import (
14
+ AnalysisWarning,
15
+ AnalyzedEntry,
16
+ AnalyzerMetadata,
17
+ EntryAnalysisResult,
18
+ FeedAnalysisSnapshot,
19
+ FeedMetadata,
20
+ NormalizedFeedEntry,
21
+ SentimentLabel,
22
+ SentimentScore,
23
+ SnapshotAggregate,
24
+ WarningCode,
25
+ WeightingMethod,
26
+ )
27
+ from feed_sentiment.services import analyze_entries, analyze_feed, analyze_text
28
+
29
+ __version__ = package_version()
30
+
31
+ __all__ = [
32
+ "AnalysisError",
33
+ "AnalysisWarning",
34
+ "AnalyzerMetadata",
35
+ "AnalyzedEntry",
36
+ "ContentTypeError",
37
+ "EntryAnalysisResult",
38
+ "FeedAnalysisSnapshot",
39
+ "FeedFormatError",
40
+ "FeedMetadata",
41
+ "FeedRetriever",
42
+ "FeedSentimentError",
43
+ "InputError",
44
+ "NormalizedFeedEntry",
45
+ "ResponseSizeError",
46
+ "RetrievalError",
47
+ "RetrievalPolicy",
48
+ "SentimentLabel",
49
+ "SentimentScore",
50
+ "SnapshotAggregate",
51
+ "UnsafeUrlError",
52
+ "WarningCode",
53
+ "WeightingMethod",
54
+ "__version__",
55
+ "analyze_entries",
56
+ "analyze_feed",
57
+ "analyze_text",
58
+ ]
@@ -0,0 +1,18 @@
1
+ from importlib import metadata
2
+
3
+ DISTRIBUTION_NAME = "feed-sentiment"
4
+ PROJECT_URL = "https://github.com/kurtpatrickyu/feed-sentiment"
5
+ UNKNOWN_VERSION = "0+unknown"
6
+
7
+
8
+ def package_version() -> str:
9
+ """Return the installed distribution version or a source-tree fallback."""
10
+ try:
11
+ return metadata.version(DISTRIBUTION_NAME)
12
+ except metadata.PackageNotFoundError:
13
+ return UNKNOWN_VERSION
14
+
15
+
16
+ def default_user_agent() -> str:
17
+ """Build the default HTTP User-Agent from installed package metadata."""
18
+ return f"{DISTRIBUTION_NAME}/{package_version()} (+{PROJECT_URL})"
@@ -0,0 +1,4 @@
1
+ from feed_sentiment.analyzers.base import SentimentAnalyzer, label_for
2
+ from feed_sentiment.analyzers.vader import VaderAnalyzer
3
+
4
+ __all__ = ["SentimentAnalyzer", "VaderAnalyzer", "label_for"]
@@ -0,0 +1,15 @@
1
+ from typing import Protocol
2
+
3
+ from feed_sentiment.models import SentimentLabel, SentimentScore
4
+
5
+
6
+ class SentimentAnalyzer(Protocol):
7
+ def analyze(self, text: str) -> SentimentScore: ...
8
+
9
+
10
+ def label_for(compound: float) -> SentimentLabel:
11
+ if compound >= 0.05:
12
+ return SentimentLabel.POSITIVE
13
+ if compound <= -0.05:
14
+ return SentimentLabel.NEGATIVE
15
+ return SentimentLabel.NEUTRAL
@@ -0,0 +1,31 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+ from typing import Any
3
+
4
+ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer # type: ignore[import-untyped]
5
+
6
+ from feed_sentiment.analyzers.base import label_for
7
+ from feed_sentiment.models import AnalyzerMetadata, SentimentScore
8
+
9
+
10
+ def vader_version() -> str | None:
11
+ try:
12
+ return version("vaderSentiment")
13
+ except PackageNotFoundError:
14
+ return None
15
+
16
+
17
+ class VaderAnalyzer:
18
+ def __init__(self, engine: Any | None = None) -> None:
19
+ self._engine = engine or SentimentIntensityAnalyzer()
20
+
21
+ def analyze(self, text: str) -> SentimentScore:
22
+ raw = self._engine.polarity_scores(text)
23
+ compound = float(raw["compound"])
24
+ return SentimentScore(
25
+ negative=float(raw["neg"]),
26
+ neutral=float(raw["neu"]),
27
+ positive=float(raw["pos"]),
28
+ compound=compound,
29
+ label=label_for(compound),
30
+ analyzer=AnalyzerMetadata("vaderSentiment", vader_version()),
31
+ )
@@ -0,0 +1,3 @@
1
+ from feed_sentiment.cli.app import app
2
+
3
+ __all__ = ["app"]
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from feed_sentiment import RetrievalPolicy, __version__, analyze_feed
10
+ from feed_sentiment.exceptions import FeedSentimentError
11
+ from feed_sentiment.models import FeedAnalysisSnapshot
12
+
13
+ app = typer.Typer(
14
+ no_args_is_help=True, add_completion=False, help="Analyze RSS and Atom sentiment snapshots."
15
+ )
16
+
17
+
18
+ class OutputFormat(StrEnum):
19
+ TEXT = "text"
20
+ JSON = "json"
21
+
22
+
23
+ def _version(value: bool) -> None:
24
+ if value:
25
+ typer.echo(__version__)
26
+ raise typer.Exit()
27
+
28
+
29
+ @app.callback()
30
+ def main(
31
+ version: Annotated[
32
+ bool,
33
+ typer.Option(
34
+ "--version", callback=_version, is_eager=True, help="Show the installed version."
35
+ ),
36
+ ] = False,
37
+ ) -> None:
38
+ """Analyze RSS and Atom sentiment snapshots."""
39
+
40
+
41
+ def render_text(snapshot: FeedAnalysisSnapshot) -> str:
42
+ lines = [f"Feed: {snapshot.feed.title or snapshot.source_url}"]
43
+ for item in snapshot.entries:
44
+ name = item.entry.title or item.entry.identity
45
+ lines.append(f"- {name}: {item.sentiment.label} ({item.sentiment.compound:.4f})")
46
+ aggregate = snapshot.snapshot_aggregate
47
+ if aggregate is None:
48
+ lines.append("Snapshot aggregate: none (no successfully analyzed entries)")
49
+ else:
50
+ lines.append(
51
+ f"Snapshot aggregate: {aggregate.label} ({aggregate.compound:.4f}); "
52
+ f"included={aggregate.included_count}, skipped={aggregate.skipped_count}, "
53
+ f"weighting={aggregate.weighting}"
54
+ )
55
+ for warning in snapshot.warnings:
56
+ lines.append(f"Warning [{warning.code}]: {warning.message}")
57
+ return "\n".join(lines)
58
+
59
+
60
+ @app.command()
61
+ def analyze(
62
+ feed_url: Annotated[str, typer.Argument(help="HTTP(S) RSS or Atom URL.")],
63
+ output_format: Annotated[
64
+ OutputFormat, typer.Option("--format", case_sensitive=False)
65
+ ] = OutputFormat.TEXT,
66
+ allow_private_network: Annotated[
67
+ bool,
68
+ typer.Option(
69
+ "--allow-private-network",
70
+ help="Explicitly allow trusted private-network feed destinations.",
71
+ ),
72
+ ] = False,
73
+ ) -> None:
74
+ """Analyze one feed snapshot."""
75
+ try:
76
+ if allow_private_network:
77
+ snapshot = analyze_feed(
78
+ feed_url,
79
+ retrieval_policy=RetrievalPolicy(allow_private_networks=True),
80
+ )
81
+ else:
82
+ snapshot = analyze_feed(feed_url)
83
+ except FeedSentimentError as exc:
84
+ typer.echo(f"Error: {exc}", err=True)
85
+ raise typer.Exit(1) from exc
86
+ if output_format is OutputFormat.JSON:
87
+ typer.echo(json.dumps(snapshot.to_dict(), ensure_ascii=True))
88
+ else:
89
+ typer.echo(render_text(snapshot))
90
+
91
+
92
+ if __name__ == "__main__":
93
+ app()
@@ -0,0 +1,34 @@
1
+ class FeedSentimentError(Exception):
2
+ """Base exception for expected package failures."""
3
+
4
+
5
+ class InputError(FeedSentimentError, ValueError):
6
+ """Raised when caller input cannot be analyzed."""
7
+
8
+
9
+ class AnalysisError(FeedSentimentError):
10
+ """Raised when a sentiment analyzer fails."""
11
+
12
+
13
+ class RetrievalError(FeedSentimentError):
14
+ """Raised when a feed cannot be retrieved."""
15
+
16
+ def __init__(self, url: str, message: str) -> None:
17
+ self.url = url
18
+ super().__init__(f"{message}: {url}")
19
+
20
+
21
+ class UnsafeUrlError(RetrievalError):
22
+ """Raised when URL policy rejects a destination."""
23
+
24
+
25
+ class ResponseSizeError(RetrievalError):
26
+ """Raised when a response exceeds its byte limit."""
27
+
28
+
29
+ class ContentTypeError(RetrievalError):
30
+ """Raised for a clearly incompatible response media type."""
31
+
32
+
33
+ class FeedFormatError(FeedSentimentError):
34
+ """Raised when content is not a recognizable RSS or Atom document."""
@@ -0,0 +1,19 @@
1
+ from feed_sentiment.feeds.parser import construct_analysis_text, html_to_text, parse_feed
2
+ from feed_sentiment.feeds.retriever import (
3
+ FeedRetriever,
4
+ HttpxFeedRetriever,
5
+ RetrievalPolicy,
6
+ RetrievedFeed,
7
+ validate_url,
8
+ )
9
+
10
+ __all__ = [
11
+ "FeedRetriever",
12
+ "HttpxFeedRetriever",
13
+ "RetrievedFeed",
14
+ "RetrievalPolicy",
15
+ "construct_analysis_text",
16
+ "html_to_text",
17
+ "parse_feed",
18
+ "validate_url",
19
+ ]
@@ -0,0 +1,125 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from datetime import UTC, datetime
5
+ from time import struct_time
6
+ from typing import Any
7
+
8
+ import feedparser # type: ignore[import-untyped]
9
+ from bs4 import BeautifulSoup
10
+
11
+ from feed_sentiment.exceptions import FeedFormatError
12
+ from feed_sentiment.models import (
13
+ AnalysisWarning,
14
+ FeedMetadata,
15
+ NormalizedFeedEntry,
16
+ ParsedFeed,
17
+ WarningCode,
18
+ )
19
+
20
+
21
+ def _clean(value: Any) -> str | None:
22
+ if value is None:
23
+ return None
24
+ cleaned = " ".join(str(value).split())
25
+ return cleaned or None
26
+
27
+
28
+ def html_to_text(value: Any) -> str | None:
29
+ if value is None:
30
+ return None
31
+ soup = BeautifulSoup(str(value), "html.parser")
32
+ for element in soup(["script", "style"]):
33
+ element.decompose()
34
+ return _clean(soup.get_text(" "))
35
+
36
+
37
+ def construct_analysis_text(title: str | None, summary: str | None) -> str:
38
+ return "\n".join(part for part in (title, summary) if part)
39
+
40
+
41
+ def _timestamp(entry: Any) -> tuple[datetime | None, bool]:
42
+ value: struct_time | None = entry.get("published_parsed") or entry.get("updated_parsed")
43
+ if value is None:
44
+ raw_present = bool(entry.get("published") or entry.get("updated"))
45
+ return None, raw_present
46
+ try:
47
+ return datetime(*value[:6], tzinfo=UTC), False
48
+ except (TypeError, ValueError, OverflowError):
49
+ return None, True
50
+
51
+
52
+ def _identity(
53
+ entry_id: str | None,
54
+ url: str | None,
55
+ title: str | None,
56
+ summary: str | None,
57
+ published_at: datetime | None,
58
+ ) -> str:
59
+ if entry_id:
60
+ return f"id:{entry_id}"
61
+ if url:
62
+ return f"url:{url}"
63
+ payload = "\x1f".join(
64
+ (title or "", summary or "", published_at.isoformat() if published_at else "")
65
+ )
66
+ return f"sha256:{hashlib.sha256(payload.encode('utf-8')).hexdigest()}"
67
+
68
+
69
+ def parse_feed(content: bytes) -> ParsedFeed:
70
+ parsed = feedparser.parse(content)
71
+ if not parsed.get("version") or parsed.get("bozo"):
72
+ detail = str(parsed.get("bozo_exception", "unrecognized RSS or Atom document"))
73
+ raise FeedFormatError(f"Malformed feed document: {detail}")
74
+
75
+ warnings: list[AnalysisWarning] = []
76
+ normalized: list[NormalizedFeedEntry] = []
77
+ seen: set[str] = set()
78
+ raw_entries = parsed.get("entries", ())
79
+ for index, raw in enumerate(raw_entries):
80
+ entry_id = _clean(raw.get("id") or raw.get("guid"))
81
+ url = _clean(raw.get("link"))
82
+ title = _clean(raw.get("title"))
83
+ summary_source = raw.get("summary")
84
+ if summary_source is None and raw.get("content"):
85
+ summary_source = raw["content"][0].get("value")
86
+ summary = html_to_text(summary_source)
87
+ published_at, invalid_date = _timestamp(raw)
88
+ identity = _identity(entry_id, url, title, summary, published_at)
89
+ if invalid_date:
90
+ warnings.append(
91
+ AnalysisWarning(
92
+ WarningCode.INVALID_METADATA,
93
+ "Invalid publication date was omitted",
94
+ identity,
95
+ index,
96
+ )
97
+ )
98
+ text = construct_analysis_text(title, summary)
99
+ if not text:
100
+ warnings.append(
101
+ AnalysisWarning(
102
+ WarningCode.NO_USABLE_TEXT,
103
+ "Entry has no usable title or summary",
104
+ identity,
105
+ index,
106
+ )
107
+ )
108
+ continue
109
+ if identity in seen:
110
+ warnings.append(
111
+ AnalysisWarning(
112
+ WarningCode.DUPLICATE_ENTRY, "Duplicate entry was skipped", identity, index
113
+ )
114
+ )
115
+ continue
116
+ seen.add(identity)
117
+ normalized.append(
118
+ NormalizedFeedEntry(identity, entry_id, url, title, summary, published_at, text)
119
+ )
120
+
121
+ if not raw_entries:
122
+ warnings.append(AnalysisWarning(WarningCode.EMPTY_FEED, "Feed contains no entries"))
123
+ feed = parsed.get("feed", {})
124
+ metadata = FeedMetadata(_clean(feed.get("title")), _clean(feed.get("link")))
125
+ return ParsedFeed(metadata, tuple(normalized), tuple(warnings), len(raw_entries))
@@ -0,0 +1,135 @@
1
+ from __future__ import annotations
2
+
3
+ import ipaddress
4
+ import socket
5
+ from dataclasses import dataclass, field
6
+ from typing import Protocol
7
+ from urllib.parse import urljoin, urlsplit
8
+
9
+ import httpx
10
+
11
+ from feed_sentiment._metadata import default_user_agent
12
+ from feed_sentiment.exceptions import (
13
+ ContentTypeError,
14
+ ResponseSizeError,
15
+ RetrievalError,
16
+ UnsafeUrlError,
17
+ )
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class RetrievalPolicy:
22
+ connect_timeout: float = 5.0
23
+ read_timeout: float = 15.0
24
+ max_redirects: int = 5
25
+ max_response_bytes: int = 5 * 1024 * 1024
26
+ allow_private_networks: bool = False
27
+ user_agent: str = field(default_factory=default_user_agent)
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class RetrievedFeed:
32
+ url: str
33
+ content: bytes
34
+ content_type: str | None
35
+
36
+
37
+ class FeedRetriever(Protocol):
38
+ def fetch(self, url: str, policy: RetrievalPolicy) -> RetrievedFeed: ...
39
+
40
+
41
+ def _blocked(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
42
+ return any(
43
+ (
44
+ address.is_private,
45
+ address.is_loopback,
46
+ address.is_link_local,
47
+ address.is_multicast,
48
+ address.is_unspecified,
49
+ address.is_reserved,
50
+ )
51
+ )
52
+
53
+
54
+ def validate_url(url: str, policy: RetrievalPolicy) -> None:
55
+ parsed = urlsplit(url)
56
+ if parsed.scheme not in {"http", "https"}:
57
+ raise UnsafeUrlError(url, "Only HTTP and HTTPS feed URLs are allowed")
58
+ if not parsed.hostname or parsed.username is not None or parsed.password is not None:
59
+ raise UnsafeUrlError(url, "Feed URL must have a host and no embedded credentials")
60
+ if policy.allow_private_networks:
61
+ return
62
+ try:
63
+ infos = socket.getaddrinfo(parsed.hostname, parsed.port, type=socket.SOCK_STREAM)
64
+ except OSError as exc:
65
+ raise RetrievalError(url, f"Could not resolve feed host ({exc})") from exc
66
+ for info in infos:
67
+ address = ipaddress.ip_address(info[4][0])
68
+ if _blocked(address):
69
+ raise UnsafeUrlError(url, f"Feed URL resolves to blocked address {address}")
70
+
71
+
72
+ _ACCEPTED_TYPES = {
73
+ "application/atom+xml",
74
+ "application/rss+xml",
75
+ "application/xml",
76
+ "text/xml",
77
+ "application/octet-stream",
78
+ "binary/octet-stream",
79
+ }
80
+
81
+
82
+ class HttpxFeedRetriever:
83
+ def fetch(self, url: str, policy: RetrievalPolicy) -> RetrievedFeed:
84
+ current = url
85
+ timeout = httpx.Timeout(policy.read_timeout, connect=policy.connect_timeout)
86
+ try:
87
+ with httpx.Client(
88
+ follow_redirects=False, timeout=timeout, headers={"User-Agent": policy.user_agent}
89
+ ) as client:
90
+ for redirect_count in range(policy.max_redirects + 1):
91
+ validate_url(current, policy)
92
+ with client.stream("GET", current) as response:
93
+ if response.is_redirect:
94
+ location = response.headers.get("location")
95
+ if not location or redirect_count >= policy.max_redirects:
96
+ raise RetrievalError(
97
+ current, "Feed redirect limit exceeded or target missing"
98
+ )
99
+ current = urljoin(current, location)
100
+ continue
101
+ response.raise_for_status()
102
+ media_type = (
103
+ response.headers.get("content-type", "")
104
+ .split(";", 1)[0]
105
+ .strip()
106
+ .lower()
107
+ )
108
+ if (
109
+ media_type
110
+ and media_type not in _ACCEPTED_TYPES
111
+ and not media_type.endswith("+xml")
112
+ ):
113
+ raise ContentTypeError(
114
+ current, f"Unsupported feed content type {media_type}"
115
+ )
116
+ chunks: list[bytes] = []
117
+ size = 0
118
+ for chunk in response.iter_bytes():
119
+ size += len(chunk)
120
+ if size > policy.max_response_bytes:
121
+ raise ResponseSizeError(
122
+ current,
123
+ f"Feed response exceeds {policy.max_response_bytes} bytes",
124
+ )
125
+ chunks.append(chunk)
126
+ return RetrievedFeed(
127
+ str(response.url), b"".join(chunks), media_type or None
128
+ )
129
+ except RetrievalError:
130
+ raise
131
+ except httpx.TimeoutException as exc:
132
+ raise RetrievalError(current, "Feed retrieval timed out") from exc
133
+ except httpx.HTTPError as exc:
134
+ raise RetrievalError(current, f"Feed retrieval failed ({exc})") from exc
135
+ raise RetrievalError(current, "Feed redirect handling failed")
@@ -0,0 +1,131 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict, dataclass
4
+ from datetime import datetime
5
+ from enum import StrEnum
6
+ from typing import Any, cast
7
+
8
+
9
+ class SentimentLabel(StrEnum):
10
+ NEGATIVE = "negative"
11
+ NEUTRAL = "neutral"
12
+ POSITIVE = "positive"
13
+
14
+
15
+ class WarningCode(StrEnum):
16
+ EMPTY_FEED = "empty_feed"
17
+ NO_USABLE_TEXT = "no_usable_text"
18
+ DUPLICATE_ENTRY = "duplicate_entry"
19
+ INVALID_METADATA = "invalid_metadata"
20
+ ANALYSIS_FAILED = "analysis_failed"
21
+
22
+
23
+ class WeightingMethod(StrEnum):
24
+ EQUAL_ENTRY = "equal_entry"
25
+
26
+
27
+ class Serializable:
28
+ def to_dict(self) -> dict[str, Any]:
29
+ return cast(dict[str, Any], _json_value(asdict(cast(Any, self))))
30
+
31
+
32
+ def _json_value(value: Any) -> Any:
33
+ if isinstance(value, datetime):
34
+ return value.isoformat()
35
+ if isinstance(value, StrEnum):
36
+ return value.value
37
+ if isinstance(value, dict):
38
+ return {key: _json_value(item) for key, item in value.items()}
39
+ if isinstance(value, (list, tuple)):
40
+ return [_json_value(item) for item in value]
41
+ return value
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class AnalyzerMetadata(Serializable):
46
+ name: str
47
+ version: str | None
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class SentimentScore(Serializable):
52
+ negative: float
53
+ neutral: float
54
+ positive: float
55
+ compound: float
56
+ label: SentimentLabel
57
+ analyzer: AnalyzerMetadata
58
+
59
+ def __post_init__(self) -> None:
60
+ for name in ("negative", "neutral", "positive"):
61
+ if not 0.0 <= getattr(self, name) <= 1.0:
62
+ raise ValueError(f"{name} must be between 0 and 1")
63
+ if not -1.0 <= self.compound <= 1.0:
64
+ raise ValueError("compound must be between -1 and 1")
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class NormalizedFeedEntry(Serializable):
69
+ identity: str
70
+ entry_id: str | None
71
+ url: str | None
72
+ title: str | None
73
+ summary: str | None
74
+ published_at: datetime | None
75
+ analysis_text: str
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class AnalyzedEntry(Serializable):
80
+ entry: NormalizedFeedEntry
81
+ sentiment: SentimentScore
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class AnalysisWarning(Serializable):
86
+ code: WarningCode
87
+ message: str
88
+ entry_identity: str | None = None
89
+ entry_index: int | None = None
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class EntryAnalysisResult(Serializable):
94
+ entries: tuple[AnalyzedEntry, ...]
95
+ warnings: tuple[AnalysisWarning, ...]
96
+ skipped_count: int
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class SnapshotAggregate(Serializable):
101
+ negative: float
102
+ neutral: float
103
+ positive: float
104
+ compound: float
105
+ label: SentimentLabel
106
+ included_count: int
107
+ skipped_count: int
108
+ weighting: WeightingMethod = WeightingMethod.EQUAL_ENTRY
109
+
110
+
111
+ @dataclass(frozen=True, slots=True)
112
+ class FeedMetadata(Serializable):
113
+ title: str | None = None
114
+ url: str | None = None
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class ParsedFeed(Serializable):
119
+ metadata: FeedMetadata
120
+ entries: tuple[NormalizedFeedEntry, ...]
121
+ warnings: tuple[AnalysisWarning, ...]
122
+ source_entry_count: int
123
+
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class FeedAnalysisSnapshot(Serializable):
127
+ source_url: str
128
+ feed: FeedMetadata
129
+ entries: tuple[AnalyzedEntry, ...]
130
+ warnings: tuple[AnalysisWarning, ...]
131
+ snapshot_aggregate: SnapshotAggregate | None
@@ -0,0 +1,8 @@
1
+ from feed_sentiment.services.analysis import (
2
+ aggregate_entries,
3
+ analyze_entries,
4
+ analyze_feed,
5
+ analyze_text,
6
+ )
7
+
8
+ __all__ = ["aggregate_entries", "analyze_entries", "analyze_feed", "analyze_text"]
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ from feed_sentiment.analyzers import SentimentAnalyzer, VaderAnalyzer, label_for
6
+ from feed_sentiment.exceptions import AnalysisError, InputError
7
+ from feed_sentiment.feeds import FeedRetriever, HttpxFeedRetriever, RetrievalPolicy, parse_feed
8
+ from feed_sentiment.models import (
9
+ AnalysisWarning,
10
+ AnalyzedEntry,
11
+ EntryAnalysisResult,
12
+ FeedAnalysisSnapshot,
13
+ NormalizedFeedEntry,
14
+ SentimentScore,
15
+ SnapshotAggregate,
16
+ WarningCode,
17
+ )
18
+
19
+
20
+ def _analyzer(selected: SentimentAnalyzer | None) -> SentimentAnalyzer:
21
+ return selected if selected is not None else VaderAnalyzer()
22
+
23
+
24
+ def analyze_text(text: str, *, analyzer: SentimentAnalyzer | None = None) -> SentimentScore:
25
+ cleaned = text.strip()
26
+ if not cleaned:
27
+ raise InputError("Text must contain non-whitespace characters")
28
+ try:
29
+ return _analyzer(analyzer).analyze(cleaned)
30
+ except AnalysisError:
31
+ raise
32
+ except Exception as exc:
33
+ raise AnalysisError("Sentiment analyzer failed") from exc
34
+
35
+
36
+ def analyze_entries(
37
+ entries: Iterable[NormalizedFeedEntry],
38
+ *,
39
+ analyzer: SentimentAnalyzer | None = None,
40
+ ) -> EntryAnalysisResult:
41
+ selected = _analyzer(analyzer)
42
+ analyzed: list[AnalyzedEntry] = []
43
+ warnings: list[AnalysisWarning] = []
44
+ skipped = 0
45
+ for index, entry in enumerate(entries):
46
+ if not entry.analysis_text.strip():
47
+ skipped += 1
48
+ warnings.append(
49
+ AnalysisWarning(
50
+ WarningCode.NO_USABLE_TEXT,
51
+ "Entry has no usable analysis text",
52
+ entry.identity,
53
+ index,
54
+ )
55
+ )
56
+ continue
57
+ try:
58
+ analyzed.append(AnalyzedEntry(entry, selected.analyze(entry.analysis_text)))
59
+ except Exception:
60
+ skipped += 1
61
+ warnings.append(
62
+ AnalysisWarning(
63
+ WarningCode.ANALYSIS_FAILED,
64
+ "Sentiment analysis failed for entry",
65
+ entry.identity,
66
+ index,
67
+ )
68
+ )
69
+ return EntryAnalysisResult(tuple(analyzed), tuple(warnings), skipped)
70
+
71
+
72
+ def aggregate_entries(
73
+ entries: tuple[AnalyzedEntry, ...], skipped_count: int
74
+ ) -> SnapshotAggregate | None:
75
+ if not entries:
76
+ return None
77
+ count = len(entries)
78
+ negative = sum(item.sentiment.negative for item in entries) / count
79
+ neutral = sum(item.sentiment.neutral for item in entries) / count
80
+ positive = sum(item.sentiment.positive for item in entries) / count
81
+ compound = sum(item.sentiment.compound for item in entries) / count
82
+ return SnapshotAggregate(
83
+ negative, neutral, positive, compound, label_for(compound), count, skipped_count
84
+ )
85
+
86
+
87
+ def analyze_feed(
88
+ url: str,
89
+ *,
90
+ analyzer: SentimentAnalyzer | None = None,
91
+ retriever: FeedRetriever | None = None,
92
+ retrieval_policy: RetrievalPolicy | None = None,
93
+ ) -> FeedAnalysisSnapshot:
94
+ policy = retrieval_policy or RetrievalPolicy()
95
+ fetched = (retriever or HttpxFeedRetriever()).fetch(url, policy)
96
+ parsed = parse_feed(fetched.content)
97
+ result = analyze_entries(parsed.entries, analyzer=analyzer)
98
+ warnings = parsed.warnings + result.warnings
99
+ skipped = parsed.source_entry_count - len(result.entries)
100
+ return FeedAnalysisSnapshot(
101
+ source_url=url,
102
+ feed=parsed.metadata,
103
+ entries=result.entries,
104
+ warnings=warnings,
105
+ snapshot_aggregate=aggregate_entries(result.entries, skipped),
106
+ )
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: feed-sentiment
3
+ Version: 0.1.0
4
+ Summary: Typed sentiment analysis for RSS and Atom feed snapshots
5
+ Project-URL: Homepage, https://github.com/kurtpatrickyu/feed-sentiment
6
+ Project-URL: Repository, https://github.com/kurtpatrickyu/feed-sentiment
7
+ Project-URL: Issues, https://github.com/kurtpatrickyu/feed-sentiment/issues
8
+ Project-URL: Changelog, https://github.com/kurtpatrickyu/feed-sentiment/releases
9
+ Author: Kurt Patrick Yu
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: atom,feed,news-analysis,nlp,rss,sentiment-analysis
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content :: News/Diary
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Text Processing
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: beautifulsoup4<5,>=4.13
25
+ Requires-Dist: feedparser<7,>=6.0.11
26
+ Requires-Dist: httpx<1,>=0.28
27
+ Requires-Dist: typer<1,>=0.16
28
+ Requires-Dist: vadersentiment<4,>=3.3.2
29
+ Provides-Extra: dev
30
+ Requires-Dist: build<2,>=1.2; extra == 'dev'
31
+ Requires-Dist: mypy<2,>=1.16; extra == 'dev'
32
+ Requires-Dist: pytest-httpx<1,>=0.35; extra == 'dev'
33
+ Requires-Dist: pytest<9,>=8.4; extra == 'dev'
34
+ Requires-Dist: ruff<1,>=0.12; extra == 'dev'
35
+ Requires-Dist: twine<7,>=6.2; extra == 'dev'
36
+ Description-Content-Type: text/markdown
37
+
38
+ # Feed Sentiment
39
+
40
+ Feed Sentiment is an MIT-licensed Python 3.12+ package for one-shot sentiment analysis of RSS and Atom feed snapshots. It normalizes entries, scores each usable entry with VADER, and returns an explicitly defined snapshot aggregate.
41
+
42
+ ## Installation
43
+
44
+ ```console
45
+ python -m pip install feed-sentiment
46
+ ```
47
+
48
+ Runtime dependencies have narrow roles: `httpx` performs bounded HTTP retrieval, `feedparser` interprets RSS and Atom, `beautifulsoup4` converts summary HTML to deterministic plain text, `vaderSentiment` supplies the default lexical analyzer, and `typer` provides the CLI. The `dev` extra adds pytest, pytest-httpx, Ruff, mypy, and build tooling.
49
+
50
+ ## Python API
51
+
52
+ ```python
53
+ from feed_sentiment import analyze_feed, analyze_text
54
+
55
+ text_score = analyze_text("This release is excellent!")
56
+ snapshot = analyze_feed("https://example.com/feed.xml")
57
+
58
+ for item in snapshot.entries:
59
+ print(item.entry.title, item.sentiment.label, item.sentiment.compound)
60
+
61
+ print(snapshot.snapshot_aggregate)
62
+ ```
63
+
64
+ `analyze_entries(entries, *, analyzer=None)` analyzes already normalized entries. All public operations return package-owned typed dataclasses; analyzer and parser implementation objects do not leak through the API. A custom analyzer can implement the public `SentimentAnalyzer` protocol.
65
+
66
+ ## CLI
67
+
68
+ ```console
69
+ feed-sentiment analyze https://example.com/feed.xml
70
+ feed-sentiment analyze https://example.com/feed.xml --format json
71
+ feed-sentiment analyze http://intranet/feed.xml --allow-private-network
72
+ feed-sentiment --version
73
+ ```
74
+
75
+ Normal results are written to stdout. Expected errors are concise, go to stderr, and exit 1; CLI usage errors exit 2. JSON mode emits exactly one undecorated JSON document.
76
+
77
+ ## Score semantics
78
+
79
+ Each entry contains VADER `negative`, `neutral`, and `positive` proportions in `[0, 1]` plus `compound` in `[-1, 1]`. Compound scores `>= 0.05` are positive, scores `<= -0.05` are negative, and values between those thresholds are neutral. Analyzer name and installed version (when discoverable) accompany each score. Empty text is an input error rather than an artificial neutral result.
80
+
81
+ Entry analysis text is `title + "\n" + plain_text_summary` when both fields exist, or the sole usable field otherwise. HTML tags and script/style content are removed, entities decoded, whitespace collapsed, and Unicode preserved. Entries with no usable text are skipped with structured warnings.
82
+
83
+ ## Snapshot aggregation
84
+
85
+ `snapshot.snapshot_aggregate` includes exactly the entries successfully analyzed in the current retrieval. Every included entry has equal weight. Negative, neutral, positive, and compound are component-wise arithmetic means calculated without intermediate rounding; the aggregate label uses the same compound thresholds. The result records included and skipped counts plus `equal_entry` weighting. If no entry succeeds, the aggregate is `None`/JSON `null`, and warnings explain empty, skipped, duplicate, or failed entries.
86
+
87
+ Within one snapshot, duplicate identity prefers entry ID, then canonical entry URL, then a SHA-256 fingerprint of normalized title, summary, and publication timestamp. This fallback is not a persistent monitoring identity contract.
88
+
89
+ ## Retrieval and security boundary
90
+
91
+ Retrieval accepts only HTTP(S), uses finite connect/read timeouts, a redirect cap, a response-size limit, explicit media-type checks, and no automatic retries. The default policy rejects embedded credentials and destinations resolving to private, loopback, link-local, multicast, unspecified, or reserved addresses, including redirect targets. Trusted applications may explicitly enable private-network feeds with `RetrievalPolicy(allow_private_networks=True)`.
92
+
93
+ These checks are defense in depth for a reusable client, not a complete hosted-service SSRF sandbox. A service accepting untrusted URLs still needs network egress controls and DNS-rebinding defenses. Importing the package never performs network access.
94
+
95
+ ## Known limitations
96
+
97
+ - This release analyzes one snapshot only; it has no subscriptions, persistence, `poll_once()`, history, rolling windows, or watch loop.
98
+ - VADER is lexical and English-oriented; it can miss domain context, irony, and nuanced language.
99
+ - Full article pages are not fetched. Only feed titles and summaries/content are analyzed.
100
+ - Malformed XML is rejected at document level. Recoverable missing or invalid entry metadata is represented by structured warnings.
101
+
102
+ ## Development
103
+
104
+ ```console
105
+ python -m pip install -e ".[dev]"
106
+ python -m pytest
107
+ python -m ruff check .
108
+ python -m mypy
109
+ python -m build
110
+ python -m twine check --strict dist/*
111
+ ```
112
+
113
+ Tests use local fixtures and controlled HTTP doubles; the normal suite does not require live public feeds.
114
+
115
+ The `Quality Gates` GitHub Actions workflow runs pytest on Python 3.12, 3.13, and 3.14, then runs Ruff and mypy before validating clean wheel and source-distribution installations. CI retains seven-day diagnostic artifacts named `test-results-python-<version>`, `static-check-results`, and `package-validation-results`. Successfully validated wheel and source-distribution files are uploaded separately as `python-package-distributions` for a future publishing workflow; generated `dist/` files remain local/CI artifacts and are not committed to Git.
116
+
117
+ Production publishing setup and the maintainer release procedure are documented in [docs/releasing.md](docs/releasing.md).
@@ -0,0 +1,19 @@
1
+ feed_sentiment/__init__.py,sha256=36E7WrtUCoVgUPmqboiZmAdgEv4SmYL2xcmbHcYypyE,1335
2
+ feed_sentiment/_metadata.py,sha256=aXjq5S3L782MH41-KqXqKRDrGeZpMkH9Csq8aVOSf9c,590
3
+ feed_sentiment/models.py,sha256=8AmqeHiS7wJYow1PKk0QA9EbZbwxo5pyqeNnjPBgQYU,3361
4
+ feed_sentiment/analyzers/__init__.py,sha256=7lSmCoyokuVrHd32yULkuyc4NjRcYXU9fwu9KC_SnXM,191
5
+ feed_sentiment/analyzers/base.py,sha256=9-0Xivu0eaToytA-dOzSKaCSzfFnU9zuPfk33iJdBk4,402
6
+ feed_sentiment/analyzers/vader.py,sha256=0M4bjJvgBPirbjSpVv8EUCyWghN8ciA2p0zWAWdZsjI,1038
7
+ feed_sentiment/cli/__init__.py,sha256=fRqUYl-OUx8NkFLMf9671zR4H0Zc5ph1eDLk5MorjNQ,58
8
+ feed_sentiment/cli/app.py,sha256=gtyCu9IISpBk_6RflPk5B2BwAvxNkz8KcGDIVeUUIgo,2805
9
+ feed_sentiment/exceptions/__init__.py,sha256=3ntYaI4QHGSO7PgATGBcnEaOfQwEVgkE9QOkr_UgK7s,935
10
+ feed_sentiment/feeds/__init__.py,sha256=HubpPmDgec8QxsgAcCERY63MFRSJMV25JVRpbeybauQ,433
11
+ feed_sentiment/feeds/parser.py,sha256=5guTjOEDT_LbkIrWgMR3ahR59UKZ9DQKLGy5bHdawaY,4160
12
+ feed_sentiment/feeds/retriever.py,sha256=VQxPnUzGPWuCsHCe-AffeEt5PBxLut_PGMAvQzf4BP8,5081
13
+ feed_sentiment/services/__init__.py,sha256=rv9rYjfIe7X1wyy2i8Dygwuj-CD7GNBSUNBowdQQ90E,213
14
+ feed_sentiment/services/analysis.py,sha256=K6o9_3rccUpfZxNnY7STa2w1LK-EXh-kM67aQnpP-IU,3606
15
+ feed_sentiment-0.1.0.dist-info/METADATA,sha256=3XjklHdxVhPhd35uuXujZNrv79QhJX0EEKiUP-d1fF8,7014
16
+ feed_sentiment-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
17
+ feed_sentiment-0.1.0.dist-info/entry_points.txt,sha256=oz3XW9g2PiA1iHwtUGAgvtONV9fdBOqQL4U1bGEK-lo,62
18
+ feed_sentiment-0.1.0.dist-info/licenses/LICENSE,sha256=FPoTe9ec8eUoYt01Su1ckgs34jBJAcuUMkzSocyqVyQ,1070
19
+ feed_sentiment-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ feed-sentiment = feed_sentiment.cli.app:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kurtpatrickyu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.