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.
- feed_sentiment/__init__.py +58 -0
- feed_sentiment/_metadata.py +18 -0
- feed_sentiment/analyzers/__init__.py +4 -0
- feed_sentiment/analyzers/base.py +15 -0
- feed_sentiment/analyzers/vader.py +31 -0
- feed_sentiment/cli/__init__.py +3 -0
- feed_sentiment/cli/app.py +93 -0
- feed_sentiment/exceptions/__init__.py +34 -0
- feed_sentiment/feeds/__init__.py +19 -0
- feed_sentiment/feeds/parser.py +125 -0
- feed_sentiment/feeds/retriever.py +135 -0
- feed_sentiment/models.py +131 -0
- feed_sentiment/services/__init__.py +8 -0
- feed_sentiment/services/analysis.py +106 -0
- feed_sentiment-0.1.0.dist-info/METADATA +117 -0
- feed_sentiment-0.1.0.dist-info/RECORD +19 -0
- feed_sentiment-0.1.0.dist-info/WHEEL +4 -0
- feed_sentiment-0.1.0.dist-info/entry_points.txt +2 -0
- feed_sentiment-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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")
|
feed_sentiment/models.py
ADDED
|
@@ -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,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,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.
|