返回 last30days-skill
truthsocial.py
根目录 / skills / last30days / scripts / lib / truthsocial.py
1 """Truth Social search via Mastodon-compatible API (requires bearer token).
2
3 Uses truthsocial.com/api/v2/search endpoint.
4 Requires TRUTHSOCIAL_TOKEN env var (bearer token from browser dev tools).
5 """
6
7 import math
8 import re
9 import sys
10 from typing import Any, Dict, List, Optional
11
12 from . import http, log
13
14 TRUTHSOCIAL_SEARCH_URL = "https://truthsocial.com/api/v2/search"
15
16 DEPTH_CONFIG = {
17 "quick": 15,
18 "default": 30,
19 "deep": 60,
20 }
21
22
23 def _log(msg: str):
24 log.source_log("TruthSocial", msg, tty_only=False)
25
26
27 def _strip_html(html: str) -> str:
28 """Strip HTML tags from Truth Social post content."""
29 text = re.sub(r'<br\s*/?>', '\n', html)
30 text = re.sub(r'<[^>]+>', '', text)
31 return text.strip()
32
33
34 def _extract_core_subject(topic: str) -> str:
35 """Extract core subject from verbose query for Truth Social search."""
36 from .query import SOCIAL_NOISE, extract_core_subject
37 return extract_core_subject(topic, noise=SOCIAL_NOISE)
38
39
40 def _parse_date(status: Dict[str, Any]) -> Optional[str]:
41 """Parse date from Mastodon status to YYYY-MM-DD.
42
43 Mastodon uses ISO 8601 format in created_at field.
44 """
45 val = status.get("created_at")
46 if val and isinstance(val, str) and len(val) >= 10:
47 return val[:10]
48 return None
49
50
51 def search_truthsocial(
52 topic: str,
53 from_date: str,
54 to_date: str,
55 depth: str = "default",
56 config: Optional[Dict[str, Any]] = None,
57 ) -> Dict[str, Any]:
58 """Search Truth Social via Mastodon-compatible API.
59
60 Args:
61 topic: Search topic
62 from_date: Start date (YYYY-MM-DD)
63 to_date: End date (YYYY-MM-DD)
64 depth: 'quick', 'default', or 'deep'
65 config: Config dict with TRUTHSOCIAL_TOKEN
66
67 Returns:
68 Dict with 'statuses' list from Mastodon API response.
69 """
70 config = config or {}
71 token = config.get("TRUTHSOCIAL_TOKEN", "")
72
73 if not token:
74 return {"statuses": [], "error": "Truth Social token not configured"}
75
76 count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
77 core_topic = _extract_core_subject(topic)
78
79 _log(f"Searching for '{core_topic}' (depth={depth}, limit={count})")
80
81 from urllib.parse import urlencode
82 params = {
83 "q": core_topic,
84 "type": "statuses",
85 "limit": str(min(count, 40)),
86 }
87 url = f"{TRUTHSOCIAL_SEARCH_URL}?{urlencode(params)}"
88
89 try:
90 response = http.request(
91 "GET", url,
92 headers={
93 "Authorization": f"Bearer {token}",
94 # Cloudflare 403s the skill's default User-Agent regardless of token validity (#909).
95 # Reuse http.BROWSER_USER_AGENT, as the keyless Reddit path does.
96 "User-Agent": http.BROWSER_USER_AGENT,
97 "Accept": "application/json, text/plain, */*",
98 "Accept-Language": "en-US,en;q=0.9",
99 "Referer": "https://truthsocial.com/",
100 },
101 timeout=30,
102 )
103 except http.HTTPError as e:
104 if e.status_code == 401:
105 _log("Token expired")
106 return {"statuses": [], "error": "Truth Social token expired"}
107 elif e.status_code == 403:
108 _log("Access denied (Cloudflare)")
109 return {"statuses": [], "error": "Truth Social access denied (Cloudflare)"}
110 elif e.status_code == 429:
111 _log("Rate limited")
112 return {"statuses": [], "error": "Truth Social rate limited"}
113 else:
114 _log(f"Search failed: {e}")
115 return {"statuses": [], "error": f"Truth Social search failed: {e.status_code}"}
116 except Exception as e:
117 _log(f"Search failed: {e}")
118 return {"statuses": [], "error": str(e)}
119
120 statuses = response.get("statuses", [])
121 _log(f"Found {len(statuses)} posts")
122 return response
123
124
125 def parse_truthsocial_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
126 """Parse Mastodon API response into normalized item dicts.
127
128 Returns:
129 List of item dicts ready for normalization.
130 """
131 statuses = response.get("statuses", [])
132 items = []
133
134 for i, status in enumerate(statuses):
135 content_html = status.get("content") or ""
136 text = _strip_html(content_html)
137
138 account = status.get("account") or {}
139 handle = account.get("acct") or account.get("username") or ""
140 display_name = account.get("display_name") or handle
141
142 url = status.get("url") or ""
143
144 likes = status.get("favourites_count") or 0
145 reposts = status.get("reblogs_count") or 0
146 replies = status.get("replies_count") or 0
147
148 date_str = _parse_date(status)
149
150 # Relevance: position-based (search results are ranked by relevance)
151 rank_score = max(0.3, 1.0 - (i * 0.02))
152 engagement_boost = min(0.2, math.log1p(likes + reposts) / 40)
153 relevance = min(1.0, rank_score * 0.7 + engagement_boost + 0.1)
154
155 items.append({
156 "handle": handle,
157 "display_name": display_name,
158 "text": text,
159 "url": url,
160 "date": date_str,
161 "engagement": {
162 "likes": likes,
163 "reposts": reposts,
164 "replies": replies,
165 },
166 "relevance": round(relevance, 2),
167 "why_relevant": f"Truth Social: @{handle}: {text[:60]}" if text else f"Truth Social: {handle}",
168 })
169
170 return items
171
171 lines PYTHON