fix(bluesky): make Bluesky opt-in with app password auth
searchPosts endpoint now returns 403 for unauthenticated requests. Add session auth via createSession, gate on BSKY_HANDLE + BSKY_APP_PASSWORD env vars. When unconfigured, Bluesky is completely invisible (no error). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -519,6 +519,7 @@ def _search_bluesky(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
depth: str,
|
||||
config: dict = None,
|
||||
) -> tuple:
|
||||
"""Search Bluesky via AT Protocol (runs in thread).
|
||||
|
||||
@@ -529,7 +530,7 @@ def _search_bluesky(
|
||||
|
||||
try:
|
||||
response = bluesky.search_bluesky(
|
||||
topic, from_date, to_date, depth=depth,
|
||||
topic, from_date, to_date, depth=depth, config=config,
|
||||
)
|
||||
except Exception as e:
|
||||
return [], f"{type(e).__name__}: {e}"
|
||||
@@ -1062,7 +1063,7 @@ def run_research(
|
||||
|
||||
if do_bluesky:
|
||||
bluesky_future = executor.submit(
|
||||
_search_bluesky, topic, from_date, to_date, depth
|
||||
_search_bluesky, topic, from_date, to_date, depth, config
|
||||
)
|
||||
|
||||
if do_polymarket:
|
||||
@@ -1497,6 +1498,9 @@ def main():
|
||||
# Auto-detect Xiaohongshu HTTP API (requires service + login)
|
||||
has_xiaohongshu = env.is_xiaohongshu_available(config)
|
||||
|
||||
# Auto-detect Bluesky (requires BSKY_HANDLE + BSKY_APP_PASSWORD)
|
||||
has_bluesky = env.is_bluesky_available(config)
|
||||
|
||||
# --diagnose: show source availability and exit
|
||||
if args.diagnose:
|
||||
web_source = env.get_web_search_source(config)
|
||||
@@ -1514,7 +1518,7 @@ def main():
|
||||
"xiaohongshu": has_xiaohongshu,
|
||||
"xiaohongshu_api_base": env.get_xiaohongshu_api_base(config),
|
||||
"hackernews": True,
|
||||
"bluesky": True,
|
||||
"bluesky": has_bluesky,
|
||||
"polymarket": True,
|
||||
"web_search_backend": web_source,
|
||||
"parallel_ai": bool(config.get("PARALLEL_API_KEY")),
|
||||
@@ -1630,7 +1634,7 @@ def main():
|
||||
|
||||
# Apply --search flag: restrict sources to the specified subset
|
||||
search_do_hackernews = True
|
||||
search_do_bluesky = True
|
||||
search_do_bluesky = has_bluesky
|
||||
search_do_polymarket = True
|
||||
search_run_youtube = has_ytdlp
|
||||
search_run_tiktok = has_tiktok
|
||||
@@ -1641,7 +1645,7 @@ def main():
|
||||
has_reddit = "reddit" in search_sources
|
||||
has_x = "x" in search_sources
|
||||
search_do_hackernews = "hn" in search_sources
|
||||
search_do_bluesky = "bluesky" in search_sources or "bsky" in search_sources
|
||||
search_do_bluesky = ("bluesky" in search_sources or "bsky" in search_sources) and has_bluesky
|
||||
search_do_polymarket = "polymarket" in search_sources
|
||||
search_run_youtube = "youtube" in search_sources and has_ytdlp
|
||||
search_run_tiktok = "tiktok" in search_sources and has_tiktok
|
||||
|
||||
+60
-5
@@ -1,7 +1,7 @@
|
||||
"""Bluesky search via AT Protocol (free, no auth required).
|
||||
"""Bluesky search via AT Protocol (requires app password).
|
||||
|
||||
Uses public.api.bsky.app for post discovery.
|
||||
No API key needed - just HTTP calls via stdlib urllib.
|
||||
Uses bsky.social for auth and public.api.bsky.app for post search.
|
||||
Requires BSKY_HANDLE and BSKY_APP_PASSWORD env vars.
|
||||
"""
|
||||
|
||||
import math
|
||||
@@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import http
|
||||
|
||||
BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
|
||||
BSKY_SEARCH_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.searchPosts"
|
||||
|
||||
DEPTH_CONFIG = {
|
||||
@@ -20,6 +21,9 @@ DEPTH_CONFIG = {
|
||||
"deep": 60,
|
||||
}
|
||||
|
||||
# Module-level token cache (valid for the lifetime of a single research run)
|
||||
_cached_token: Optional[str] = None
|
||||
|
||||
|
||||
def _log(msg: str):
|
||||
"""Log to stderr (only in TTY mode to avoid cluttering Claude Code output)."""
|
||||
@@ -28,6 +32,39 @@ def _log(msg: str):
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
def _create_session(handle: str, app_password: str) -> Optional[str]:
|
||||
"""Create an AT Protocol session and return the access token.
|
||||
|
||||
Args:
|
||||
handle: Bluesky handle (e.g. user.bsky.social)
|
||||
app_password: App password from bsky.app/settings/app-passwords
|
||||
|
||||
Returns:
|
||||
Access JWT string, or None on failure.
|
||||
"""
|
||||
global _cached_token
|
||||
if _cached_token:
|
||||
return _cached_token
|
||||
|
||||
try:
|
||||
response = http.request(
|
||||
"POST",
|
||||
BSKY_SESSION_URL,
|
||||
json_data={"identifier": handle, "password": app_password},
|
||||
timeout=15,
|
||||
)
|
||||
token = response.get("accessJwt")
|
||||
if token:
|
||||
_cached_token = token
|
||||
_log("Session created successfully")
|
||||
return token
|
||||
_log("No accessJwt in session response")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log(f"Session creation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _extract_core_subject(topic: str) -> str:
|
||||
"""Extract core subject from verbose query for Bluesky search."""
|
||||
text = topic.lower().strip()
|
||||
@@ -73,18 +110,32 @@ def search_bluesky(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
depth: str = "default",
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Search Bluesky via AT Protocol public API.
|
||||
"""Search Bluesky via AT Protocol API.
|
||||
|
||||
Args:
|
||||
topic: Search topic
|
||||
from_date: Start date (YYYY-MM-DD)
|
||||
to_date: End date (YYYY-MM-DD)
|
||||
depth: 'quick', 'default', or 'deep'
|
||||
config: Config dict with BSKY_HANDLE and BSKY_APP_PASSWORD
|
||||
|
||||
Returns:
|
||||
Dict with 'posts' list from AT Protocol response.
|
||||
"""
|
||||
config = config or {}
|
||||
handle = config.get("BSKY_HANDLE", "")
|
||||
app_password = config.get("BSKY_APP_PASSWORD", "")
|
||||
|
||||
if not handle or not app_password:
|
||||
return {"posts": [], "error": "Bluesky credentials not configured"}
|
||||
|
||||
# Authenticate
|
||||
token = _create_session(handle, app_password)
|
||||
if not token:
|
||||
return {"posts": [], "error": "Bluesky auth failed"}
|
||||
|
||||
count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||
core_topic = _extract_core_subject(topic)
|
||||
|
||||
@@ -99,7 +150,11 @@ def search_bluesky(
|
||||
url = f"{BSKY_SEARCH_URL}?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
response = http.request("GET", url, timeout=30)
|
||||
response = http.request(
|
||||
"GET", url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30,
|
||||
)
|
||||
except http.HTTPError as e:
|
||||
_log(f"Search failed: {e}")
|
||||
return {"posts": [], "error": str(e)}
|
||||
|
||||
+5
-3
@@ -255,6 +255,8 @@ def get_config() -> Dict[str, Any]:
|
||||
('APIFY_API_TOKEN', None),
|
||||
('AUTH_TOKEN', None),
|
||||
('CT0', None),
|
||||
('BSKY_HANDLE', None),
|
||||
('BSKY_APP_PASSWORD', None),
|
||||
]
|
||||
|
||||
for key, default in keys:
|
||||
@@ -480,12 +482,12 @@ def is_hackernews_available() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_bluesky_available() -> bool:
|
||||
def is_bluesky_available(config: Dict[str, Any]) -> bool:
|
||||
"""Check if Bluesky source is available.
|
||||
|
||||
Always returns True - AT Protocol search is free, no key needed.
|
||||
Requires BSKY_HANDLE and BSKY_APP_PASSWORD (app password from bsky.app/settings).
|
||||
"""
|
||||
return True
|
||||
return bool(config.get('BSKY_HANDLE') and config.get('BSKY_APP_PASSWORD'))
|
||||
|
||||
|
||||
def is_polymarket_available() -> bool:
|
||||
|
||||
Reference in New Issue
Block a user