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
+45 -1
View File
@@ -4,7 +4,7 @@ from typing import Any, Dict, List, TypeVar, Union
from . import dates, schema
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.InstagramItem, schema.HackerNewsItem, schema.PolymarketItem)
T = TypeVar("T", schema.RedditItem, schema.XItem, schema.WebSearchItem, schema.YouTubeItem, schema.TikTokItem, schema.InstagramItem, schema.HackerNewsItem, schema.BlueskyItem, schema.PolymarketItem)
def filter_by_date_range(
@@ -350,6 +350,50 @@ def normalize_hackernews_items(
return normalized
def normalize_bluesky_items(
items: List[Dict[str, Any]],
from_date: str,
to_date: str,
) -> List[schema.BlueskyItem]:
"""Normalize raw Bluesky items to schema.
Args:
items: Raw Bluesky items from AT Protocol API
from_date: Start of date range
to_date: End of date range
Returns:
List of BlueskyItem objects
"""
normalized = []
for i, item in enumerate(items):
eng_raw = item.get("engagement") or {}
engagement = schema.Engagement(
likes=eng_raw.get("likes"),
reposts=eng_raw.get("reposts"),
replies=eng_raw.get("replies"),
quotes=eng_raw.get("quotes"),
)
date_str = item.get("date")
normalized.append(schema.BlueskyItem(
id=f"BS{i+1}",
text=item.get("text", ""),
url=item.get("url", ""),
author_handle=item.get("handle", ""),
display_name=item.get("display_name", ""),
date=date_str,
date_confidence="high",
engagement=engagement,
relevance=item.get("relevance", 0.5),
why_relevant=item.get("why_relevant", ""),
))
return normalized
def normalize_polymarket_items(
items: List[Dict[str, Any]],
from_date: str,