luckiest-co 1.0.47 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,374 @@
1
+ """StockTwits source for last30days — ticker/crypto topics only.
2
+
3
+ StockTwits is a cashtag-native social network for traders. Every message can
4
+ carry a self-reported Bullish/Bearish tag, which makes it uniquely good at one
5
+ thing the other sources can't quantify: a sentiment *ratio* and retail *volume*
6
+ on a specific symbol.
7
+
8
+ GATING: this source is only meaningful for financial topics. `detect_symbols()`
9
+ and `is_financial_topic()` are the gate — the pipeline must NOT register
10
+ stocktwits for a non-ticker topic (see INTEGRATION.md, step 3). Treat the output
11
+ as a direction/volume signal, never as analysis: StockTwits skews retail and
12
+ promotional, sentiment tags are self-reported, and bot/pump noise is common.
13
+
14
+ API: public, no auth. Symbol stream + symbol search endpoints. Unauthenticated
15
+ quota is ~200 requests/hour and is rate-limited per IP — keep pagination small.
16
+ Respect StockTwits' API terms if this is ever shipped beyond personal use.
17
+
18
+ NOTE: uses raw urllib rather than the shared `from . import http` helper.
19
+ Every call is wrapped in try/except and degrades to a partial/empty result, but
20
+ switching to the shared helper (429 Retry-After, retry budget, backoff) is a
21
+ known follow-up to match siblings like hackernews.py.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import datetime
27
+ import json
28
+ import re
29
+ import sys
30
+ import time
31
+ import urllib.parse
32
+ import urllib.request
33
+ from typing import Any
34
+
35
+ _UA = "Mozilla/5.0 (last30days stocktwits source)"
36
+ _STREAM_URL = "https://api.stocktwits.com/api/2/streams/symbol/{symbol}.json"
37
+ _SEARCH_URL = "https://api.stocktwits.com/api/2/search/symbols.json"
38
+
39
+ # Topic must look financial before we even try to resolve a symbol. This is the
40
+ # coarse gate; symbol resolution is the fine gate.
41
+ _FINANCE_HINTS = re.compile(
42
+ # Unambiguous finance vocabulary only. Bare "share/token/coin/bull/bear"
43
+ # were removed: they misfire on general topics ("share files", "token
44
+ # limits", "coin collecting", "bear attacks") and would inject stock chatter
45
+ # into non-financial runs.
46
+ r"\b(stock|stocks|ticker|cashtag|equit(?:y|ies)|price target|"
47
+ r"earnings|premarket|pre-?market|after\s?hours|dividend|valuation|"
48
+ r"crypto|altcoin|defi|market cap|bullish|bearish|"
49
+ # Unambiguous crypto names so "bitcoin price" gates without a cashtag.
50
+ # Short aliases (eth, sol, ada, doge, ripple) stay OUT of the gate: they
51
+ # collide with everyday topics (ETH Zurich, ADA compliance, doge memes);
52
+ # they still resolve via _CRYPTO_ALIASES once the gate fires another way.
53
+ r"bitcoin|btc|ethereum|solana|dogecoin|cardano|xrp|"
54
+ r"\$[A-Za-z]{1,5}(?:\.[A-Z])?)\b",
55
+ re.IGNORECASE,
56
+ )
57
+ _CASHTAG = re.compile(r"\$([A-Za-z]{1,5}(?:\.[A-Z])?)\b")
58
+
59
+ # A small built-in crypto map so we don't burn a symbol-search call on the
60
+ # obvious ones. StockTwits uses the `.X` suffix for crypto symbols.
61
+ _CRYPTO_ALIASES = {
62
+ "bitcoin": "BTC.X", "btc": "BTC.X",
63
+ "ethereum": "ETH.X", "eth": "ETH.X",
64
+ "solana": "SOL.X", "sol": "SOL.X",
65
+ "dogecoin": "DOGE.X", "doge": "DOGE.X",
66
+ "ripple": "XRP.X", "xrp": "XRP.X",
67
+ "cardano": "ADA.X", "ada": "ADA.X",
68
+ }
69
+
70
+
71
+ def _log(msg: str) -> None:
72
+ try:
73
+ from . import log as _enginelog
74
+ _enginelog.source_log("StockTwits", msg, tty_only=False)
75
+ except Exception: # standalone / outside package
76
+ print(f"[StockTwits] {msg}", file=sys.stderr)
77
+
78
+
79
+ def _get_json(url: str, timeout: int = 20) -> dict[str, Any]:
80
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
81
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
82
+ return json.load(resp)
83
+
84
+
85
+ # --------------------------------------------------------------------------- #
86
+ # Gating + symbol resolution #
87
+ # --------------------------------------------------------------------------- #
88
+
89
+ def is_financial_topic(topic: str) -> bool:
90
+ """Coarse gate: does the topic look like it's about a tradeable asset?"""
91
+ return bool(_CASHTAG.search(topic) or _FINANCE_HINTS.search(topic))
92
+
93
+
94
+ def detect_symbols(topic: str, *, resolve: bool = True, max_symbols: int = 2) -> list[str]:
95
+ """Resolve a topic to StockTwits symbols. Returns [] for non-financial topics.
96
+
97
+ Order of resolution:
98
+ 1. Explicit cashtags in the topic ($NOW, $BTC.X) — trusted as-is.
99
+ 2. Crypto name aliases (bitcoin -> BTC.X).
100
+ 3. StockTwits symbol-search API for a company/product name, but ONLY if the
101
+ topic also tripped the finance gate (so "Apple pie" never resolves AAPL).
102
+
103
+ `resolve=False` skips the network call (useful for the cheap gate check).
104
+ """
105
+ found: list[str] = []
106
+
107
+ for m in _CASHTAG.finditer(topic):
108
+ sym = m.group(1).upper()
109
+ if sym not in found:
110
+ found.append(sym)
111
+
112
+ lowered = topic.lower()
113
+ for alias, sym in _CRYPTO_ALIASES.items():
114
+ if re.search(rf"\b{re.escape(alias)}\b", lowered) and sym not in found:
115
+ found.append(sym)
116
+
117
+ if found:
118
+ return found[:max_symbols]
119
+
120
+ # No explicit symbol. Only hit the network if the topic looks financial.
121
+ if not resolve or not is_financial_topic(topic):
122
+ return []
123
+
124
+ # Strip finance noise words so the search query is just the entity name.
125
+ name = re.sub(
126
+ r"\b(stock|stocks|shares?|price|ticker|earnings|forecast|crypto|"
127
+ r"token|coin|news|today|now)\b",
128
+ "", topic, flags=re.IGNORECASE,
129
+ ).strip()
130
+ if not name:
131
+ return []
132
+ try:
133
+ url = _SEARCH_URL + "?" + urllib.parse.urlencode({"q": name})
134
+ data = _get_json(url)
135
+ for result in data.get("results", []):
136
+ sym = (result.get("symbol") or "").upper()
137
+ if sym and sym not in found:
138
+ found.append(sym)
139
+ if len(found) >= max_symbols:
140
+ break
141
+ if found:
142
+ _log(f"Resolved '{name}' -> {found}")
143
+ except Exception as e: # noqa: BLE001 — network/parse, degrade gracefully
144
+ _log(f"symbol search failed for '{name}': {e}")
145
+ return found[:max_symbols]
146
+
147
+
148
+ # --------------------------------------------------------------------------- #
149
+ # Fetch + parse #
150
+ # --------------------------------------------------------------------------- #
151
+
152
+ _DEPTH = {"quick": 30, "default": 60, "deep": 120}
153
+
154
+
155
+ def search_stocktwits(
156
+ topic_or_symbol: str,
157
+ from_date: str | None = None,
158
+ to_date: str | None = None,
159
+ *,
160
+ depth: str = "default",
161
+ ) -> dict[str, Any]:
162
+ """Fetch the symbol stream for the topic. Paginates by cursor up to `depth`.
163
+
164
+ Accepts either a raw topic (resolved via detect_symbols) or an explicit
165
+ symbol. Returns {"messages": [...], "symbols": [...], "watchlist": int}.
166
+ """
167
+ symbols = (
168
+ [topic_or_symbol.lstrip("$").upper()]
169
+ if _CASHTAG.fullmatch("$" + topic_or_symbol.lstrip("$"))
170
+ else detect_symbols(topic_or_symbol)
171
+ )
172
+ if not symbols:
173
+ return {"messages": [], "symbols": [], "error": "no symbol resolved"}
174
+
175
+ symbol = symbols[0] # primary symbol drives the stream
176
+ target = _DEPTH.get(depth, 60)
177
+ messages: list[dict[str, Any]] = []
178
+ watchlist = None
179
+ cursor_max = None
180
+ try:
181
+ while len(messages) < target:
182
+ url = _STREAM_URL.format(symbol=urllib.parse.quote(symbol))
183
+ if cursor_max:
184
+ url += f"?max={cursor_max}"
185
+ data = _get_json(url)
186
+ if watchlist is None:
187
+ watchlist = (data.get("symbol") or {}).get("watchlist_count")
188
+ batch = data.get("messages", [])
189
+ if not batch:
190
+ break
191
+ messages.extend(batch)
192
+ cursor = data.get("cursor", {})
193
+ if not cursor.get("more") or not cursor.get("max"):
194
+ break
195
+ cursor_max = cursor["max"]
196
+ time.sleep(0.8) # be polite to the unauth quota
197
+ except Exception as e: # noqa: BLE001
198
+ _log(f"stream fetch failed for {symbol}: {e}")
199
+ messages = _filter_by_date(messages, from_date, to_date)
200
+ return {
201
+ "messages": messages,
202
+ "symbols": symbols,
203
+ "error": str(e),
204
+ "freshness_window": {
205
+ "depth": depth,
206
+ "from_date": from_date,
207
+ "to_date": to_date,
208
+ },
209
+ }
210
+
211
+ messages = _filter_by_date(messages, from_date, to_date)
212
+ _log(f"{symbol}: {len(messages)} messages (watchlist {watchlist})")
213
+ return {
214
+ "messages": messages,
215
+ "symbols": symbols,
216
+ "watchlist": watchlist,
217
+ "freshness_window": {
218
+ "depth": depth,
219
+ "from_date": from_date,
220
+ "to_date": to_date,
221
+ },
222
+ }
223
+
224
+
225
+ def _filter_by_date(messages: list[dict], from_date: str | None, to_date: str | None) -> list[dict]:
226
+ if not (from_date or to_date):
227
+ return messages
228
+ out = []
229
+ for m in messages:
230
+ d = (m.get("created_at") or "")[:10]
231
+ if from_date and d and d < from_date:
232
+ continue
233
+ if to_date and d and d > to_date:
234
+ continue
235
+ out.append(m)
236
+ return out
237
+
238
+
239
+ def parse_stocktwits_response(response: dict[str, Any], query: str = "") -> list[dict[str, Any]]:
240
+ """Normalize the stream into engine-style item dicts (same keys as HN/Reddit).
241
+
242
+ Each item carries metadata.sentiment in {"Bullish","Bearish",None} and the
243
+ symbol-level bull/bear aggregate so synthesis can cite the ratio.
244
+ """
245
+ messages = response.get("messages", [])
246
+ symbols = response.get("symbols", [])
247
+ agg = aggregate_sentiment(messages)
248
+ items: list[dict[str, Any]] = []
249
+ for i, m in enumerate(messages):
250
+ user = m.get("user") or {}
251
+ username = user.get("username") or "unknown"
252
+ body = (m.get("body") or "").strip()
253
+ sentiment = ((m.get("entities") or {}).get("sentiment") or {}).get("basic")
254
+ likes = (m.get("likes") or {}).get("total", 0) or 0
255
+ reshares = (m.get("reshares") or {}).get("reshared_count", 0) or 0
256
+ followers = user.get("followers", 0) or 0
257
+ # Relevance: cashtag-native source, so on-symbol is a near-given. Nudge
258
+ # by author reach + a tagged-sentiment bonus (tagged posts are higher
259
+ # intent than chatter).
260
+ relevance = min(1.0, 0.7 + (0.1 if sentiment else 0.0) + min(0.2, followers / 50000))
261
+ items.append({
262
+ "id": str(m.get("id") or f"ST{i+1}"),
263
+ "title": body[:120] or f"${symbols[0] if symbols else ''} post",
264
+ "url": f"https://stocktwits.com/{username}/message/{m.get('id')}",
265
+ "author": username,
266
+ "date": (m.get("created_at") or "")[:10] or None,
267
+ "engagement": {"likes": likes, "reshares": reshares, "followers": followers},
268
+ "relevance": round(relevance, 2),
269
+ "why_relevant": f"StockTwits ${symbols[0] if symbols else ''} post"
270
+ + (f" tagged {sentiment}" if sentiment else ""),
271
+ "snippet": body[:400],
272
+ "metadata": {
273
+ "sentiment": sentiment,
274
+ "symbol": symbols[0] if symbols else None,
275
+ "sentiment_aggregate": agg, # same dict on every item; cheap, lets synthesis cite it
276
+ "watchlist": response.get("watchlist"),
277
+ "freshness_window": response.get("freshness_window"),
278
+ },
279
+ })
280
+ return items
281
+
282
+
283
+ def aggregate_sentiment(messages: list[dict[str, Any]]) -> dict[str, Any]:
284
+ """Bull/bear counts + ratio over the sentiment-tagged subset."""
285
+ bull = bear = 0
286
+ for m in messages:
287
+ s = ((m.get("entities") or {}).get("sentiment") or {}).get("basic")
288
+ if s == "Bullish":
289
+ bull += 1
290
+ elif s == "Bearish":
291
+ bear += 1
292
+ tagged = bull + bear
293
+ return {
294
+ "bullish": bull,
295
+ "bearish": bear,
296
+ "untagged": len(messages) - tagged,
297
+ "pct_bullish": round(100 * bull / tagged) if tagged else None,
298
+ "sample": len(messages),
299
+ }
300
+
301
+
302
+ def refetch_datum(item: Any, datum_key: str) -> dict[str, Any]:
303
+ """Re-fetch the same paginated, date-filtered symbol-stream population."""
304
+ from . import http
305
+
306
+ if datum_key != "pct_bullish":
307
+ raise KeyError(f"Unsupported StockTwits datum: {datum_key}")
308
+ symbol = str(item.metadata.get("symbol") or item.container or "").strip().upper()
309
+ if not symbol:
310
+ raise ValueError("StockTwits item has no symbol")
311
+ url = _STREAM_URL.format(symbol=urllib.parse.quote(symbol))
312
+ window = item.metadata.get("freshness_window") or {}
313
+ depth = str(window.get("depth") or "default")
314
+ target = _DEPTH.get(depth, _DEPTH["default"])
315
+ messages: list[dict[str, Any]] = []
316
+ cursor_max = None
317
+ while len(messages) < target:
318
+ request_kwargs: dict[str, Any] = {"timeout": 10, "retries": 2}
319
+ if cursor_max:
320
+ request_kwargs["params"] = {"max": cursor_max}
321
+ data = http.request("GET", url, **request_kwargs)
322
+ if not isinstance(data, dict) or not isinstance(data.get("messages"), list):
323
+ raise KeyError("StockTwits symbol stream was not returned")
324
+ batch = data["messages"]
325
+ if not batch:
326
+ break
327
+ messages.extend(batch)
328
+ cursor = data.get("cursor") or {}
329
+ if not cursor.get("more") or not cursor.get("max"):
330
+ break
331
+ cursor_max = cursor["max"]
332
+ messages = _filter_by_date(
333
+ messages,
334
+ window.get("from_date"),
335
+ window.get("to_date"),
336
+ )
337
+ aggregate = aggregate_sentiment(messages)
338
+ value = aggregate.get("pct_bullish")
339
+ if value is None:
340
+ raise KeyError("StockTwits stream has no tagged sentiment")
341
+ newest = max(
342
+ (str(message.get("created_at") or "") for message in messages),
343
+ default="",
344
+ )
345
+ return {
346
+ "value": value,
347
+ "values": {"pct_bullish": value},
348
+ "url": item.url,
349
+ "timestamp": newest or None,
350
+ }
351
+
352
+
353
+ # --------------------------------------------------------------------------- #
354
+ # Standalone CLI (ad-hoc use today, before any engine wiring) #
355
+ # python3 stocktwits.py "ServiceNow stock" #
356
+ # --------------------------------------------------------------------------- #
357
+ if __name__ == "__main__":
358
+ topic = " ".join(sys.argv[1:]) or "$NOW"
359
+ if not is_financial_topic(topic) and not detect_symbols(topic, resolve=False):
360
+ print(f"Not a ticker/crypto topic — skipping StockTwits: {topic!r}")
361
+ raise SystemExit(0)
362
+ today = datetime.date.today()
363
+ since = (today - datetime.timedelta(days=30)).isoformat()
364
+ resp = search_stocktwits(topic, from_date=since, depth="default")
365
+ if resp.get("error") and not resp.get("messages"):
366
+ print("error:", resp["error"]); raise SystemExit(1)
367
+ items = parse_stocktwits_response(resp, query=topic)
368
+ agg = aggregate_sentiment(resp["messages"])
369
+ print(f"symbol(s): {resp.get('symbols')} | watchlist {resp.get('watchlist')}")
370
+ print(f"sentiment: {agg['bullish']} bull / {agg['bearish']} bear "
371
+ f"({agg['pct_bullish']}% bullish of tagged) over {agg['sample']} msgs")
372
+ for it in sorted(items, key=lambda x: x["engagement"]["likes"], reverse=True)[:8]:
373
+ s = it["metadata"]["sentiment"] or "-"
374
+ print(f" [{it['engagement']['likes']}♥ {s}] @{it['author']}: {it['snippet'][:120]}")