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
|
@@ -7,12 +7,16 @@ Requires SCRAPECREATORS_API_KEY in config (same key as TikTok + Instagram).
|
|
|
7
7
|
API docs: https://scrapecreators.com/docs
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
|
+
import copy
|
|
10
11
|
import math
|
|
11
12
|
import re
|
|
12
13
|
import sys
|
|
14
|
+
import threading
|
|
13
15
|
import time
|
|
16
|
+
from contextlib import ExitStack
|
|
14
17
|
from collections import Counter
|
|
15
18
|
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
|
|
19
|
+
from datetime import date, datetime, timezone
|
|
16
20
|
from typing import Any, Dict, List, Optional, Set
|
|
17
21
|
|
|
18
22
|
def _first_of(*values, default=None):
|
|
@@ -22,7 +26,7 @@ def _first_of(*values, default=None):
|
|
|
22
26
|
return v
|
|
23
27
|
return default
|
|
24
28
|
|
|
25
|
-
from . import dates, http, log
|
|
29
|
+
from . import dates, health, http, log
|
|
26
30
|
|
|
27
31
|
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
|
|
28
32
|
|
|
@@ -39,19 +43,16 @@ DEPTH_CONFIG = {
|
|
|
39
43
|
"global_searches": 1,
|
|
40
44
|
"subreddit_searches": 2,
|
|
41
45
|
"comment_enrichments": 3,
|
|
42
|
-
"timeframe": "week",
|
|
43
46
|
},
|
|
44
47
|
"default": {
|
|
45
48
|
"global_searches": 2,
|
|
46
49
|
"subreddit_searches": 3,
|
|
47
50
|
"comment_enrichments": 5,
|
|
48
|
-
"timeframe": "month",
|
|
49
51
|
},
|
|
50
52
|
"deep": {
|
|
51
53
|
"global_searches": 3,
|
|
52
54
|
"subreddit_searches": 5,
|
|
53
55
|
"comment_enrichments": 8,
|
|
54
|
-
"timeframe": "month",
|
|
55
56
|
},
|
|
56
57
|
}
|
|
57
58
|
|
|
@@ -78,6 +79,16 @@ def _log(msg: str):
|
|
|
78
79
|
log.source_log("Reddit", msg, tty_only=False)
|
|
79
80
|
|
|
80
81
|
|
|
82
|
+
def classify_run_failure(detail: str) -> str:
|
|
83
|
+
"""Map Reddit auth and anti-bot responses that do not carry HTTP status."""
|
|
84
|
+
text = detail.lower()
|
|
85
|
+
if any(marker in text for marker in ("interstitial", "blocked by reddit", "too many requests")):
|
|
86
|
+
return health.RATE_LIMITED
|
|
87
|
+
if any(marker in text for marker in ("login required", "invalid token", "expired token")):
|
|
88
|
+
return health.AUTH_FAILED
|
|
89
|
+
return http.classify_failure(message=detail)
|
|
90
|
+
|
|
91
|
+
|
|
81
92
|
def _extract_core_subject(topic: str) -> str:
|
|
82
93
|
"""Extract core subject from verbose query.
|
|
83
94
|
|
|
@@ -253,7 +264,7 @@ def _relevance_rank_key(item: Dict[str, Any]) -> float:
|
|
|
253
264
|
~0) above an on-topic one (relevance >= RELEVANCE_FLOOR).
|
|
254
265
|
"""
|
|
255
266
|
rel = item.get("relevance") or 0.0
|
|
256
|
-
eng_bonus = min(0.25, math.log10(_total_engagement(item) + 1) / 20.0)
|
|
267
|
+
eng_bonus = min(0.25, math.log10(max(0, _total_engagement(item)) + 1) / 20.0)
|
|
257
268
|
return rel + eng_bonus
|
|
258
269
|
|
|
259
270
|
|
|
@@ -394,6 +405,9 @@ def _subreddit_search(
|
|
|
394
405
|
def fetch_post_comments(
|
|
395
406
|
url: str,
|
|
396
407
|
token: str,
|
|
408
|
+
*,
|
|
409
|
+
deadline_monotonic: float | None = None,
|
|
410
|
+
cancel: threading.Event | None = None,
|
|
397
411
|
) -> List[Dict[str, Any]]:
|
|
398
412
|
"""Fetch comments for a Reddit post via ScrapeCreators.
|
|
399
413
|
|
|
@@ -411,6 +425,9 @@ def fetch_post_comments(
|
|
|
411
425
|
params={"url": url},
|
|
412
426
|
timeout=30,
|
|
413
427
|
retries=2,
|
|
428
|
+
deadline_monotonic=deadline_monotonic,
|
|
429
|
+
cancel=cancel,
|
|
430
|
+
owned_get=deadline_monotonic is not None,
|
|
414
431
|
)
|
|
415
432
|
return data.get("comments", data.get("data", []))
|
|
416
433
|
except http.HTTPError as e:
|
|
@@ -443,6 +460,51 @@ def _dedupe_posts(posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
|
443
460
|
return unique
|
|
444
461
|
|
|
445
462
|
|
|
463
|
+
def _days_to_reddit_bucket(days: float) -> str:
|
|
464
|
+
"""Map a day count onto the smallest Reddit rolling bucket that covers it.
|
|
465
|
+
|
|
466
|
+
Adds one day of slack so calendar windows that cross a day boundary still
|
|
467
|
+
fit inside Reddit's rolling ``t=`` buckets (a yesterday→today request needs
|
|
468
|
+
``week``, not ``day``).
|
|
469
|
+
"""
|
|
470
|
+
covered = days + 1
|
|
471
|
+
if covered <= 1:
|
|
472
|
+
return "day"
|
|
473
|
+
if covered <= 7:
|
|
474
|
+
return "week"
|
|
475
|
+
if covered <= 31:
|
|
476
|
+
return "month"
|
|
477
|
+
if covered <= 366:
|
|
478
|
+
return "year"
|
|
479
|
+
return "all"
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _window_to_time_filter(from_date: str, to_date: str) -> str:
|
|
483
|
+
"""Map a requested YYYY-MM-DD window onto Reddit's coarse `t` param.
|
|
484
|
+
|
|
485
|
+
Reddit's ``t=day|week|month`` buckets are rolling windows ending *now*, not
|
|
486
|
+
calendar spans and not anchored to ``to_date``. Coverage therefore needs:
|
|
487
|
+
|
|
488
|
+
1. Span — a yesterday→today request needs more than rolling ``t=day``.
|
|
489
|
+
2. Historical reach — a one-day request ending two weeks ago still needs a
|
|
490
|
+
bucket that reaches ``from_date``; span-alone would pick ``week`` and
|
|
491
|
+
the API would omit the entire requested range.
|
|
492
|
+
|
|
493
|
+
Take the wider of the two, independently of retrieval depth.
|
|
494
|
+
Phase 5 still trims to ``from_date``/``to_date``. Falls back to ``month``
|
|
495
|
+
if the dates don't parse.
|
|
496
|
+
"""
|
|
497
|
+
try:
|
|
498
|
+
start = date.fromisoformat(from_date)
|
|
499
|
+
end = date.fromisoformat(to_date)
|
|
500
|
+
except (ValueError, TypeError):
|
|
501
|
+
return "month"
|
|
502
|
+
span_days = max(0, (end - start).days)
|
|
503
|
+
# Age of from_date relative to "today" — Reddit always anchors to now.
|
|
504
|
+
age_days = max(0, (datetime.now(timezone.utc).date() - start).days)
|
|
505
|
+
return _days_to_reddit_bucket(max(span_days, age_days))
|
|
506
|
+
|
|
507
|
+
|
|
446
508
|
def search_reddit(
|
|
447
509
|
topic: str,
|
|
448
510
|
from_date: str,
|
|
@@ -470,7 +532,7 @@ def search_reddit(
|
|
|
470
532
|
return {"items": [], "error": "No SCRAPECREATORS_API_KEY configured"}
|
|
471
533
|
|
|
472
534
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
473
|
-
timeframe =
|
|
535
|
+
timeframe = _window_to_time_filter(from_date, to_date)
|
|
474
536
|
intent = infer_query_intent(topic)
|
|
475
537
|
|
|
476
538
|
# === Phase 1: Query Expansion ===
|
|
@@ -487,7 +549,9 @@ def search_reddit(
|
|
|
487
549
|
with ThreadPoolExecutor(max_workers=min(5, len(subreddits))) as executor:
|
|
488
550
|
futures = {}
|
|
489
551
|
for sub in subreddits:
|
|
490
|
-
futures[
|
|
552
|
+
futures[http.submit_with_context(
|
|
553
|
+
executor, _subreddit_search, sub, core, token, "relevance", timeframe,
|
|
554
|
+
)] = sub
|
|
491
555
|
for future in as_completed(futures):
|
|
492
556
|
sub = futures[future]
|
|
493
557
|
sub_posts = future.result()
|
|
@@ -506,7 +570,9 @@ def search_reddit(
|
|
|
506
570
|
# from relevant communities instead of keyword-matched noise.
|
|
507
571
|
sort = "top" if intent in ("product", "comparison") else ("relevance" if i == 0 else "top")
|
|
508
572
|
_log(f"Global search {i+1}/{max_global}: '{query}' (sort={sort})")
|
|
509
|
-
futures[
|
|
573
|
+
futures[http.submit_with_context(
|
|
574
|
+
executor, _global_search, query, token, sort, timeframe,
|
|
575
|
+
)] = query
|
|
510
576
|
for future in as_completed(futures):
|
|
511
577
|
query = futures[future]
|
|
512
578
|
posts = future.result()
|
|
@@ -529,7 +595,9 @@ def search_reddit(
|
|
|
529
595
|
futures = {}
|
|
530
596
|
for sub in discovered_subs[:subreddit_limit]:
|
|
531
597
|
_log(f"Subreddit search: r/{sub} for '{core}'")
|
|
532
|
-
futures[
|
|
598
|
+
futures[http.submit_with_context(
|
|
599
|
+
executor, _subreddit_search, sub, core, token, "relevance", timeframe,
|
|
600
|
+
)] = sub
|
|
533
601
|
for future in as_completed(futures):
|
|
534
602
|
sub = futures[future]
|
|
535
603
|
sub_posts = future.result()
|
|
@@ -608,7 +676,7 @@ def enrich_with_comments(
|
|
|
608
676
|
config = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
609
677
|
max_comments = config["comment_enrichments"]
|
|
610
678
|
|
|
611
|
-
if not items or not token or max_comments <= 0:
|
|
679
|
+
if not items or not token or max_comments <= 0 or budget_seconds <= 0:
|
|
612
680
|
return items
|
|
613
681
|
|
|
614
682
|
# Select the top threads by total engagement (upvotes + comment count),
|
|
@@ -619,10 +687,24 @@ def enrich_with_comments(
|
|
|
619
687
|
_log(f"Enriching comments for {len(top_items)} posts (by total engagement)")
|
|
620
688
|
|
|
621
689
|
start = time.monotonic()
|
|
690
|
+
deadline = start + budget_seconds
|
|
691
|
+
cancel = threading.Event()
|
|
692
|
+
|
|
693
|
+
def fetch(item):
|
|
694
|
+
if cancel.is_set() or time.monotonic() >= deadline:
|
|
695
|
+
return None
|
|
696
|
+
comments = fetch_post_comments(
|
|
697
|
+
item.get("url", ""), token,
|
|
698
|
+
deadline_monotonic=deadline, cancel=cancel,
|
|
699
|
+
)
|
|
700
|
+
return comments if time.monotonic() < deadline else None
|
|
622
701
|
|
|
623
|
-
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
|
|
702
|
+
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor, ExitStack() as cleanup:
|
|
703
|
+
cleanup.callback(cancel.set)
|
|
624
704
|
futures = {
|
|
625
|
-
|
|
705
|
+
http.submit_with_context(
|
|
706
|
+
executor, fetch, item,
|
|
707
|
+
): item
|
|
626
708
|
for item in top_items
|
|
627
709
|
if item.get("url")
|
|
628
710
|
}
|
|
@@ -630,6 +712,8 @@ def enrich_with_comments(
|
|
|
630
712
|
# Wait with budget instead of unbounded as_completed
|
|
631
713
|
remaining = max(0, budget_seconds - (time.monotonic() - start))
|
|
632
714
|
done, not_done = futures_wait(futures, timeout=remaining)
|
|
715
|
+
if not_done:
|
|
716
|
+
cancel.set()
|
|
633
717
|
|
|
634
718
|
enriched_count = 0
|
|
635
719
|
for future in done:
|
|
@@ -723,6 +807,87 @@ def search_and_enrich(
|
|
|
723
807
|
return result
|
|
724
808
|
|
|
725
809
|
|
|
810
|
+
# Run-scoped memo for the paid ScrapeCreators Reddit call (R9). All subquery
|
|
811
|
+
# streams share the raw topic, and the thin-source retry repeats it, so without
|
|
812
|
+
# this one run could pay for the same query five times. Concurrent callers for
|
|
813
|
+
# one key wait on the first call; results AND failures are kept until the
|
|
814
|
+
# per-command reset, so a failed backfill is never retried within the run.
|
|
815
|
+
_SC_MEMO: Dict[tuple, tuple] = {}
|
|
816
|
+
_SC_INFLIGHT: Dict[tuple, threading.Event] = {}
|
|
817
|
+
_SC_MEMO_LOCK = threading.Lock()
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def reset_scrapecreators_memo() -> None:
|
|
821
|
+
"""Forget memoized ScrapeCreators Reddit calls. Called once per command, and by tests."""
|
|
822
|
+
with _SC_MEMO_LOCK:
|
|
823
|
+
_SC_MEMO.clear()
|
|
824
|
+
_SC_INFLIGHT.clear()
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _sc_memo_outcome(entry: tuple, replay_failures: bool = True) -> Dict[str, Any]:
|
|
828
|
+
ok, value, failures = entry
|
|
829
|
+
if replay_failures:
|
|
830
|
+
# search_and_enrich swallows ScrapeCreators HTTP errors into the
|
|
831
|
+
# caller's failure sink; a later caller must see them too, or an
|
|
832
|
+
# empty cached result reads as a clean no-results.
|
|
833
|
+
for failure in failures:
|
|
834
|
+
http._record_failure(failure)
|
|
835
|
+
if not ok:
|
|
836
|
+
raise value
|
|
837
|
+
# Each caller gets its own copy so one stream cannot mutate another's items.
|
|
838
|
+
return copy.deepcopy(value)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def search_and_enrich_memo(
|
|
842
|
+
topic: str,
|
|
843
|
+
from_date: str,
|
|
844
|
+
to_date: str,
|
|
845
|
+
depth: str = "default",
|
|
846
|
+
token: str = None,
|
|
847
|
+
subreddits: List[str] | None = None,
|
|
848
|
+
) -> Dict[str, Any]:
|
|
849
|
+
""":func:`search_and_enrich`, at most once per key per command.
|
|
850
|
+
|
|
851
|
+
Keyed by query, date window, depth, and sorted subreddits.
|
|
852
|
+
"""
|
|
853
|
+
key = (topic, from_date, to_date, depth, tuple(sorted(subreddits or ())))
|
|
854
|
+
while True:
|
|
855
|
+
with _SC_MEMO_LOCK:
|
|
856
|
+
entry = _SC_MEMO.get(key)
|
|
857
|
+
if entry is None:
|
|
858
|
+
gate = _SC_INFLIGHT.get(key)
|
|
859
|
+
owner = gate is None
|
|
860
|
+
if owner:
|
|
861
|
+
gate = threading.Event()
|
|
862
|
+
_SC_INFLIGHT[key] = gate
|
|
863
|
+
if entry is not None:
|
|
864
|
+
return _sc_memo_outcome(entry)
|
|
865
|
+
if owner:
|
|
866
|
+
break
|
|
867
|
+
gate.wait()
|
|
868
|
+
# Loop: read the owner's cached outcome, or re-elect if it was
|
|
869
|
+
# interrupted before caching one (e.g. KeyboardInterrupt).
|
|
870
|
+
try:
|
|
871
|
+
with http.tee_failures() as recorded:
|
|
872
|
+
try:
|
|
873
|
+
result = search_and_enrich(
|
|
874
|
+
topic, from_date, to_date, depth=depth, token=token,
|
|
875
|
+
subreddits=subreddits,
|
|
876
|
+
)
|
|
877
|
+
outcome = (True, result)
|
|
878
|
+
except Exception as exc:
|
|
879
|
+
outcome = (False, exc)
|
|
880
|
+
entry = (*outcome, list(recorded))
|
|
881
|
+
with _SC_MEMO_LOCK:
|
|
882
|
+
_SC_MEMO[key] = entry
|
|
883
|
+
finally:
|
|
884
|
+
with _SC_MEMO_LOCK:
|
|
885
|
+
_SC_INFLIGHT.pop(key, None)
|
|
886
|
+
gate.set()
|
|
887
|
+
# The owner's failures already reached its own sink through tee_failures.
|
|
888
|
+
return _sc_memo_outcome(entry, replay_failures=False)
|
|
889
|
+
|
|
890
|
+
|
|
726
891
|
def parse_reddit_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
727
892
|
"""Parse ScrapeCreators response to item list.
|
|
728
893
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Arctic-shift score resolver — post upvote counts by id, keyless and free.
|
|
2
2
|
|
|
3
|
-
``search.json`` and ``/comments/{id}.json`` are 403 keyless
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
``search.json`` and ``/comments/{id}.json`` are 403 keyless. Site search and the
|
|
4
|
+
shreddit listing partials usually carry a score, but some posts still arrive
|
|
5
|
+
unscored. For a thread that reaches the merge with score 0, the free score
|
|
6
|
+
comes from arctic-shift
|
|
7
7
|
(https://arctic-shift.photon-reddit.com), a public Reddit archive whose
|
|
8
8
|
``/api/posts/ids`` returns the post object (score, num_comments, title) for a
|
|
9
9
|
batch of base36 post ids. Scores are point-in-time snapshots — slightly stale vs
|
|
@@ -16,16 +16,32 @@ count rather than failing the Reddit source.
|
|
|
16
16
|
|
|
17
17
|
import sys
|
|
18
18
|
import time
|
|
19
|
-
from
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
20
21
|
|
|
21
22
|
from . import http
|
|
22
23
|
|
|
23
24
|
API = "https://arctic-shift.photon-reddit.com/api/posts/ids"
|
|
25
|
+
SEARCH_API = "https://arctic-shift.photon-reddit.com/api/posts/search"
|
|
24
26
|
BATCH = 50 # ids per request
|
|
25
27
|
TIMEOUT = 15
|
|
26
28
|
MAX_BATCHES = 3 # cap total requests per run (bounds latency + rate-limit risk)
|
|
27
29
|
PACE_SECONDS = 0.4 # gap between batches; arctic-shift answers 422 "slow down"
|
|
28
30
|
CACHE_MAX = 4096 # hard size bound so the in-run memo can never grow unbounded
|
|
31
|
+
# Listing-lane knobs. Base limits mirror reddit_listing's DEPTH_LIMITS so callers
|
|
32
|
+
# get the same per-depth volume. The supplement multiplier is applied when the
|
|
33
|
+
# caller requested multiple sorts (top/hot/new) — arctic-shift has no sort lanes,
|
|
34
|
+
# so we fetch more posts to increase the chance of covering what the failed
|
|
35
|
+
# shreddit lanes would have returned.
|
|
36
|
+
#
|
|
37
|
+
# KNOWN LIMITATION: Arctic-shift is recency-only (sort=desc). It has no top/hot/
|
|
38
|
+
# new/rising lanes — failed shreddit sort lanes are supplemented with recent
|
|
39
|
+
# posts, not lane-specific results. This is a fundamental backend constraint.
|
|
40
|
+
_LISTING_DEPTH_LIMITS = {"quick": 10, "default": 25, "deep": 50}
|
|
41
|
+
_LISTING_SUPPLEMENT_MULTIPLIER = 2 # fetch 2x posts when supplementing multi-sort requests
|
|
42
|
+
# Total deadline for listing fetches to prevent unbounded stalls when many
|
|
43
|
+
# subreddits are requested and arctic is slow/unreachable.
|
|
44
|
+
_LISTING_DEADLINE_SECONDS = 45 # ~3 subs at 15s timeout each
|
|
29
45
|
# In-run memo: base36 id -> {score, num_comments}. Module-level so repeated
|
|
30
46
|
# fetch_scores calls within one `/last30days` run (e.g. across subqueries) reuse
|
|
31
47
|
# results, but capped at CACHE_MAX entries (never reached in a normal CLI run).
|
|
@@ -90,3 +106,128 @@ def fetch_scores(post_ids: List[str]) -> Dict[str, Dict[str, int]]:
|
|
|
90
106
|
_cache[rid] = entry
|
|
91
107
|
out[rid] = entry
|
|
92
108
|
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _epoch_to_date(value: Any) -> Optional[str]:
|
|
112
|
+
"""Epoch seconds -> YYYY-MM-DD (UTC), or None on garbage."""
|
|
113
|
+
try:
|
|
114
|
+
return datetime.fromtimestamp(int(value), tz=timezone.utc).date().isoformat()
|
|
115
|
+
except (TypeError, ValueError, OSError):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _normalize_listing_row(row: Dict[str, Any], query: str = "") -> Dict[str, Any]:
|
|
120
|
+
"""Normalize an arctic-shift post row to reddit_listing.parse_cards shape.
|
|
121
|
+
|
|
122
|
+
Mirrors the shreddit card schema (title/url/score/num_comments/subreddit/
|
|
123
|
+
created_utc/author/selftext/date/engagement/relevance/metadata.post_id) so
|
|
124
|
+
reddit_keyless can consume either backend interchangeably.
|
|
125
|
+
"""
|
|
126
|
+
from .relevance import token_overlap_relevance
|
|
127
|
+
|
|
128
|
+
pid = str(row.get("id") or "").removeprefix("t3_")
|
|
129
|
+
permalink = row.get("permalink") or ""
|
|
130
|
+
title = row.get("title") or ""
|
|
131
|
+
try:
|
|
132
|
+
score = int(row.get("score") or 0)
|
|
133
|
+
except (TypeError, ValueError):
|
|
134
|
+
score = 0
|
|
135
|
+
try:
|
|
136
|
+
num_comments = int(row.get("num_comments") or 0)
|
|
137
|
+
except (TypeError, ValueError):
|
|
138
|
+
num_comments = 0
|
|
139
|
+
author = row.get("author") or "[deleted]"
|
|
140
|
+
if author in ("[deleted]", "[removed]"):
|
|
141
|
+
author = "[deleted]"
|
|
142
|
+
url = f"https://www.reddit.com{permalink}" if permalink.startswith("/") else (permalink or "")
|
|
143
|
+
return {
|
|
144
|
+
"id": "",
|
|
145
|
+
"title": title,
|
|
146
|
+
"url": url,
|
|
147
|
+
"score": score,
|
|
148
|
+
"num_comments": num_comments,
|
|
149
|
+
"subreddit": row.get("subreddit") or "",
|
|
150
|
+
"created_utc": row.get("created_utc"),
|
|
151
|
+
"author": author,
|
|
152
|
+
"selftext": row.get("selftext") or "",
|
|
153
|
+
"date": _epoch_to_date(row.get("created_utc")),
|
|
154
|
+
"engagement": {"score": score, "num_comments": num_comments, "upvote_ratio": None},
|
|
155
|
+
"relevance": round(token_overlap_relevance(query, title), 3) if query else 0.0,
|
|
156
|
+
"why_relevant": "Reddit listing (arctic-shift)",
|
|
157
|
+
"metadata": {"post_id": pid},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def fetch_listings(
|
|
162
|
+
subreddits: List[str],
|
|
163
|
+
depth: str = "default",
|
|
164
|
+
query: str = "",
|
|
165
|
+
sorts: Optional[List[str]] = None,
|
|
166
|
+
timeframe: str = "month",
|
|
167
|
+
limit: Optional[int] = None,
|
|
168
|
+
) -> List[Dict[str, Any]]:
|
|
169
|
+
"""Scored subreddit listings from the arctic-shift archive, keyless.
|
|
170
|
+
|
|
171
|
+
Drop-in fallback/supplement for ``reddit_listing.fetch_listings`` (shreddit
|
|
172
|
+
partials), which datacenter IPs get HTTP 403 on. Arctic-shift serves recent
|
|
173
|
+
posts with real score/num_comments from any IP.
|
|
174
|
+
|
|
175
|
+
Arctic-shift has no top/hot/new lanes, only recency. When ``sorts`` contains
|
|
176
|
+
multiple entries (e.g., dedicated lanes requesting top+hot+new), we fetch
|
|
177
|
+
more posts per subreddit to partially compensate for the missing lane
|
|
178
|
+
coverage — the caller's engagement ranking does the final sorting.
|
|
179
|
+
|
|
180
|
+
Best-effort, never raises: returns ``[]`` on any failure.
|
|
181
|
+
"""
|
|
182
|
+
if not subreddits:
|
|
183
|
+
return []
|
|
184
|
+
base = limit or _LISTING_DEPTH_LIMITS.get(depth, _LISTING_DEPTH_LIMITS["default"])
|
|
185
|
+
# When multiple sorts were requested, fetch more posts to compensate for
|
|
186
|
+
# arctic-shift's lack of sort lanes.
|
|
187
|
+
n = base * _LISTING_SUPPLEMENT_MULTIPLIER if sorts and len(sorts) > 1 else base
|
|
188
|
+
out: List[Dict[str, Any]] = []
|
|
189
|
+
# Process all requested subreddits with pacing and a total deadline to
|
|
190
|
+
# prevent unbounded stalls when arctic is slow or unreachable.
|
|
191
|
+
deadline = time.time() + _LISTING_DEADLINE_SECONDS
|
|
192
|
+
fetched_count = 0
|
|
193
|
+
for sub in subreddits:
|
|
194
|
+
if time.time() >= deadline:
|
|
195
|
+
_log(f"listing deadline reached after {fetched_count} subs; skipping remaining")
|
|
196
|
+
break
|
|
197
|
+
sub = sub.removeprefix("r/").strip()
|
|
198
|
+
if not sub or sub.lower() == "all":
|
|
199
|
+
continue
|
|
200
|
+
if fetched_count:
|
|
201
|
+
time.sleep(PACE_SECONDS)
|
|
202
|
+
fetched_count += 1
|
|
203
|
+
try:
|
|
204
|
+
# Use retries=1 (single attempt) so retries don't exceed our deadline.
|
|
205
|
+
# The deadline handles overall timing; per-request retries would
|
|
206
|
+
# multiply the delay unpredictably.
|
|
207
|
+
data = http.get(
|
|
208
|
+
f"{SEARCH_API}?subreddit={sub}&limit={n}&sort=desc",
|
|
209
|
+
headers={"User-Agent": http.BROWSER_USER_AGENT},
|
|
210
|
+
timeout=TIMEOUT,
|
|
211
|
+
retries=1,
|
|
212
|
+
)
|
|
213
|
+
except Exception as e: # network error / non-200 — degrade, never raise
|
|
214
|
+
_log(f"listing search failed r/{sub}: {e}")
|
|
215
|
+
continue
|
|
216
|
+
rows = (data or {}).get("data")
|
|
217
|
+
if not isinstance(rows, list):
|
|
218
|
+
_log(f"unexpected listing response for r/{sub}: {str(data)[:80]}")
|
|
219
|
+
continue
|
|
220
|
+
for row in rows:
|
|
221
|
+
if not isinstance(row, dict):
|
|
222
|
+
continue
|
|
223
|
+
post = _normalize_listing_row(row, query)
|
|
224
|
+
if post["url"]:
|
|
225
|
+
out.append(post)
|
|
226
|
+
|
|
227
|
+
seen: set = set()
|
|
228
|
+
unique: List[Dict[str, Any]] = []
|
|
229
|
+
for p in out:
|
|
230
|
+
if p["url"] not in seen:
|
|
231
|
+
seen.add(p["url"])
|
|
232
|
+
unique.append(p)
|
|
233
|
+
return unique
|