luckiest-co 1.0.46 → 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 (266) 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 +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  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/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -18,7 +18,12 @@ def filter_by_date_range(
18
18
  filtered: list[schema.SourceItem] = []
19
19
  for item in items:
20
20
  if not item.published_at:
21
- if not require_date:
21
+ basis = item.metadata.get("date_window_basis") if item.source == "grounding" else None
22
+ if (
23
+ not require_date
24
+ or basis == "server_bounds"
25
+ or basis == "server_start_current"
26
+ ):
22
27
  filtered.append(item)
23
28
  continue
24
29
  if item.published_at < from_date or item.published_at > to_date:
@@ -40,12 +45,28 @@ def normalize_source_items(
40
45
  "reddit": _normalize_reddit,
41
46
  "x": _normalize_x,
42
47
  "youtube": _normalize_youtube,
43
- "tiktok": lambda s, i, idx, fd, td: _normalize_shortform_video(s, i, idx, fd, td, "TK", "TikTok post"),
44
- "instagram": lambda s, i, idx, fd, td: _normalize_shortform_video(s, i, idx, fd, td, "IG", "Instagram reel"),
48
+ "tiktok": lambda s, i, idx, fd, td: _normalize_shortform_video(
49
+ s, i, idx, fd, td, "TK", "TikTok post"
50
+ ),
51
+ "instagram": lambda s, i, idx, fd, td: _normalize_shortform_video(
52
+ s, i, idx, fd, td, "IG", "Instagram reel"
53
+ ),
45
54
  "hackernews": _normalize_hackernews,
46
- "bluesky": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "BS", "Bluesky post"),
47
- "truthsocial": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "TS", "Truth Social post"),
48
- "threads": lambda s, i, idx, fd, td: _normalize_microblog(s, i, idx, fd, td, "TH", "Threads post"),
55
+ "devto": _normalize_devto,
56
+ "stocktwits": _normalize_stocktwits,
57
+ "dripstack": _normalize_dripstack,
58
+ "bluesky": lambda s, i, idx, fd, td: _normalize_microblog(
59
+ s, i, idx, fd, td, "BS", "Bluesky post"
60
+ ),
61
+ "truthsocial": lambda s, i, idx, fd, td: _normalize_microblog(
62
+ s, i, idx, fd, td, "TS", "Truth Social post"
63
+ ),
64
+ "threads": lambda s, i, idx, fd, td: _normalize_microblog(
65
+ s, i, idx, fd, td, "TH", "Threads post"
66
+ ),
67
+ "telegram": lambda s, i, idx, fd, td: _normalize_microblog(
68
+ s, i, idx, fd, td, "TG", "Telegram post"
69
+ ),
49
70
  "xquik": _normalize_x,
50
71
  "pinterest": _normalize_pinterest,
51
72
  "polymarket": _normalize_polymarket,
