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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
+
}
|