返回 last30days-skill
bluesky.py
根目录 / skills / last30days / scripts / lib / bluesky.py
1 """Bluesky search via AT Protocol (requires app password).
2
3 Uses bsky.social for auth and api.bsky.app for post search (the canonical
4 authenticated AppView). The previous default `public.api.bsky.app` is the
5 unauthenticated public mirror, which BunnyCDN now blocks for searchPosts
6 regardless of auth header (verified 2026-05-04). Override the search host
7 via BSKY_SEARCH_HOST env var if Bluesky migrates infrastructure again.
8
9 Requires BSKY_HANDLE and BSKY_APP_PASSWORD env vars. App passwords are
10 19-char xxxx-xxxx-xxxx-xxxx; generate at bsky.app/settings/app-passwords.
11 The createSession endpoint accepts main-account passwords too, but they're
12 bad hygiene (no scope, can't revoke individually).
13 """
14
15 import math
16 import os
17 import re
18 import sys
19 import time
20 from datetime import datetime, timezone
21 from typing import Any, Dict, List, Optional
22
23 from . import http, log
24
25 BSKY_SESSION_URL = "https://bsky.social/xrpc/com.atproto.server.createSession"
26 _DEFAULT_BSKY_SEARCH_HOST = "api.bsky.app"
27
28
29 def _resolve_search_url(config: Optional[Dict[str, Any]] = None) -> str:
30 """Resolve the Bluesky search URL with BSKY_SEARCH_HOST override.
31
32 Default is api.bsky.app. Override via BSKY_SEARCH_HOST in shell env or
33 .env file. The project's env.py loads .env into config but not into
34 os.environ, so check both — same hybrid pattern as last30days.py for
35 LAST30DAYS_STORE.
36
37 Hardens user-supplied host values against three common mis-configurations:
38 whitespace (e.g. " api.bsky.app "), embedded path components (e.g.
39 "api.bsky.app/xrpc/proxy") that would double the /xrpc/ segment, and
40 embedded scheme prefixes (e.g. "https://api.bsky.app"). On any of these
41 we log a warning and fall back to the default rather than building an
42 invalid URL with an opaque downstream error.
43 """
44 config = config or {}
45 raw = (
46 os.environ.get("BSKY_SEARCH_HOST")
47 or config.get("BSKY_SEARCH_HOST")
48 or _DEFAULT_BSKY_SEARCH_HOST
49 )
50 host = raw.strip().rstrip("/")
51 # Strip embedded scheme so users who paste full URLs do not break the f-string.
52 for prefix in ("https://", "http://"):
53 if host.lower().startswith(prefix):
54 host = host[len(prefix):]
55 break
56 if not host or "/" in host or " " in host:
57 # Embedded path or whitespace remains — don't trust it. Default + log.
58 if raw != _DEFAULT_BSKY_SEARCH_HOST:
59 _log(
60 f"BSKY_SEARCH_HOST={raw!r} is not a bare hostname; "
61 f"falling back to default {_DEFAULT_BSKY_SEARCH_HOST!r}"
62 )
63 host = _DEFAULT_BSKY_SEARCH_HOST
64 return f"https://{host}/xrpc/app.bsky.feed.searchPosts"
65
66
67 # App-password format: xxxx-xxxx-xxxx-xxxx (19 chars, lowercase alphanumeric
68 # with three hyphens at fixed positions).
69 _APP_PASSWORD_RE = re.compile(r"^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$")
70
71
72 def _validate_app_password_format(value) -> bool:
73 """Return True if value matches Bluesky's 19-char app-password format.
74
75 False for non-strings (None, int, list) so callers passing config dict
76 values directly don't crash. Detect-but-not-gate: the createSession
77 endpoint also accepts main-account passwords, so failing this check is
78 a hygiene smell, not a hard error.
79 """
80 if not isinstance(value, str):
81 return False
82 return bool(_APP_PASSWORD_RE.fullmatch(value))
83
84
85 DEPTH_CONFIG = {
86 "quick": 15,
87 "default": 30,
88 "deep": 60,
89 }
90
91 # Module-level token cache (valid for the lifetime of a single research run)
92 _cached_token: Optional[str] = None
93 _token_created_at: float = 0.0
94 _session_error: Optional[str] = None
95 _TOKEN_MAX_AGE_SECONDS = 5400 # 90 minutes (conservative, tokens last ~2 hours)
96
97
98 def _log(msg: str):
99 log.source_log("Bluesky", msg, tty_only=False)
100
101
102 def _create_session(handle: str, app_password: str) -> Optional[str]:
103 """Create an AT Protocol session and return the access token.
104
105 Args:
106 handle: Bluesky handle (e.g. user.bsky.social)
107 app_password: App password from bsky.app/settings/app-passwords
108
109 Returns:
110 Access JWT string, or None on failure. Sets _session_error on failure.
111 """
112 global _cached_token, _token_created_at, _session_error
113 if _cached_token and (time.monotonic() - _token_created_at < _TOKEN_MAX_AGE_SECONDS):
114 return _cached_token
115 if _cached_token:
116 _log("Session token expired, re-authenticating")
117 _cached_token = None
118 _token_created_at = 0.0
119
120 try:
121 response = http.request(
122 "POST",
123 BSKY_SESSION_URL,
124 json_data={"identifier": handle, "password": app_password},
125 timeout=15,
126 )
127 token = response.get("accessJwt")
128 if token:
129 _cached_token = token
130 _token_created_at = time.monotonic()
131 _session_error = None
132 _log("Session created successfully")
133 return token
134 _log("No accessJwt in session response")
135 _session_error = "No accessJwt in session response"
136 return None
137 except http.HTTPError as e:
138 if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
139 _session_error = "Cloudflare blocked the request (403 Forbidden). This is a network-level block, not an auth issue. Try a different network or VPN."
140 elif e.status_code == 401:
141 _session_error = "Invalid credentials (401 Unauthorized). Check BSKY_HANDLE and BSKY_APP_PASSWORD."
142 else:
143 _session_error = f"Session request failed: {e}"
144 _log(f"Session creation failed: {_session_error}")
145 return None
146 except Exception as e:
147 _session_error = f"Session request failed: {type(e).__name__}: {e}"
148 _log(f"Session creation failed: {_session_error}")
149 return None
150
151
152 def _reset_session_cache() -> None:
153 global _cached_token, _token_created_at, _session_error
154 _cached_token = None
155 _token_created_at = 0.0
156 _session_error = None
157
158
159 def _extract_core_subject(topic: str) -> str:
160 """Extract core subject from verbose query for Bluesky search."""
161 from .query import SOCIAL_NOISE, extract_core_subject
162 return extract_core_subject(topic, noise=SOCIAL_NOISE)
163
164
165 def _parse_date(item: Dict[str, Any]) -> Optional[str]:
166 """Parse date from Bluesky post to YYYY-MM-DD.
167
168 AT Protocol uses ISO 8601 format in indexedAt and createdAt fields.
169 """
170 for key in ("indexedAt", "createdAt"):
171 val = item.get(key)
172 if val and isinstance(val, str):
173 try:
174 dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
175 return dt.strftime("%Y-%m-%d")
176 except (ValueError, TypeError):
177 pass
178 return None
179
180
181 def search_bluesky(
182 topic: str,
183 from_date: str,
184 to_date: str,
185 depth: str = "default",
186 config: Optional[Dict[str, Any]] = None,
187 ) -> Dict[str, Any]:
188 """Search Bluesky via AT Protocol API.
189
190 Args:
191 topic: Search topic
192 from_date: Start date (YYYY-MM-DD)
193 to_date: End date (YYYY-MM-DD)
194 depth: 'quick', 'default', or 'deep'
195 config: Config dict with BSKY_HANDLE and BSKY_APP_PASSWORD
196
197 Returns:
198 Dict with 'posts' list from AT Protocol response.
199 """
200 config = config or {}
201 handle = config.get("BSKY_HANDLE", "")
202 app_password = config.get("BSKY_APP_PASSWORD", "")
203
204 if not handle or not app_password:
205 return {"posts": [], "error": "Bluesky credentials not configured"}
206
207 # One-shot hygiene warning if BSKY_APP_PASSWORD is not in app-password
208 # form. createSession accepts main-account passwords too — but main
209 # passwords have no scope (full account access), can't be revoked
210 # individually, and rotating them breaks every service that holds them.
211 # We warn but do not gate, matching the project's detect-don't-block
212 # philosophy elsewhere.
213 if not _validate_app_password_format(app_password):
214 _log(
215 "BSKY_APP_PASSWORD does not look like an app password "
216 "(expected xxxx-xxxx-xxxx-xxxx, 19 chars). It may be a main "
217 "account password — those work but are bad hygiene. Generate "
218 "an app password at https://bsky.app/settings/app-passwords"
219 )
220
221 count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
222 core_topic = _extract_core_subject(topic)
223
224 _log(f"Searching for '{core_topic}' (depth={depth}, limit={count})")
225
226 from urllib.parse import urlencode
227 params = {
228 "q": core_topic,
229 "limit": str(min(count, 100)),
230 "sort": "top",
231 }
232 url = f"{_resolve_search_url(config)}?{urlencode(params)}"
233
234 def _auth_and_search() -> tuple[Optional[Dict[str, Any]], Optional[str]]:
235 token = _create_session(handle, app_password)
236 if not token:
237 error_msg = _session_error or "Bluesky session creation failed (unknown error)"
238 return None, error_msg
239 try:
240 response = http.request(
241 "GET", url,
242 headers={"Authorization": f"Bearer {token}"},
243 timeout=30,
244 )
245 return response, None
246 except http.HTTPError as e:
247 _log(f"Search failed: {e}")
248 if e.status_code == 401:
249 _reset_session_cache()
250 return None, "refresh"
251 if e.status_code == 403 and e.body and "cloudflare" in e.body.lower():
252 return None, "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."
253 return None, f"Bluesky search failed: {e}"
254 except Exception as e:
255 _log(f"Search failed: {e}")
256 return None, f"Bluesky search failed: {type(e).__name__}: {e}"
257
258 response, error_msg = _auth_and_search()
259 if error_msg == "refresh":
260 _log("Session expired; recreating token and retrying once")
261 response, error_msg = _auth_and_search()
262 if error_msg:
263 return {"posts": [], "error": error_msg}
264 if response is None:
265 return {"posts": [], "error": "Bluesky search failed (unknown error)"}
266
267 posts = response.get("posts", [])
268 _log(f"Found {len(posts)} posts")
269 return response
270
271
272 def parse_bluesky_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
273 """Parse AT Protocol response into normalized item dicts.
274
275 Returns:
276 List of item dicts ready for normalization.
277 """
278 posts = response.get("posts", [])
279 items = []
280
281 for i, post in enumerate(posts):
282 record = post.get("record") or {}
283 text = record.get("text") or ""
284
285 author = post.get("author") or {}
286 handle = author.get("handle") or ""
287 display_name = author.get("displayName") or handle
288
289 # Post URI -> URL
290 # URI format: at://did:plc:xxx/app.bsky.feed.post/rkey
291 uri = post.get("uri") or ""
292 rkey = uri.rsplit("/", 1)[-1] if uri else ""
293 url = f"https://bsky.app/profile/{handle}/post/{rkey}" if handle and rkey else ""
294
295 likes = post.get("likeCount") or 0
296 reposts = post.get("repostCount") or 0
297 replies = post.get("replyCount") or 0
298 quotes = post.get("quoteCount") or 0
299
300 date_str = _parse_date(post) or _parse_date(record)
301
302 # Relevance: position-based (AT Protocol sorts by relevance with sort=top)
303 rank_score = max(0.3, 1.0 - (i * 0.02))
304 engagement_boost = min(0.2, math.log1p(likes + reposts) / 40)
305 relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
306
307 items.append({
308 "handle": handle,
309 "display_name": display_name,
310 "text": text,
311 "url": url,
312 "date": date_str,
313 "engagement": {
314 "likes": likes,
315 "reposts": reposts,
316 "replies": replies,
317 "quotes": quotes,
318 },
319 "relevance": round(relevance, 2),
320 "why_relevant": f"Bluesky: @{handle}: {text[:60]}" if text else f"Bluesky: {handle}",
321 })
322
323 return items
324
324 lines PYTHON