luckiest-co 1.0.47 → 1.0.49

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 (274) 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/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,877 @@
1
+ """Amazon product and review signals via the Bright Data CLI.
2
+
3
+ Two-stage source, following the digg discover-then-enrich shape:
4
+
5
+ 1. **Discovery** -- one ``amazon_product_search`` per run turns a
6
+ model-supplied product keyword into product records carrying live
7
+ aggregate stats (rating, rating count, price). Cheap and fast.
8
+ 2. **Enrichment** -- ``amazon_product_reviews`` pulls a capped sample of
9
+ written reviews for the top few surviving products, in parallel, under
10
+ a lane deadline. Reviews ride on their product item as metadata
11
+ comments and feed community-voice weaving.
12
+
13
+ The signature signal is the fusion of those two: an all-time rating from
14
+ thousands of ratings, set against the average of just the reviews inside
15
+ the last-30-day window. When those disagree, something changed this month,
16
+ and the review text says what. No Amazon page shows that.
17
+
18
+ Metering (R13): one credit per pipeline request regardless of records
19
+ returned, so the caps here bound paid-tier *records*, not credits. A
20
+ default run is 1 search + up to 3 review pulls = 4 requests.
21
+
22
+ Field names and quirks below are verified against live payloads pulled
23
+ 2026-08-13; see the plan's schema block. Three fields arrive doubled
24
+ (``review_posted_date``, ``review_header``, ``badge``) and are repaired
25
+ here rather than downstream.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ import time
32
+ from concurrent.futures import ThreadPoolExecutor, as_completed
33
+ from datetime import datetime, timezone
34
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
35
+ from urllib.parse import urlparse
36
+
37
+ from . import brightdata, log
38
+ from .relevance import token_overlap_relevance
39
+
40
+
41
+ SEARCH_PIPELINE = "amazon_product_search"
42
+ REVIEWS_PIPELINE = "amazon_product_reviews"
43
+
44
+ DEFAULT_DOMAIN = "https://www.amazon.com"
45
+
46
+ # Reviews requested per pull. Uniform across topic shapes and depths by
47
+ # decision: billing is per *request*, not per record, so a bigger cap is
48
+ # free on the monthly credit tier, and the in-window sample is what the
49
+ # drift signal rests on. Live-verified that latency does not scale with
50
+ # this number (50 reviews in 22s vs 20 reviews in 115s on a slower SKU).
51
+ #
52
+ # It is a ceiling, never a quota -- a SKU with 31 total reviews returns 31.
53
+ MAX_REVIEWS = 50
54
+
55
+ # How many products get a review pull, per depth. Quick spends one credit
56
+ # on discovery only: aggregate stats with no recent window.
57
+ DEPTH_CONFIG = {
58
+ "quick": 0,
59
+ "default": 3,
60
+ "deep": 5,
61
+ }
62
+
63
+ SEARCH_TIMEOUT = 90
64
+ REVIEW_TIMEOUT = 180
65
+
66
+ # Wall-clock ceiling for the whole parallel review lane. Pulls that miss it
67
+ # are abandoned, and their products degrade to `quiet` rather than
68
+ # disappearing (a slow SKU is real and unrelated to the cap: one live pull
69
+ # took 115s).
70
+ LANE_DEADLINE = 180
71
+
72
+ # The engine's foreground contract. The lane deadline is clamped against
73
+ # whatever remains of it, minus room to render.
74
+ FOREGROUND_CONTRACT = 300
75
+ RENDER_MARGIN = 20
76
+
77
+ # Minimum useful budget for the review lane. Below this threshold, Bright
78
+ # Data pulls reliably time out (cli_timeout = max(5, timeout-10), so budget
79
+ # 11s → CLI timeout 1s). Crumbs are a skip, not a short timeout: firing
80
+ # doomed pulls still spends 3 credits with no reviews returned.
81
+ MIN_USEFUL_REVIEW_BUDGET = 90
82
+
83
+ # Minimum dated reviews inside the window before a drift arrow is honest.
84
+ # Live census: a 50-cap pull returned 31 records of which only 5 were
85
+ # inside 30 days, so an unguarded arrow would routinely publish a "trend"
86
+ # computed from one or two reviews.
87
+ MIN_DRIFT_SAMPLE = 5
88
+
89
+ RECENT_WINDOW_DAYS = 30
90
+
91
+ # Product names run long and pipe-delimited; the footer needs a scannable
92
+ # handle, not a title.
93
+ SHORT_NAME_MAX = 18
94
+
95
+ _STAR_FIELDS = (
96
+ ("one_star", 1),
97
+ ("two_star", 2),
98
+ ("three_star", 3),
99
+ ("four_star", 4),
100
+ ("five_star", 5),
101
+ )
102
+
103
+
104
+ def _log(msg: str) -> None:
105
+ log.source_log("Amazon", msg, tty_only=False)
106
+
107
+
108
+ def _today() -> datetime:
109
+ return datetime.now(timezone.utc)
110
+
111
+
112
+ # --------------------------------------------------------------- parsing
113
+
114
+
115
+ def undouble(text: str) -> str:
116
+ """Repair the CLI's doubled string fields.
117
+
118
+ Observed live: ``review_header`` arrives as ``"Best Box!Best Box!"`` and
119
+ ``badge`` as ``"Verified Purchase, Verified Purchase"``. Handles the
120
+ exact-repeat case and the comma-joined repeat, and leaves anything else
121
+ untouched -- a genuinely repetitive title must survive intact.
122
+ """
123
+ value = (text or "").strip()
124
+ if not value:
125
+ return ""
126
+ half, odd = divmod(len(value), 2)
127
+ # Only treat an exact repeat as doubling when the halves are substantial
128
+ # and look like a phrase rather than a syllable -- otherwise a real title
129
+ # of "ByeBye" or "NoNo" gets silently truncated to half of itself. The
130
+ # observed artifact doubles whole headlines, so requiring some length and
131
+ # either whitespace or terminal punctuation keeps the repair targeted.
132
+ if not odd and half >= 6 and value[:half] == value[half:]:
133
+ first = value[:half]
134
+ if " " in first or first[-1] in ".!?":
135
+ return first.strip()
136
+ parts = [p.strip() for p in value.split(",")]
137
+ if len(parts) == 2 and parts[0] and parts[0] == parts[1]:
138
+ return parts[0]
139
+ return value
140
+
141
+
142
+ _DATE_HEAD = re.compile(r"^([A-Z][a-z]+ \d{1,2}, \d{4})")
143
+
144
+
145
+ def parse_review_date(raw: Any) -> Optional[str]:
146
+ """Pull the ISO date out of the CLI's prose-wrapped date field.
147
+
148
+ Live shape: ``"August 3, 2026Reviewed in the United States on August 3,
149
+ 2026"``. Only the leading ``%B %d, %Y`` is trustworthy; the tail is
150
+ localized prose that varies by marketplace.
151
+
152
+ Returns ``YYYY-MM-DD`` or None.
153
+ """
154
+ match = _DATE_HEAD.match(str(raw or "").strip())
155
+ if not match:
156
+ return None
157
+ try:
158
+ return datetime.strptime(match.group(1), "%B %d, %Y").date().isoformat()
159
+ except ValueError:
160
+ return None
161
+
162
+
163
+ def short_name(name: str, brand: str = "") -> str:
164
+ """Derive a scannable footer handle from a long product name.
165
+
166
+ Live names are pipe-delimited marketing strings with the brand carried
167
+ in its own field rather than as a prefix ("Chill Max Leak-Proof XL
168
+ Bento-Style Lunch Box | Included Ice Pack Keeps Food Cold"). Take the
169
+ segment before the first delimiter, drop a leading brand token if one
170
+ did sneak in, and clip to a scannable width on a word boundary.
171
+ """
172
+ text = re.split(r"[|(–—]", str(name or ""), maxsplit=1)[0].strip(" -,")
173
+ brand_token = str(brand or "").strip()
174
+ if brand_token:
175
+ # Word-boundary anchored: a bare startswith() eats into sub-brands and
176
+ # coincidental prefixes ("AnkerWork" under brand "Anker" would become
177
+ # "Work", "Chillax" under "Chill" would become "ax").
178
+ stripped = re.sub(
179
+ rf"^{re.escape(brand_token)}\b[\s\-,]*", "", text, count=1, flags=re.IGNORECASE
180
+ )
181
+ if stripped:
182
+ text = stripped.strip(" -,")
183
+ if len(text) <= SHORT_NAME_MAX:
184
+ return text
185
+ clipped = text[:SHORT_NAME_MAX].rsplit(" ", 1)[0].strip(" -,")
186
+ return clipped or text[:SHORT_NAME_MAX].strip()
187
+
188
+
189
+ def _as_float(value: Any) -> Optional[float]:
190
+ try:
191
+ result = float(value)
192
+ except (TypeError, ValueError):
193
+ return None
194
+ return result
195
+
196
+
197
+ def _as_int(value: Any) -> int:
198
+ try:
199
+ return int(value)
200
+ except (TypeError, ValueError):
201
+ return 0
202
+
203
+
204
+ def _is_sponsored(value: Any) -> bool:
205
+ """The flag arrives as the string 'true'/'false', not a bool.
206
+
207
+ Recorded in metadata but never used to filter (R4): its distribution
208
+ swings hard with keyword phrasing, so filtering on it can blank the
209
+ lane on an unlucky query.
210
+ """
211
+ if isinstance(value, bool):
212
+ return value
213
+ return str(value or "").strip().lower() == "true"
214
+
215
+
216
+ def _valid_product_url(url: str, domain: str) -> bool:
217
+ """Accept only https URLs on the configured Amazon host."""
218
+ try:
219
+ parsed = urlparse(url)
220
+ expected = urlparse(domain or DEFAULT_DOMAIN)
221
+ except ValueError:
222
+ return False
223
+ if parsed.scheme != "https" or not parsed.netloc:
224
+ return False
225
+ host = parsed.netloc.lower().removeprefix("www.")
226
+ want = (expected.netloc or "").lower().removeprefix("www.")
227
+ return bool(want) and host == want
228
+
229
+
230
+ # Amazon ASINs are a fixed shape. Validating it matters because the value
231
+ # is interpolated into a URL that is then refetched through the CLI *and*
232
+ # rendered as a link in the report -- two sinks, one unvalidated API field.
233
+ _ASIN_RE = re.compile(r"^[A-Za-z0-9]{10}$")
234
+
235
+
236
+ def _valid_asin(asin: str) -> bool:
237
+ return bool(_ASIN_RE.match(asin or ""))
238
+
239
+
240
+ def canonical_product_url(url: str, asin: str, domain: str) -> str:
241
+ """Strip Amazon's tracking tail down to a stable /dp/<asin> link.
242
+
243
+ Search records carry 200+ character URLs with session-scoped ``dib``
244
+ tokens. Those work but are unreadable in a report and unstable across
245
+ runs, which breaks dedupe on re-runs of the same topic.
246
+
247
+ Falls back to the (already host-validated) original URL if the ASIN is
248
+ not well-formed, so a malformed record can never shape the rebuilt URL.
249
+ """
250
+ if not _valid_asin(asin):
251
+ return url
252
+ base = (domain or DEFAULT_DOMAIN).rstrip("/")
253
+ return f"{base}/dp/{asin}"
254
+
255
+
256
+ # ------------------------------------------------------------- discovery
257
+
258
+
259
+ def search_products(
260
+ keyword: str,
261
+ *,
262
+ domain: str = DEFAULT_DOMAIN,
263
+ config: Optional[Dict[str, Any]] = None,
264
+ timeout: int = SEARCH_TIMEOUT,
265
+ ) -> Dict[str, Any]:
266
+ """Run one product search. Never raises; returns the adapter envelope."""
267
+ query = (keyword or "").strip()
268
+ if not query:
269
+ return {"records": []}
270
+ # A leading dash would be parsed as a CLI option rather than a search
271
+ # term. The keyword is model-supplied and can be influenced by
272
+ # pre-research over untrusted web content, so reject rather than
273
+ # sanitize -- a keyword starting with '-' is never a real product.
274
+ if query.startswith("-"):
275
+ _log(f"rejecting option-shaped keyword: {query!r}")
276
+ return {"records": [], "error": "amazon keyword may not begin with '-'"}
277
+ _log(f"search '{query}' on {domain}")
278
+ response = brightdata.run_pipeline(
279
+ SEARCH_PIPELINE, [query, domain or DEFAULT_DOMAIN],
280
+ timeout=timeout, config=config,
281
+ )
282
+ if response.get("error"):
283
+ _log(f"search failed: {response['error']}")
284
+ else:
285
+ _log(f"search returned {len(response.get('records') or [])} records")
286
+ return response
287
+
288
+
289
+ def parse_search_response(
290
+ response: Dict[str, Any],
291
+ keyword: str,
292
+ *,
293
+ domain: str = DEFAULT_DOMAIN,
294
+ min_relevance: float = 0.15,
295
+ ) -> List[Dict[str, Any]]:
296
+ """Turn raw search records into deduped, relevance-gated product dicts.
297
+
298
+ Dedupe is by ASIN: live payloads repeat a single product up to five
299
+ times across the result set (64 unique of 66 records on one pull).
300
+ Relevance is scored against the *supplied keyword*, not the run topic,
301
+ because the model may search "June Oven" on a topic about a person.
302
+ """
303
+ records = response.get("records") if isinstance(response, dict) else None
304
+ if not isinstance(records, list):
305
+ return []
306
+
307
+ today = _today().date().isoformat()
308
+ seen: Dict[str, Dict[str, Any]] = {}
309
+ for record in records:
310
+ if not isinstance(record, dict):
311
+ continue
312
+ asin = str(record.get("asin") or "").strip()
313
+ raw_url = str(record.get("url") or "").strip()
314
+ if not _valid_asin(asin) or not _valid_product_url(raw_url, domain):
315
+ continue
316
+
317
+ name = str(record.get("name") or "").strip()
318
+ brand = str(record.get("brand") or "").strip()
319
+ if not name:
320
+ continue
321
+
322
+ relevance = token_overlap_relevance(keyword, f"{brand} {name}".strip())
323
+ if relevance < min_relevance:
324
+ continue
325
+
326
+ num_ratings = _as_int(record.get("num_ratings"))
327
+ existing = seen.get(asin)
328
+ # Duplicates of one ASIN can disagree on rating count (variant-level
329
+ # records); keep the richest.
330
+ if existing and _as_int(existing.get("num_ratings")) >= num_ratings:
331
+ continue
332
+
333
+ seen[asin] = {
334
+ "asin": asin,
335
+ # Current-date stamped (KTD6, trustpilot precedent): a live
336
+ # aggregate rating is a fact about now, not about the product's
337
+ # launch date, so it must not be dropped by the 30-day filter.
338
+ "date": today,
339
+ "name": name,
340
+ "short_name": short_name(name, brand),
341
+ "brand": brand,
342
+ "url": canonical_product_url(raw_url, asin, domain),
343
+ "rating": _as_float(record.get("rating")),
344
+ "num_ratings": num_ratings,
345
+ "price": _as_float(record.get("final_price")),
346
+ "currency": str(record.get("currency") or "").strip(),
347
+ "badge": undouble(str(record.get("badge") or "")),
348
+ "sponsored": _is_sponsored(record.get("sponsored")),
349
+ "bought_past_month": _as_int(record.get("bought_past_month")),
350
+ "rank_on_page": _as_int(record.get("rank_on_page")),
351
+ "relevance": relevance,
352
+ }
353
+
354
+ products = sorted(
355
+ seen.values(),
356
+ key=lambda p: (p["num_ratings"], p["relevance"]),
357
+ reverse=True,
358
+ )
359
+ _log(f"{len(products)} unique on-keyword products after dedupe")
360
+ return products
361
+
362
+
363
+ def infer_brand(products: Sequence[Dict[str, Any]], keyword: str) -> str:
364
+ """Detect a brand topic by matching record brands against the keyword.
365
+
366
+ This is the guard against paying to review a competitor. Rival brands
367
+ buy ads against a brand keyword and can outrank the brand's own catalog
368
+ on raw rating count: on a live "bentgo lunch box" search a competitor
369
+ held the top two slots and would have taken two of the three review
370
+ pulls, putting a rival's reviews in a Bentgo report.
371
+
372
+ Matching the *keyword's own tokens*, rather than picking the most
373
+ common brand in the results, is what keeps category topics unfiltered.
374
+ "best bluetooth speaker" names no brand, so nothing is constrained and
375
+ the top products across brands compete on merit -- which is exactly
376
+ what that topic shape wants.
377
+ """
378
+ normalized_keyword = " ".join(re.findall(r"[a-z0-9]+", (keyword or "").lower()))
379
+ if not normalized_keyword:
380
+ return ""
381
+ keyword_tokens = set(normalized_keyword.split())
382
+
383
+ # Keyed by the lowercased brand so one vendor spelled two ways ("Bentgo"
384
+ # and "BENTGO" in the same result set) reads as one candidate. Without
385
+ # this the set has two members, the function bails, and the guard it
386
+ # exists to provide silently turns off.
387
+ candidates: Dict[str, str] = {}
388
+ for product in products:
389
+ brand = str(product.get("brand") or "").strip()
390
+ if not brand:
391
+ continue
392
+ brand_tokens = re.findall(r"[a-z0-9]+", brand.lower())
393
+ if not brand_tokens:
394
+ continue
395
+ # Multi-word brands ("Hydro Flask") can never match a single-token
396
+ # test, so compare the brand's whole token sequence against the
397
+ # keyword's -- otherwise the guard is off for every two-word brand.
398
+ if len(brand_tokens) == 1:
399
+ matched = brand_tokens[0] in keyword_tokens and len(brand_tokens[0]) > 2
400
+ else:
401
+ matched = " ".join(brand_tokens) in normalized_keyword
402
+ if matched:
403
+ # First spelling wins, so the result is deterministic across runs.
404
+ candidates.setdefault(brand.lower(), brand)
405
+ return next(iter(candidates.values())) if len(candidates) == 1 else ""
406
+
407
+
408
+ def select_enrichment_targets(
409
+ products: Sequence[Dict[str, Any]],
410
+ *,
411
+ limit: int,
412
+ brand: str = "",
413
+ keyword: str = "",
414
+ ) -> List[Dict[str, Any]]:
415
+ """Pick which products get a review pull.
416
+
417
+ Ranked by rating count, which is a coarse signal: search records carry
418
+ variant-level counts that can undercount badly (84 on a record whose
419
+ review pull reported 8,446). The review pull's own
420
+ ``product_rating_count`` is authoritative once available.
421
+
422
+ Two filters run before the cut:
423
+
424
+ * **Brand**, supplied or inferred from the keyword (see ``infer_brand``).
425
+ The record's own ``brand`` field does the work, which also solves
426
+ accessory contamination outright -- a "grill brush for Weber" carries
427
+ the brush maker's brand, not Weber. A front-anchored name match covers
428
+ the few records where ``brand`` is null.
429
+ * **Variant collapse.** Live results repeat one product across colors
430
+ and sizes under distinct ASINs with near-identical names. Two of those
431
+ would burn two of three pulls on the same product and render as
432
+ duplicate footer entries, so only the best-ranked of each short-name
433
+ group stays eligible.
434
+ """
435
+ if limit <= 0:
436
+ return []
437
+ pool = list(products)
438
+
439
+ wanted = (brand or "").strip().lower() or infer_brand(pool, keyword).lower()
440
+ if wanted:
441
+ matched = [
442
+ p for p in pool
443
+ if (p.get("brand") or "").strip().lower() == wanted
444
+ or (not (p.get("brand") or "").strip()
445
+ and str(p.get("name") or "").strip().lower().startswith(wanted))
446
+ ]
447
+ if matched:
448
+ pool = matched
449
+
450
+ deduped: List[Dict[str, Any]] = []
451
+ seen_names: set[str] = set()
452
+ for product in pool:
453
+ key = (product.get("short_name") or "").strip().lower()
454
+ if key and key in seen_names:
455
+ continue
456
+ if key:
457
+ seen_names.add(key)
458
+ deduped.append(product)
459
+ return deduped[:limit]
460
+
461
+
462
+ # ------------------------------------------------------------ enrichment
463
+
464
+
465
+ def fetch_reviews(
466
+ product_url: str,
467
+ *,
468
+ max_reviews: int = MAX_REVIEWS,
469
+ config: Optional[Dict[str, Any]] = None,
470
+ timeout: int = REVIEW_TIMEOUT,
471
+ ) -> Dict[str, Any]:
472
+ """Pull a capped review sample for one product. Never raises."""
473
+ if not product_url:
474
+ return {"records": []}
475
+ return brightdata.run_pipeline(
476
+ REVIEWS_PIPELINE, [product_url, str(max_reviews)],
477
+ timeout=timeout, config=config,
478
+ )
479
+
480
+
481
+ def parse_reviews(response: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
482
+ """Split a review payload into comment dicts and product-level stats.
483
+
484
+ Product-level fields (``product_rating``, ``product_rating_count``, the
485
+ ``product_rating_object`` star distribution) ride on *every* review
486
+ record, so they are read off the first one.
487
+
488
+ Comments are built directly in the shared score/excerpt shape rather
489
+ than routed through ``normalize._remap_comments``, which strips keys it
490
+ does not know -- and rating, date, and verified are exactly the keys
491
+ this source needs to keep. Sorted newest first so the woven sample
492
+ favors recent voices.
493
+ """
494
+ records = response.get("records") if isinstance(response, dict) else None
495
+ if not isinstance(records, list) or not records:
496
+ return [], {}
497
+
498
+ first = records[0]
499
+ distribution = first.get("product_rating_object")
500
+ stats: Dict[str, Any] = {
501
+ "product_rating": _as_float(first.get("product_rating")),
502
+ "product_rating_count": _as_int(first.get("product_rating_count")),
503
+ "star_distribution": distribution if isinstance(distribution, dict) else {},
504
+ }
505
+
506
+ comments: List[Dict[str, Any]] = []
507
+ for record in records:
508
+ if not isinstance(record, dict):
509
+ continue
510
+ body = str(record.get("review_text") or "").strip()
511
+ header = undouble(str(record.get("review_header") or ""))
512
+ excerpt = body or header
513
+ if not excerpt:
514
+ continue
515
+ comments.append(
516
+ {
517
+ # Shared comment shape: downstream weaving reads score/excerpt.
518
+ "score": _as_int(record.get("helpful_count")),
519
+ "excerpt": excerpt,
520
+ "author": str(record.get("author_name") or "").strip(),
521
+ "rating": _as_int(record.get("rating")),
522
+ "date": parse_review_date(record.get("review_posted_date")),
523
+ "verified": bool(record.get("is_verified")),
524
+ "vine": bool(record.get("is_amazon_vine")),
525
+ "title": header,
526
+ }
527
+ )
528
+
529
+ # Newest first; undated records sink rather than disappear (R2a).
530
+ comments.sort(key=lambda c: (c["date"] or "", c["score"]), reverse=True)
531
+ return comments, stats
532
+
533
+
534
+ def _remaining_lane_budget(elapsed: float) -> int:
535
+ """Compute the review lane's wall-clock budget.
536
+
537
+ Returns the lesser of LANE_DEADLINE and whatever remains of the foreground
538
+ contract. If the remaining time is below MIN_USEFUL_REVIEW_BUDGET, returns
539
+ 0 (skip the lane entirely) rather than firing doomed short pulls that spend
540
+ Bright Data credits without returning reviews.
541
+ """
542
+ remaining = FOREGROUND_CONTRACT - elapsed - RENDER_MARGIN
543
+ if remaining < MIN_USEFUL_REVIEW_BUDGET:
544
+ return 0
545
+ return int(min(LANE_DEADLINE, remaining))
546
+
547
+
548
+ def enrich_with_reviews(
549
+ products: Sequence[Dict[str, Any]],
550
+ *,
551
+ depth: str = "default",
552
+ config: Optional[Dict[str, Any]] = None,
553
+ elapsed: float = 0.0,
554
+ max_reviews: int = MAX_REVIEWS,
555
+ brand: str = "",
556
+ keyword: str = "",
557
+ fetcher=None,
558
+ ) -> Tuple[List[Dict[str, Any]], Optional[str]]:
559
+ """Attach review samples to the top products, in parallel, under a deadline.
560
+
561
+ Every product is returned either way. A product whose pull is dropped
562
+ by the deadline keeps its search-record stats and simply carries no
563
+ review sample -- it renders as ``quiet`` rather than vanishing, because
564
+ losing a top product entirely is a worse failure than losing its
565
+ recent-window read. The dropped pull has spent its credit regardless.
566
+
567
+ Returns (enriched_products, status_detail). status_detail is None when
568
+ enrichment succeeded normally, or a string describing a degraded outcome:
569
+ - ``"review lane skipped (budget 0s)"`` -- crumb budget, lane did not run
570
+ - ``"review lane timed out"`` -- all pulls dropped by the deadline
571
+ """
572
+ enriched = [dict(p) for p in products]
573
+ pull_count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
574
+ if pull_count <= 0:
575
+ _log(f"depth={depth}: discovery only, no review pulls")
576
+ return enriched, None
577
+
578
+ budget = _remaining_lane_budget(elapsed)
579
+ if budget <= 0:
580
+ _log(f"review lane skipped (budget {budget}s, floor {MIN_USEFUL_REVIEW_BUDGET}s)")
581
+ return enriched, "review lane skipped (budget 0s)"
582
+
583
+ targets = select_enrichment_targets(
584
+ enriched, limit=pull_count, brand=brand, keyword=keyword
585
+ )
586
+ if not targets:
587
+ return enriched, None
588
+
589
+ by_asin = {p["asin"]: p for p in enriched}
590
+ pull = fetcher or (
591
+ lambda url: fetch_reviews(
592
+ url, max_reviews=max_reviews, config=config,
593
+ timeout=min(REVIEW_TIMEOUT, budget),
594
+ )
595
+ )
596
+
597
+ _log(f"pulling up to {max_reviews} reviews for {len(targets)} products (budget {budget}s)")
598
+ started = time.monotonic()
599
+ completed_count = 0
600
+ dropped_count = 0
601
+ # Not a `with` block on purpose. Every future is already running (one
602
+ # worker per target), so `future.cancel()` can never succeed, and
603
+ # ThreadPoolExecutor's context-manager exit calls shutdown(wait=True) --
604
+ # which would block on the very straggler the deadline just declared
605
+ # dropped, making the deadline advisory rather than real. Shutting down
606
+ # without waiting lets the abandoned thread finish and discard its result
607
+ # in the background while the run proceeds.
608
+ pool = ThreadPoolExecutor(max_workers=max(1, len(targets)))
609
+ try:
610
+ futures = {pool.submit(pull, t["url"]): t["asin"] for t in targets}
611
+ try:
612
+ for future in as_completed(futures, timeout=budget):
613
+ asin = futures[future]
614
+ try:
615
+ response = future.result()
616
+ except Exception as exc: # never let one pull kill siblings
617
+ _log(f"review pull failed for {asin}: {exc}")
618
+ continue
619
+ if response.get("error"):
620
+ _log(f"review pull error for {asin}: {response['error']}")
621
+ continue
622
+ comments, stats = parse_reviews(response)
623
+ product = by_asin.get(asin)
624
+ if product is None:
625
+ continue
626
+ product["top_comments"] = comments
627
+ product.update({k: v for k, v in stats.items() if v})
628
+ completed_count += 1
629
+ except TimeoutError:
630
+ dropped_count = sum(1 for f in futures if not f.done())
631
+ _log(f"lane deadline {budget}s hit; dropped {dropped_count} straggling pull(s)")
632
+ finally:
633
+ pool.shutdown(wait=False, cancel_futures=True)
634
+
635
+ _log(f"review lane finished in {time.monotonic() - started:.0f}s")
636
+
637
+ # Report degraded outcome if all pulls dropped (none completed)
638
+ status_detail = None
639
+ if completed_count == 0 and dropped_count > 0:
640
+ status_detail = "review lane timed out"
641
+
642
+ return enriched, status_detail
643
+
644
+
645
+ def enrich_source_items(
646
+ items: List[Any],
647
+ *,
648
+ depth: str = "default",
649
+ config: Optional[Dict[str, Any]] = None,
650
+ keyword: str = "",
651
+ elapsed: float = 0.0,
652
+ max_reviews: int = MAX_REVIEWS,
653
+ fetcher=None,
654
+ ) -> List[Any]:
655
+ """Attach review samples to the amazon SourceItems that survived dedupe.
656
+
657
+ Reads product identity out of ``metadata`` and writes ``top_comments``
658
+ plus the computed stat block back into it, in place. Runs from
659
+ ``pipeline._finalize_items_by_source`` so the review budget is spent on
660
+ the products the brief will actually show, not on the top of the raw
661
+ fanout (the digg enrichment precedent).
662
+ """
663
+ products: List[Dict[str, Any]] = []
664
+ by_asin: Dict[str, Any] = {}
665
+ for item in items:
666
+ if getattr(item, "source", None) != "amazon":
667
+ continue
668
+ metadata = getattr(item, "metadata", None) or {}
669
+ asin = str(metadata.get("asin") or "").strip()
670
+ if not asin or metadata.get("top_comments"):
671
+ continue
672
+ products.append(
673
+ {
674
+ "asin": asin,
675
+ "url": getattr(item, "url", "") or metadata.get("url", ""),
676
+ "name": metadata.get("name") or getattr(item, "title", ""),
677
+ "short_name": metadata.get("short_name") or "",
678
+ "brand": metadata.get("brand") or "",
679
+ "num_ratings": metadata.get("num_ratings") or 0,
680
+ "rating": metadata.get("rating"),
681
+ }
682
+ )
683
+ by_asin[asin] = item
684
+
685
+ if not products:
686
+ return items
687
+
688
+ enriched, _status = enrich_with_reviews(
689
+ products, depth=depth, config=config, elapsed=elapsed,
690
+ max_reviews=max_reviews, keyword=keyword, fetcher=fetcher,
691
+ )
692
+ for product in enriched:
693
+ item = by_asin.get(product["asin"])
694
+ if item is None:
695
+ continue
696
+ metadata = getattr(item, "metadata", None)
697
+ if metadata is None:
698
+ continue
699
+ if product.get("top_comments"):
700
+ metadata["top_comments"] = product["top_comments"]
701
+ stats = product_stats(product)
702
+ metadata["stats"] = stats
703
+ # The review pull's product_rating_count supersedes the search
704
+ # record's, which is variant-level and can undercount by orders of
705
+ # magnitude (84 on a record whose pull reported 8,446). Normalization
706
+ # ran before enrichment, so refresh the surfaces that already baked
707
+ # the old number in -- otherwise one product shows two different
708
+ # rating counts in the same report.
709
+ for key in ("product_rating", "product_rating_count", "star_distribution"):
710
+ if product.get(key):
711
+ metadata[key] = product[key]
712
+ authoritative = stats.get("ratings_total") or 0
713
+ if authoritative and getattr(item, "engagement", None) is not None:
714
+ item.engagement["ratings"] = authoritative
715
+ metadata["num_ratings"] = authoritative
716
+ _refresh_title(item, stats)
717
+ return items
718
+
719
+
720
+ def _refresh_title(item: Any, stats: Dict[str, Any]) -> None:
721
+ """Rewrite the trailing "- 4.4/5 (N ratings)" headline after enrichment."""
722
+ title = getattr(item, "title", "") or ""
723
+ rating = stats.get("all_time")
724
+ total = stats.get("ratings_total") or 0
725
+ if not title or rating is None or not total:
726
+ return
727
+ headline = f"{rating}/5 ({total:,} ratings)"
728
+ base = title.rsplit(" - ", 1)[0] if " - " in title else title
729
+ item.title = f"{base} - {headline}"
730
+
731
+
732
+ # ------------------------------------------------------------------ stats
733
+
734
+
735
+ def stats_from_item(item: Any, *, today: Optional[datetime] = None) -> Dict[str, Any]:
736
+ """Compute the stat block for a rendered SourceItem.
737
+
738
+ Enrichment stores a precomputed block, but mock runs and replayed
739
+ fixtures skip enrichment entirely, so render recomputes from metadata
740
+ when it is absent. Cheap and pure -- all the inputs already live on
741
+ the item.
742
+ """
743
+ metadata = getattr(item, "metadata", None) or {}
744
+ cached = metadata.get("stats")
745
+ if isinstance(cached, dict) and cached:
746
+ return cached
747
+ return product_stats(
748
+ {
749
+ "short_name": metadata.get("short_name") or "",
750
+ "name": metadata.get("name") or getattr(item, "title", ""),
751
+ "url": getattr(item, "url", "") or "",
752
+ "rating": metadata.get("rating"),
753
+ "num_ratings": metadata.get("num_ratings") or 0,
754
+ "product_rating": metadata.get("product_rating"),
755
+ "product_rating_count": metadata.get("product_rating_count") or 0,
756
+ "star_distribution": metadata.get("star_distribution") or {},
757
+ "top_comments": metadata.get("top_comments") or [],
758
+ },
759
+ today=today,
760
+ )
761
+
762
+
763
+ def footer_entry(stats: Dict[str, Any], *, quote: str = "") -> str:
764
+ """Render one product's segment of the emoji-footer line (R1c).
765
+
766
+ Shapes, by drift state::
767
+
768
+ Chill Max XL 4.4★→3.8★ ↓ "the lid jams" negative drift (+ quote)
769
+ Deluxe Bag 4.7★→5.0★ positive or flat drift
770
+ Spirit E-325 4.4★ quiet too few in-window reviews
771
+ BLUEY Set new no all-time baseline
772
+
773
+ The ``↓`` is asymmetric on purpose: a sagging product is the alarm
774
+ worth catching at a glance, and a healthy one needs no decoration.
775
+ """
776
+ name = stats.get("short_name") or "Product"
777
+ all_time = stats.get("all_time")
778
+ recent = stats.get("recent_avg")
779
+ drift = stats.get("drift")
780
+
781
+ if drift == "new" or all_time is None:
782
+ return f"{name} new"
783
+ if drift == "quiet" or recent is None:
784
+ return f"{name} {all_time}★ quiet"
785
+
786
+ entry = f"{name} {all_time}★→{recent}★"
787
+ if drift == "down":
788
+ entry += " ↓"
789
+ if quote:
790
+ entry += f' "{quote}"'
791
+ return entry
792
+
793
+
794
+ def five_star_share(distribution: Dict[str, Any]) -> Optional[float]:
795
+ """Share of ratings that are 5-star, from the star-distribution object."""
796
+ if not isinstance(distribution, dict) or not distribution:
797
+ return None
798
+ total = sum(_as_int(distribution.get(key)) for key, _ in _STAR_FIELDS)
799
+ if total <= 0:
800
+ return None
801
+ return _as_int(distribution.get("five_star")) / total
802
+
803
+
804
+ def recent_window_stats(
805
+ comments: Iterable[Dict[str, Any]],
806
+ *,
807
+ today: Optional[datetime] = None,
808
+ window_days: int = RECENT_WINDOW_DAYS,
809
+ ) -> Dict[str, Any]:
810
+ """Average rating and sample size inside the recent window."""
811
+ reference = (today or _today()).date()
812
+ ratings: List[int] = []
813
+ for comment in comments or []:
814
+ iso = comment.get("date")
815
+ if not iso:
816
+ continue
817
+ try:
818
+ posted = datetime.strptime(iso, "%Y-%m-%d").date()
819
+ except (TypeError, ValueError):
820
+ continue
821
+ if 0 <= (reference - posted).days <= window_days:
822
+ rating = _as_int(comment.get("rating"))
823
+ if rating:
824
+ ratings.append(rating)
825
+ if not ratings:
826
+ return {"recent_n": 0, "recent_avg": None}
827
+ return {"recent_n": len(ratings), "recent_avg": sum(ratings) / len(ratings)}
828
+
829
+
830
+ def product_stats(
831
+ product: Dict[str, Any],
832
+ *,
833
+ today: Optional[datetime] = None,
834
+ ) -> Dict[str, Any]:
835
+ """Compute the render-facing stat block for one product.
836
+
837
+ ``drift`` is one of:
838
+ * ``"new"`` -- no all-time baseline to move away from
839
+ * ``"quiet"`` -- baseline exists but the window has too few dated
840
+ reviews to average honestly (below MIN_DRIFT_SAMPLE)
841
+ * ``"up"`` / ``"down"`` / ``"flat"`` -- a real, sample-backed move
842
+
843
+ The engine owns every number here; the model owns the words (R1b).
844
+ """
845
+ # The review pull's rating count supersedes the search record's, which
846
+ # can be variant-level and badly low.
847
+ all_time = product.get("product_rating")
848
+ if all_time is None:
849
+ all_time = product.get("rating")
850
+ ratings_total = product.get("product_rating_count") or product.get("num_ratings") or 0
851
+
852
+ window = recent_window_stats(product.get("top_comments") or [], today=today)
853
+ recent_avg = window["recent_avg"]
854
+ recent_n = window["recent_n"]
855
+
856
+ if all_time is None:
857
+ drift = "new"
858
+ elif recent_n < MIN_DRIFT_SAMPLE or recent_avg is None:
859
+ drift = "quiet"
860
+ elif round(recent_avg, 1) > round(float(all_time), 1):
861
+ drift = "up"
862
+ elif round(recent_avg, 1) < round(float(all_time), 1):
863
+ drift = "down"
864
+ else:
865
+ drift = "flat"
866
+
867
+ return {
868
+ "short_name": product.get("short_name") or short_name(product.get("name", "")),
869
+ "url": product.get("url", ""),
870
+ "all_time": round(float(all_time), 1) if all_time is not None else None,
871
+ "ratings_total": _as_int(ratings_total),
872
+ "five_star_share": five_star_share(product.get("star_distribution") or {}),
873
+ "recent_avg": round(recent_avg, 1) if recent_avg is not None else None,
874
+ "recent_n": recent_n,
875
+ "reviews_pulled": len(product.get("top_comments") or []),
876
+ "drift": drift,
877
+ }