feat(bluesky): add Bluesky/AT Protocol as social source

Free, no-auth-required search via public.api.bsky.app.
Always-on like HN and Polymarket (no API key needed).

- New scripts/lib/bluesky.py: search + parse via AT Protocol
- BlueskyItem schema, normalization, scoring, deduplication
- Wired into orchestrator ThreadPoolExecutor with timeout config
- Rendering in compact, full, and JSON output modes
- 14 unit tests covering parsing, dates, relevance, edge cases
- --search=bluesky / --search=bsky for bluesky-only mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-09 22:54:20 -07:00
parent 4b7087e136
commit 9a1059ee9d
9 changed files with 586 additions and 13 deletions
+42
View File
@@ -355,6 +355,43 @@ class HackerNewsItem:
return d
@dataclass
class BlueskyItem:
"""Normalized Bluesky post."""
id: str # "BS1", "BS2", ...
text: str
url: str # bsky.app permalink
author_handle: str # user.bsky.social
display_name: str
date: Optional[str] = None
date_confidence: str = "high" # AT Protocol has exact timestamps
engagement: Optional[Engagement] = None # likes, reposts, replies, quotes
relevance: float = 0.5
why_relevant: str = ""
subs: SubScores = field(default_factory=SubScores)
score: int = 0
cross_refs: List[str] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
d = {
'id': self.id,
'text': self.text,
'url': self.url,
'author_handle': self.author_handle,
'display_name': self.display_name,
'date': self.date,
'date_confidence': self.date_confidence,
'engagement': self.engagement.to_dict() if self.engagement else None,
'relevance': self.relevance,
'why_relevant': self.why_relevant,
'subs': self.subs.to_dict(),
'score': self.score,
}
if self.cross_refs:
d['cross_refs'] = self.cross_refs
return d
@dataclass
class PolymarketItem:
"""Normalized Polymarket prediction market item."""
@@ -415,6 +452,7 @@ class Report:
tiktok: List[TikTokItem] = field(default_factory=list)
instagram: List[InstagramItem] = field(default_factory=list)
hackernews: List[HackerNewsItem] = field(default_factory=list)
bluesky: List[BlueskyItem] = field(default_factory=list)
polymarket: List[PolymarketItem] = field(default_factory=list)
best_practices: List[str] = field(default_factory=list)
prompt_pack: List[str] = field(default_factory=list)
@@ -427,6 +465,7 @@ class Report:
tiktok_error: Optional[str] = None
instagram_error: Optional[str] = None
hackernews_error: Optional[str] = None
bluesky_error: Optional[str] = None
polymarket_error: Optional[str] = None
# Handle resolution
resolved_x_handle: Optional[str] = None
@@ -452,6 +491,7 @@ class Report:
'tiktok': [t.to_dict() for t in self.tiktok],
'instagram': [ig.to_dict() for ig in self.instagram],
'hackernews': [h.to_dict() for h in self.hackernews],
'bluesky': [b.to_dict() for b in self.bluesky],
'polymarket': [p.to_dict() for p in self.polymarket],
'best_practices': self.best_practices,
'prompt_pack': self.prompt_pack,
@@ -473,6 +513,8 @@ class Report:
d['instagram_error'] = self.instagram_error
if self.hackernews_error:
d['hackernews_error'] = self.hackernews_error
if self.bluesky_error:
d['bluesky_error'] = self.bluesky_error
if self.polymarket_error:
d['polymarket_error'] = self.polymarket_error
if self.from_cache: