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