@@ -53,6 +74,8 @@ def normalize_source_items(
53
74
  "arxiv": _normalize_arxiv,
54
75
  "techmeme": _normalize_techmeme,
55
76
  "trustpilot": _normalize_trustpilot,
77
+ "amazon": _normalize_amazon,
78
+ "meta_ads": _normalize_meta_ads,
56
79
  "grounding": _normalize_grounding,
57
80
  "xiaohongshu": _normalize_grounding,
58
81
  "github": _normalize_github,
@@ -63,7 +86,14 @@ def normalize_source_items(
63
86
  normalizer = normalizers.get(source)
64
87
  if normalizer is None:
65
88
  raise ValueError(f"Unsupported source: {source}")
66
- normalized = [normalizer(source, item, index, from_date, to_date) for index, item in enumerate(items)]
89
+ normalized = [
90
+ normalizer(source, item, index, from_date, to_date)
91
+ for index, item in enumerate(items)
92
+ ]
93
+ if source == "arxiv":
94
+ # The adapter owns arXiv's 365-day recency contract. Applying the
95
+ # report window again here drops relevant papers the adapter accepted.
96
+ return normalized
67
97
  if source == "jobs":
68
98
  # A careers board is a snapshot of CURRENTLY OPEN roles. An open posting
69
99
  # is current evidence regardless of when it was posted, so date-windowing
@@ -72,9 +102,27 @@ def normalize_source_items(
72
102
  # Keep the full board; recency is annotated, not used to drop.
73
103
  return normalized
74
104
  require_date = source == "grounding"
75
- filtered = filter_by_date_range(normalized, from_date, to_date, require_date=require_date)
105
+ filtered = filter_by_date_range(
106
+ normalized, from_date, to_date, require_date=require_date
107
+ )
76
108
  if filtered:
77
109
  return filtered
110
+ # For evergreen_ok, YouTube search keeps out-of-window videos when fewer
111
+ # than 3 are recent, then pays for transcripts. A second hard date filter
112
+ # here dropped those transcribed items to zero (#1043). Keep
113
+ # transcript-backed retrieved items instead of paying for transcripts that
114
+ # never appear in the brief. Metadata-only / caption-failed videos are not
115
+ # that rescue. Recent modes (strict_recent / balanced_recent) get no
116
+ # rescue: years-old videos on a recent topic are noise, not evidence.
117
+ if freshness_mode == "evergreen_ok" and source == "youtube" and normalized:
118
+ transcribed = [
119
+ item for item in normalized if str(item.snippet or "").strip()
120
+ ]
121
+ if transcribed:
122
+ if require_date:
123
+ dated = [item for item in transcribed if item.published_at]
124
+ return dated or transcribed
125
+ return transcribed
78
126
  if freshness_mode == "evergreen_ok" and source == "youtube":
79
127
  if require_date:
80
128
  return [item for item in normalized if item.published_at]
@@ -86,6 +134,8 @@ def _remap_comments(
86
134
  raw: list[Any],
87
135
  score_keys: tuple[str, ...],
88
136
  excerpt_keys: tuple[str, ...],
137
+ *,
138
+ preserve_absent_score: bool = False,
89
139
  ) -> list[dict[str, Any]]:
90
140
  """Normalize comments from any source into the shared Reddit-compatible shape.
91
141
 
@@ -93,19 +143,29 @@ def _remap_comments(
93
143
  entity_extract, rerank) all expect `score` and `excerpt`. This helper maps
94
144
  per-source field names (YT: likes/text, TikTok: digg_count/text) onto that
95
145
  shape while preserving author/date/url passthrough.
146
+
147
+ Sources that distinguish an absent vote from a measured zero can opt into
148
+ preserving the absent value as ``None``.
96
149
  """
97
150
  out: list[dict[str, Any]] = []
98
151
  for raw_c in raw:
99
152
  if not isinstance(raw_c, dict):
100
153
  continue
101
- score = _first_present(raw_c, score_keys, default=0)
154
+ score = _first_present(
155
+ raw_c,
156
+ score_keys,
157
+ default=None if preserve_absent_score else 0,
158
+ )
102
159
  excerpt = _first_present(raw_c, excerpt_keys, default="")
103
- try:
104
- score_int = int(score or 0)
105
- except (TypeError, ValueError):
106
- score_int = 0
160
+ if score is None and preserve_absent_score:
161
+ normalized_score = None
162
+ else:
163
+ try:
164
+ normalized_score = int(score or 0)
165
+ except (TypeError, ValueError):
166
+ normalized_score = 0
107
167
  entry: dict[str, Any] = {
108
- "score": score_int,
168
+ "score": normalized_score,
109
169
  "excerpt": str(excerpt or "")[:400],
110
170
  "author": str(raw_c.get("author") or ""),
111
171
  "date": str(raw_c.get("date") or ""),
@@ -143,7 +203,9 @@ def _domain_from_url(url: str) -> str | None:
143
203
  return domain or None
144
204
 
145
205
 
146
- def _date_confidence(item: dict[str, Any], from_date: str, to_date: str, default: str = "low") -> str:
206
+ def _date_confidence(
207
+ item: dict[str, Any], from_date: str, to_date: str, default: str = "low"
208
+ ) -> str:
147
209
  if item.get("date_confidence"):
148
210
  return str(item["date_confidence"])
149
211
  date_value = item.get("date")
@@ -187,6 +249,70 @@ def _source_item(
187
249
  )
188
250
 
189
251
 
252
+ def _normalize_stocktwits(
253
+ source: str,
254
+ item: dict[str, Any],
255
+ index: int,
256
+ from_date: str,
257
+ to_date: str,
258
+ ) -> schema.SourceItem:
259
+ meta = item.get("metadata") or {}
260
+ return _source_item(
261
+ item_id=str(item.get("id") or f"ST{index + 1}"),
262
+ source=source,
263
+ title=str(item.get("title") or ""),
264
+ body=str(item.get("snippet") or ""),
265
+ url=str(item.get("url") or ""),
266
+ author=str(item.get("author") or "") or None,
267
+ container=str(meta.get("symbol") or "") or None,
268
+ published_at=item.get("date"),
269
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
270
+ engagement=item.get("engagement") or {},
271
+ relevance_hint=item.get("relevance", 0.7),
272
+ why_relevant=str(item.get("why_relevant") or ""),
273
+ snippet=str(item.get("snippet") or "")[:400],
274
+ metadata=meta, # carries sentiment + symbol-level bull/bear aggregate
275
+ )
276
+
277
+
278
+ def _normalize_dripstack(
279
+ source: str,
280
+ item: dict[str, Any],
281
+ index: int,
282
+ from_date: str,
283
+ to_date: str,
284
+ ) -> schema.SourceItem:
285
+ """Normalizer for DripStack newsletter search results.
286
+
287
+ DripStack returns article metadata from paid financial newsletters.
288
+ No engagement signal — ranking relies on DripStack's own relevanceScore
289
+ (0-100, normalized to 0-1) plus recency. The publication name serves as
290
+ author/attribution (e.g. "SemiAnalysis", "Bloomberg").
291
+ """
292
+ meta = item.get("metadata") or {}
293
+ return _source_item(
294
+ item_id=str(item.get("id") or f"DS{index + 1}"),
295
+ source=source,
296
+ title=str(item.get("title") or ""),
297
+ body=str(item.get("body") or "")
298
+ or str(item.get("snippet") or "")
299
+ or str(item.get("title") or ""),
300
+ url=str(item.get("url") or ""),
301
+ author=str(item.get("author") or "") or None,
302
+ container=str(meta.get("publication_slug") or "") or None,
303
+ published_at=item.get("date"),
304
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
305
+ engagement={},
306
+ relevance_hint=item.get("relevance", 0.5),
307
+ why_relevant=str(item.get("why_relevant") or ""),
308
+ snippet=str(item.get("snippet") or "")[:400],
309
+ metadata={
310
+ **meta,
311
+ "publication_slug": meta.get("publication_slug"),
312
+ },
313
+ )
314
+
315
+
190
316
  def _normalize_reddit(
191
317
  source: str,
192
318
  item: dict[str, Any],
@@ -262,7 +388,9 @@ def _normalize_jobs(
262
388
  title = str(item.get("title") or "").strip()
263
389
  department = str(item.get("department") or "").strip()
264
390
  location = str(item.get("location") or "").strip()
265
- body = "\n".join(part for part in [title, department, location, description] if part)
391
+ body = "\n".join(
392
+ part for part in [title, department, location, description] if part
393
+ )
266
394
  provider = str(item.get("provider") or "").strip()
267
395
  return _source_item(
268
396
  item_id=str(item.get("id") or f"J{index + 1}"),
@@ -281,12 +409,15 @@ def _normalize_jobs(
281
409
  metadata={
282
410
  "provider": provider,
283
411
  "department": department,
284
- "departments": item.get("departments") or ([department] if department else []),
412
+ "departments": item.get("departments")
413
+ or ([department] if department else []),
285
414
  "location": location,
286
415
  "offices": item.get("offices") or [],
287
416
  "board_token": item.get("board_token") or "",
288
417
  "source_url": item.get("source_url") or "",
289
- "source_domain": item.get("source_domain") or _domain_from_url(str(item.get("url") or "")) or "",
418
+ "source_domain": item.get("source_domain")
419
+ or _domain_from_url(str(item.get("url") or ""))
420
+ or "",
290
421
  },
291
422
  )
292
423
 
@@ -361,9 +492,10 @@ def _normalize_shortform_video(
361
492
  "hashtags": item.get("hashtags") or [],
362
493
  "top_comments": _remap_comments(
363
494
  item.get("top_comments") or [],
364
- # TikTok uses digg_count as the vote field; Instagram has no
365
- # comment fetcher today so the key is harmlessly absent.
366
- score_keys=("score", "digg_count", "likes"),
495
+ # Instagram comments use comment_like_count as the vote field
496
+ # (ScrapeCreators /v2/instagram/post/comments); digg_count/likes
497
+ # kept for shape compatibility.
498
+ score_keys=("score", "comment_like_count", "digg_count", "likes"),
367
499
  excerpt_keys=("excerpt", "text"),
368
500
  ),
369
501
  },
@@ -399,6 +531,41 @@ def _normalize_pinterest(
399
531
  )
400
532
 
401
533
 
534
+ def _normalize_devto(
535
+ source: str,
536
+ item: dict[str, Any],
537
+ index: int,
538
+ from_date: str,
539
+ to_date: str,
540
+ ) -> schema.SourceItem:
541
+ top_comments = _remap_comments(
542
+ item.get("top_comments") or [],
543
+ score_keys=("replies",),
544
+ excerpt_keys=("text", "excerpt"),
545
+ preserve_absent_score=True,
546
+ )
547
+ comment_text = _join_comment_excerpts(top_comments, "excerpt")
548
+ title = str(item.get("title") or "").strip()
549
+ description = str(item.get("text") or "").strip()
550
+ body = "\n".join(part for part in [title, description, comment_text] if part)
551
+ return _source_item(
552
+ item_id=str(item.get("id") or f"DEV{index + 1}"),
553
+ source=source,
554
+ title=title or f"dev.to article {index + 1}",
555
+ body=body,
556
+ url=str(item.get("url") or ""),
557
+ author=str(item.get("author") or ""),
558
+ container="dev.to",
559
+ published_at=item.get("date"),
560
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
561
+ engagement=item.get("engagement") or {},
562
+ relevance_hint=item.get("relevance", 0.5),
563
+ why_relevant=str(item.get("why_relevant") or ""),
564
+ snippet=description or comment_text,
565
+ metadata={**(item.get("metadata") or {}), "top_comments": top_comments},
566
+ )
567
+
568
+
402
569
  def _normalize_hackernews(
403
570
  source: str,
404
571
  item: dict[str, Any],
@@ -406,10 +573,23 @@ def _normalize_hackernews(
406
573
  from_date: str,
407
574
  to_date: str,
408
575
  ) -> schema.SourceItem:
409
- top_comments = item.get("top_comments") or []
410
- comment_text = _join_comment_excerpts(top_comments, "text")
576
+ # HN comments arrive as {author, text, points}; downstream code keys on
577
+ # score/excerpt, so remap here exactly as the YouTube and TikTok normalisers
578
+ # do. Without this the per-source floor in render._top_comments_list reads a
579
+ # `score` that is never present and rejects every HN comment.
580
+ top_comments = _remap_comments(
581
+ item.get("top_comments") or [],
582
+ score_keys=("points", "score"),
583
+ excerpt_keys=("text", "excerpt"),
584
+ preserve_absent_score=True,
585
+ )
586
+ comment_text = _join_comment_excerpts(top_comments, "excerpt")
411
587
  title = str(item.get("title") or "").strip()
412
- body = "\n".join(part for part in [title, str(item.get("text") or "").strip(), comment_text] if part)
588
+ body = "\n".join(
589
+ part
590
+ for part in [title, str(item.get("text") or "").strip(), comment_text]
591
+ if part
592
+ )
413
593
  return _source_item(
414
594
  item_id=str(item.get("id") or f"HN{index + 1}"),
415
595
  source=source,
@@ -582,6 +762,92 @@ def _normalize_techmeme(
582
762
  )
583
763
 
584
764
 
765
+ def _normalize_meta_ads(
766
+ source: str,
767
+ item: dict[str, Any],
768
+ index: int,
769
+ from_date: str,
770
+ to_date: str,
771
+ ) -> schema.SourceItem:
772
+ """Normalizer for Meta Ad Library creatives.
773
+
774
+ One item per distinct creative launched inside the window. Identity is the
775
+ Ad Library permalink, never the landing URL: many creatives for one product
776
+ share a landing page, and fusion merges by normalized URL, so keying on the
777
+ landing page would collapse a whole campaign into one candidate and lose
778
+ every transcript but the first.
779
+
780
+ Grounding-exempt on the Amazon precedent. Ad copy is written to sell, not
781
+ to name the brand -- "Stop making boring drinks" never repeats the topic --
782
+ and the advertiser page was already resolved by name before any of these
783
+ were created, so they are on-entity by construction.
784
+ """
785
+ body = str(item.get("text") or "").strip()
786
+ transcript = str(item.get("transcript") or "").strip()
787
+ advertiser = str(item.get("advertiser") or "").strip()
788
+ cta = str(item.get("cta") or "").strip()
789
+ landing = str(item.get("landing_url") or "").strip()
790
+ placements = [str(p) for p in (item.get("placements") or [])]
791
+ promo = str(item.get("promo_code") or "").strip()
792
+
793
+ # The spoken script is usually the sharper version of the pitch, so it
794
+ # leads the evidence snippet when present.
795
+ snippet_parts = [part for part in [body, transcript] if part]
796
+ # The adapter deliberately keeps creatives that launched inside the window
797
+ # and have since ended -- a one-week promo push is exactly the signal this
798
+ # source exists for -- so the wording has to follow the stored state rather
799
+ # than calling every creative active.
800
+ running = bool(item.get("is_active"))
801
+ ended_on = str(item.get("ended_on") or "").strip()
802
+ if running:
803
+ state_word = "Running paid creative"
804
+ elif ended_on:
805
+ state_word = f"Paid creative that ran until {ended_on}"
806
+ else:
807
+ state_word = "Paid creative that has since ended"
808
+ default_why = f"{state_word} from {advertiser}" if advertiser else state_word
809
+ context = " │ ".join(
810
+ part
811
+ for part in [
812
+ cta or "",
813
+ f"code {promo}" if promo else "",
814
+ landing,
815
+ ]
816
+ if part
817
+ )
818
+ return _source_item(
819
+ item_id=str(item.get("id") or f"MA{index + 1}"),
820
+ source=source,
821
+ title=str(item.get("title") or "").strip() or body[:140] or f"Meta ad {index + 1}",
822
+ body=body,
823
+ url=str(item.get("url") or "").strip(),
824
+ author=advertiser,
825
+ container="Meta Ad Library",
826
+ published_at=item.get("date"),
827
+ date_confidence=_date_confidence(item, from_date, to_date, default="high"),
828
+ engagement={"variants": int(item.get("variants") or 1)},
829
+ relevance_hint=0.8,
830
+ why_relevant=str(item.get("why_relevant") or "") or default_why,
831
+ snippet=" ".join(snippet_parts)[:400],
832
+ metadata={
833
+ "grounding_exempt": True,
834
+ "advertiser": advertiser,
835
+ "page_id": str(item.get("page_id") or ""),
836
+ "is_active": bool(item.get("is_active")),
837
+ "ended_on": item.get("ended_on"),
838
+ "display_format": str(item.get("display_format") or ""),
839
+ "placements": placements,
840
+ "cta": cta,
841
+ "landing_url": landing,
842
+ "promo_code": promo,
843
+ "variants": int(item.get("variants") or 1),
844
+ "has_video": bool(item.get("has_video")),
845
+ "transcript_snippet": transcript[:400],
846
+ "ad_context": context,
847
+ },
848
+ )
849
+
850
+
585
851
  def _normalize_trustpilot(
586
852
  source: str,
587
853
  item: dict[str, Any],
@@ -601,7 +867,8 @@ def _normalize_trustpilot(
601
867
  return _source_item(
602
868
  item_id=str(item.get("id") or f"TP{index + 1}"),
603
869
  source=source,
604
- title=title or (f"{name} on Trustpilot" if name else f"Trustpilot reviews {index + 1}"),
870
+ title=title
871
+ or (f"{name} on Trustpilot" if name else f"Trustpilot reviews {index + 1}"),
605
872
  body=body,
606
873
  url=str(item.get("url") or ""),
607
874
  author=name or None,
@@ -621,6 +888,87 @@ def _normalize_trustpilot(
621
888
  )
622
889
 
623
890
 
891
+ def _normalize_amazon(
892
+ source: str,
893
+ item: dict[str, Any],
894
+ index: int,
895
+ from_date: str,
896
+ to_date: str,
897
+ ) -> schema.SourceItem:
898
+ """Normalizer for Amazon product-and-review signals.
899
+
900
+ One item per product. The aggregate rating is current-state evidence, so
901
+ the item is stamped with today's date on the Trustpilot precedent -- a
902
+ live 4.4-star average is a fact about now, not about whenever the
903
+ product launched.
904
+
905
+ Reviews arrive already in the shared score/excerpt comment shape (built
906
+ in the amazon adapter, deliberately not routed through _remap_comments,
907
+ which would strip the rating/date/verified keys this source needs), so
908
+ they pass straight through to metadata.
909
+ """
910
+ name = str(item.get("name") or "").strip()
911
+ brand = str(item.get("brand") or "").strip()
912
+ top_comments = item.get("top_comments") or []
913
+ comment_text = _join_comment_excerpts(top_comments, "excerpt")
914
+ rating = item.get("product_rating") if item.get("product_rating") is not None else item.get("rating")
915
+ ratings_total = item.get("product_rating_count") or item.get("num_ratings") or 0
916
+
917
+ headline = " ".join(
918
+ part for part in [
919
+ f"{rating}/5" if rating is not None else "",
920
+ f"({ratings_total:,} ratings)" if ratings_total else "",
921
+ ] if part
922
+ )
923
+ # The brand rides in its own field and is usually absent from the name,
924
+ # so prepend it -- unless the name already leads with it, which would
925
+ # otherwise read "Weber Weber Spirit E-325".
926
+ if brand and not name.lower().startswith(brand.lower()):
927
+ product_label = f"{brand} {name}".strip()
928
+ else:
929
+ product_label = name or brand
930
+ title = " - ".join(part for part in [product_label, headline] if part)
931
+ body = "\n".join(part for part in [title, comment_text] if part)
932
+
933
+ return _source_item(
934
+ item_id=str(item.get("asin") or f"AMZ{index + 1}"),
935
+ source=source,
936
+ title=title or f"Amazon product {index + 1}",
937
+ body=body,
938
+ url=str(item.get("url") or ""),
939
+ author=brand or None,
940
+ container="Amazon",
941
+ published_at=item.get("date"),
942
+ date_confidence=_date_confidence(item, from_date, to_date, default="low"),
943
+ engagement=item.get("engagement") or {"ratings": ratings_total},
944
+ relevance_hint=item.get("relevance", 0.6),
945
+ why_relevant=str(item.get("why_relevant") or ""),
946
+ snippet=comment_text[:400],
947
+ metadata={
948
+ "asin": str(item.get("asin") or ""),
949
+ "name": name,
950
+ "short_name": item.get("short_name") or "",
951
+ "brand": brand,
952
+ "rating": item.get("rating"),
953
+ "num_ratings": item.get("num_ratings") or 0,
954
+ "price": item.get("price"),
955
+ "currency": item.get("currency") or "",
956
+ "badge": item.get("badge") or "",
957
+ # Recorded, never used as a filter: the flag's distribution
958
+ # swings with keyword phrasing, so filtering can blank the lane.
959
+ "sponsored": bool(item.get("sponsored")),
960
+ "top_comments": top_comments,
961
+ "product_rating": item.get("product_rating"),
962
+ "product_rating_count": item.get("product_rating_count") or 0,
963
+ "star_distribution": item.get("star_distribution") or {},
964
+ # Relevant by construction: the adapter already gated products
965
+ # against the model-supplied keyword, and review text rarely
966
+ # names the product (KTD8).
967
+ "grounding_exempt": True,
968
+ },
969
+ )
970
+
971
+
624
972
  def _normalize_polymarket(
625
973
  source: str,
626
974
  item: dict[str, Any],
@@ -635,10 +983,14 @@ def _normalize_polymarket(
635
983
  "liquidity": item.get("liquidity") or 0,
636
984
  }
637
985
  return _source_item(
638
- item_id=str(item.get("id") or f"PM{index + 1}"),
986
+ item_id=str(item.get("event_id") or item.get("id") or f"PM{index + 1}"),
639
987
  source=source,
640
988
  title=title or question or f"Polymarket event {index + 1}",
641
- body="\n".join(part for part in [title, question, str(item.get("price_movement") or "")] if part),
989
+ body="\n".join(
990
+ part
991
+ for part in [title, question, str(item.get("price_movement") or "")]
992
+ if part
993
+ ),
642
994
  url=str(item.get("url") or ""),
643
995
  author=None,
644
996
  container="Polymarket",
@@ -649,6 +1001,7 @@ def _normalize_polymarket(
649
1001
  why_relevant=str(item.get("why_relevant") or ""),
650
1002
  snippet=str(item.get("price_movement") or ""),
651
1003
  metadata={
1004
+ "event_id": item.get("event_id"),
652
1005
  "question": question,
653
1006
  "end_date": item.get("end_date"),
654
1007
  "outcome_prices": item.get("outcome_prices") or [],
@@ -657,7 +1010,6 @@ def _normalize_polymarket(
657
1010
  )
658
1011
 
659
1012
 
660
-
661
1013
  def _normalize_github(
662
1014
  source: str,
663
1015
  item: dict[str, Any],
@@ -690,9 +1042,11 @@ def _normalize_github(
690
1042
  "labels": metadata.get("labels") or [],
691
1043
  "state": metadata.get("state", ""),
692
1044
  "is_pr": metadata.get("is_pr", False),
1045
+ "date_is_snapshot": bool(metadata.get("date_is_snapshot")),
693
1046
  },
694
1047
  )
695
1048
 
1049
+
696
1050
  def _normalize_grounding(
697
1051
  source: str,
698
1052
  item: dict[str, Any],