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
|
@@ -6,9 +6,11 @@ on whatever was discovered:
|
|
|
6
6
|
|
|
7
7
|
Dedicated lane entity-home subreddits (e.g. r/Kanye) pulled in full via the
|
|
8
8
|
shreddit listing partials (top+hot+new, real scores), kept
|
|
9
|
-
whole
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
whole (floor-exempt) because the sub IS the topic.
|
|
10
|
+
Search lane Reddit's site search fragment (reddit_search): global keyword
|
|
11
|
+
search plus per-sub search for targeted subreddits. Results
|
|
12
|
+
arrive dated and scored. Targeted runs also merge the
|
|
13
|
+
subreddits' listing partials. Relevance-floored.
|
|
12
14
|
Enrichment shreddit comment + count enrichment (reddit_shreddit) for the
|
|
13
15
|
top-ranked posts (author + score + text + permalink).
|
|
14
16
|
|
|
@@ -22,19 +24,17 @@ import sys
|
|
|
22
24
|
from concurrent.futures import ThreadPoolExecutor
|
|
23
25
|
from typing import Any, Dict, List, Optional
|
|
24
26
|
|
|
25
|
-
from
|
|
26
|
-
|
|
27
|
-
from
|
|
28
|
-
#
|
|
29
|
-
# final
|
|
30
|
-
#
|
|
31
|
-
# are shared with the keyed path (reddit.py) via relevance.py.
|
|
27
|
+
from . import http
|
|
28
|
+
from . import reddit_search, reddit_shreddit, reddit_listing, reddit_arctic
|
|
29
|
+
# High-upvote posts from targeted-sub listings can bury on-topic search hits
|
|
30
|
+
# under an engagement-first final sort. A relevance floor + relevance-first
|
|
31
|
+
# final ranking keeps the section on-topic. Thresholds are shared with the
|
|
32
|
+
# keyed path (reddit.py) via relevance.py.
|
|
32
33
|
from .relevance import RELEVANCE_FLOOR, MIN_ON_TOPIC
|
|
33
34
|
|
|
34
35
|
ENRICH_LIMITS = reddit_shreddit.ENRICH_LIMITS
|
|
35
36
|
ENRICH_BUDGET = 45 # seconds total across all enrichment threads
|
|
36
37
|
MAX_ENRICH_WORKERS = 4
|
|
37
|
-
MAX_DERIVED_SUBS = 5 # subreddits derived from RSS results for score backfill
|
|
38
38
|
# Dedicated subreddits (the entity's home, e.g. r/Kanye for "Kanye West") are
|
|
39
39
|
# wholly on-topic, so pull top+hot+new — the top-of-month listing alone misses
|
|
40
40
|
# fresh threads — and keep every item (floor-exempt).
|
|
@@ -50,7 +50,7 @@ def _relevance_rank_key(post: Dict[str, Any]) -> float:
|
|
|
50
50
|
"""
|
|
51
51
|
eng = post.get("engagement", {})
|
|
52
52
|
total = (eng.get("score", 0) or 0) + (eng.get("num_comments", 0) or 0)
|
|
53
|
-
return (post.get("relevance") or 0.0) + min(0.25, math.log10(total + 1) / 20.0)
|
|
53
|
+
return (post.get("relevance") or 0.0) + min(0.25, math.log10(max(0, total) + 1) / 20.0)
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def _log(msg: str) -> None:
|
|
@@ -58,12 +58,6 @@ def _log(msg: str) -> None:
|
|
|
58
58
|
sys.stderr.flush()
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def _top_subreddits(posts: List[Dict[str, Any]], limit: int = MAX_DERIVED_SUBS) -> List[str]:
|
|
62
|
-
"""Most frequent subreddits across discovered posts (for score backfill)."""
|
|
63
|
-
counts = Counter(p.get("subreddit", "") for p in posts if p.get("subreddit"))
|
|
64
|
-
return [sub for sub, _ in counts.most_common(limit)]
|
|
65
|
-
|
|
66
|
-
|
|
67
61
|
def _apply_scores(post: Dict[str, Any], scored: Dict[str, int]) -> None:
|
|
68
62
|
post["score"] = scored["score"]
|
|
69
63
|
post["num_comments"] = scored["num_comments"]
|
|
@@ -71,66 +65,107 @@ def _apply_scores(post: Dict[str, Any], scored: Dict[str, int]) -> None:
|
|
|
71
65
|
post["engagement"]["num_comments"] = scored["num_comments"]
|
|
72
66
|
|
|
73
67
|
|
|
68
|
+
def _scored_listings(
|
|
69
|
+
subreddits: List[str],
|
|
70
|
+
depth: str = "default",
|
|
71
|
+
query: str = "",
|
|
72
|
+
sorts: Optional[List[str]] = None,
|
|
73
|
+
) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Scored subreddit listings: shreddit partials, arctic-shift supplement.
|
|
75
|
+
|
|
76
|
+
The shreddit ``community-more-posts`` partials 403 from datacenter IPs
|
|
77
|
+
(and any host Reddit decides to block). Shreddit is tried first; arctic-
|
|
78
|
+
shift supplements with any posts shreddit missed. Individual sort lanes
|
|
79
|
+
can fail silently (shreddit's ``fetch_listings`` flattens results without
|
|
80
|
+
exposing per-sort status), so arctic is called for all requested subreddits
|
|
81
|
+
and merged via deduplication. This ensures fresh posts sought through
|
|
82
|
+
``hot`` or ``new`` are recovered even when only ``top`` succeeded. Never
|
|
83
|
+
raises.
|
|
84
|
+
"""
|
|
85
|
+
posts = reddit_listing.fetch_listings(subreddits, depth=depth, query=query, sorts=sorts)
|
|
86
|
+
|
|
87
|
+
# Supplement with arctic for all requested subreddits. Shreddit's per-sort
|
|
88
|
+
# success/failure is opaque, so arctic provides coverage for any failed
|
|
89
|
+
# sort lanes (e.g., hot/new failing while top succeeded). Deduplication
|
|
90
|
+
# ensures no redundant posts when shreddit fully succeeded.
|
|
91
|
+
if subreddits:
|
|
92
|
+
try:
|
|
93
|
+
arctic_posts = reddit_arctic.fetch_listings(
|
|
94
|
+
subreddits, depth=depth, query=query, sorts=sorts
|
|
95
|
+
)
|
|
96
|
+
except Exception as exc: # the fallback must never break the pipeline
|
|
97
|
+
_log(f"arctic-shift listing supplement failed: {exc}")
|
|
98
|
+
arctic_posts = []
|
|
99
|
+
if arctic_posts:
|
|
100
|
+
# Merge and dedupe by URL — shreddit posts take priority.
|
|
101
|
+
seen = {p["url"] for p in posts}
|
|
102
|
+
added = 0
|
|
103
|
+
for p in arctic_posts:
|
|
104
|
+
if p["url"] not in seen:
|
|
105
|
+
seen.add(p["url"])
|
|
106
|
+
posts.append(p)
|
|
107
|
+
added += 1
|
|
108
|
+
if added:
|
|
109
|
+
_log(f"arctic-shift supplement: {added} new posts from {len(arctic_posts)} arctic results")
|
|
110
|
+
return posts
|
|
111
|
+
|
|
112
|
+
|
|
74
113
|
def _discover(
|
|
75
114
|
topic: str,
|
|
76
115
|
depth: str,
|
|
77
116
|
subreddits: Optional[List[str]],
|
|
78
117
|
dedicated_subreddits: Optional[List[str]] = None,
|
|
118
|
+
from_date: Optional[str] = None,
|
|
119
|
+
to_date: Optional[str] = None,
|
|
79
120
|
) -> List[Dict[str, Any]]:
|
|
80
121
|
# Dedicated lane: the entity's home subs are wholly on-topic. Pull
|
|
81
122
|
# top+hot+new (real scores from the listing) and mark them floor-exempt so
|
|
82
123
|
# an on-topic post whose title lacks the entity name is never dropped.
|
|
83
124
|
dedicated_posts: List[Dict[str, Any]] = []
|
|
84
125
|
if dedicated_subreddits:
|
|
85
|
-
dedicated_posts =
|
|
126
|
+
dedicated_posts = _scored_listings(
|
|
86
127
|
dedicated_subreddits, depth=depth, query=topic, sorts=DEDICATED_SORTS
|
|
87
128
|
)
|
|
88
129
|
for p in dedicated_posts:
|
|
89
130
|
p["dedicated"] = True
|
|
90
131
|
_log(f"Dedicated lane: {len(dedicated_posts)} posts from {dedicated_subreddits}")
|
|
91
132
|
|
|
92
|
-
# search.json is permanently 403/429 keyless (no Tier 0). Discovery is
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
|
|
133
|
+
# search.json is permanently 403/429 keyless (no Tier 0). Discovery is
|
|
134
|
+
# Reddit's site search: global, plus per-sub for targeted subreddits. The
|
|
135
|
+
# dedicated subs keep their full-listing lane above and are not searched.
|
|
136
|
+
search_posts = reddit_search.search(
|
|
137
|
+
topic, depth=depth, subreddits=subreddits, from_date=from_date, to_date=to_date
|
|
138
|
+
)
|
|
96
139
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
# Bare global run: subreddits derived from noisy RSS results are NOT
|
|
104
|
-
# reliably on-topic, so their listings are used ONLY to backfill scores
|
|
105
|
-
# onto the keyword-matched RSS posts — never merged as discovery, which
|
|
106
|
-
# would flood results with high-upvote but irrelevant posts.
|
|
107
|
-
listing_posts = []
|
|
108
|
-
derived = _top_subreddits(rss_posts)
|
|
109
|
-
score_source = reddit_listing.fetch_listings(derived, depth=depth, query=topic)
|
|
140
|
+
# Targeted run: the caller chose these subreddits, so their listing cards
|
|
141
|
+
# are on-topic; include them as scored discovery. A bare run fetches no
|
|
142
|
+
# listings, so high-upvote off-topic posts cannot flood the results.
|
|
143
|
+
listing_posts = (
|
|
144
|
+
_scored_listings(subreddits, depth=depth, query=topic) if subreddits else []
|
|
145
|
+
)
|
|
110
146
|
_log(
|
|
111
|
-
f"Tier 1 (
|
|
112
|
-
f"{'listing discovery ' + str(len(listing_posts)) if subreddits else '
|
|
113
|
-
f"{len(score_source)} scored cards"
|
|
147
|
+
f"Tier 1 (site search) {len(search_posts)} posts; "
|
|
148
|
+
f"{'listing discovery ' + str(len(listing_posts)) if subreddits else 'no listings'}"
|
|
114
149
|
)
|
|
115
150
|
|
|
116
151
|
# Score lookup by post id, from the scored listing cards.
|
|
117
152
|
score_map: Dict[str, Dict[str, int]] = {}
|
|
118
|
-
for p in
|
|
153
|
+
for p in listing_posts:
|
|
119
154
|
pid = p.get("metadata", {}).get("post_id", "")
|
|
120
155
|
if pid:
|
|
121
156
|
score_map[pid] = {"score": p["score"], "num_comments": p["num_comments"]}
|
|
122
157
|
|
|
123
|
-
# Merge: dedicated-sub posts first (floor-exempt), then scored
|
|
124
|
-
#
|
|
125
|
-
# the post appears
|
|
126
|
-
#
|
|
158
|
+
# Merge: dedicated-sub posts first (floor-exempt), then scored listing posts
|
|
159
|
+
# (targeted only), then search results, taking a listing's live score where
|
|
160
|
+
# the post also appears there. First writer wins the dedupe, so a thread in
|
|
161
|
+
# both the dedicated lane and a listing keeps its floor-exempt status.
|
|
127
162
|
merged: List[Dict[str, Any]] = []
|
|
128
163
|
seen: set = set()
|
|
129
164
|
for p in dedicated_posts + listing_posts:
|
|
130
165
|
if p["url"] not in seen:
|
|
131
166
|
seen.add(p["url"])
|
|
132
167
|
merged.append(p)
|
|
133
|
-
for p in
|
|
168
|
+
for p in search_posts:
|
|
134
169
|
if p["url"] in seen:
|
|
135
170
|
continue
|
|
136
171
|
pid = reddit_listing._post_id(p["url"])
|
|
@@ -139,9 +174,9 @@ def _discover(
|
|
|
139
174
|
seen.add(p["url"])
|
|
140
175
|
merged.append(p)
|
|
141
176
|
|
|
142
|
-
# Backfill
|
|
143
|
-
#
|
|
144
|
-
#
|
|
177
|
+
# Backfill posts still at score 0 from the free arctic-shift archive. Posts
|
|
178
|
+
# already scored by search or a listing keep that live score; arctic only
|
|
179
|
+
# fills the gap, and is best-effort (never raises).
|
|
145
180
|
need = [pid for p in merged
|
|
146
181
|
if not (p.get("engagement", {}).get("score"))
|
|
147
182
|
for pid in [reddit_listing._post_id(p["url"])] if pid]
|
|
@@ -189,10 +224,15 @@ def _enrich(posts: List[Dict[str, Any]], depth: str) -> List[Dict[str, Any]]:
|
|
|
189
224
|
try:
|
|
190
225
|
with ThreadPoolExecutor(max_workers=min(limit, MAX_ENRICH_WORKERS)) as executor:
|
|
191
226
|
futures = {
|
|
192
|
-
|
|
227
|
+
http.submit_with_context(executor, _enrich_one, post): i
|
|
193
228
|
for i, post in enumerate(to_enrich)
|
|
194
229
|
}
|
|
195
|
-
|
|
230
|
+
# The budget covers the fetches; the allowance covers the shared
|
|
231
|
+
# bucket's queue (other lanes, other entities in compare mode).
|
|
232
|
+
done, not_done = concurrent.futures.wait(
|
|
233
|
+
futures,
|
|
234
|
+
timeout=ENRICH_BUDGET + http.reddit_keyless_wait_allowance(len(to_enrich)),
|
|
235
|
+
)
|
|
196
236
|
for future in done:
|
|
197
237
|
idx = futures[future]
|
|
198
238
|
try:
|
|
@@ -210,6 +250,21 @@ def _enrich(posts: List[Dict[str, Any]], depth: str) -> List[Dict[str, Any]]:
|
|
|
210
250
|
return enriched + rest
|
|
211
251
|
|
|
212
252
|
|
|
253
|
+
def _by_comments(posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
254
|
+
"""Stable-sort posts by comment count descending for enrichment slots.
|
|
255
|
+
|
|
256
|
+
Ties (equal counts, including unknown counts treated as 0) preserve the
|
|
257
|
+
incoming order, which search_and_enrich's provisional score-first sort
|
|
258
|
+
establishes. Mirrors _relevance_rank_key's `or 0` guard so a present-but-
|
|
259
|
+
None count is treated as 0 rather than raising.
|
|
260
|
+
"""
|
|
261
|
+
def _comment_count(post: Dict[str, Any]) -> int:
|
|
262
|
+
eng = post.get("engagement") or {}
|
|
263
|
+
return eng.get("num_comments") or post.get("num_comments") or 0
|
|
264
|
+
|
|
265
|
+
return sorted(posts, key=_comment_count, reverse=True)
|
|
266
|
+
|
|
267
|
+
|
|
213
268
|
def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
214
269
|
"""Order posts for enrichment slots: entity-matching posts first.
|
|
215
270
|
|
|
@@ -222,9 +277,11 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
|
|
|
222
277
|
post text) so slots go to posts likely to survive final ranking — keying
|
|
223
278
|
on the same head token keeps the two paths from diverging. Falls back to
|
|
224
279
|
token-overlap relevance when the topic yields no usable primary entity.
|
|
225
|
-
Within each tier
|
|
226
|
-
|
|
227
|
-
|
|
280
|
+
Within each tier posts are ordered by comment count descending (stable:
|
|
281
|
+
equal or unknown counts preserve the incoming score-first order), so the
|
|
282
|
+
scarce slots go to the threads with the most discussion rather than to
|
|
283
|
+
near-empty threads that merely ranked higher by score. Never raises; on
|
|
284
|
+
any failure the incoming order is returned unchanged.
|
|
228
285
|
"""
|
|
229
286
|
try:
|
|
230
287
|
from . import relevance, rerank
|
|
@@ -246,7 +303,7 @@ def _slot_priority(topic: str, posts: List[Dict[str, Any]]) -> List[Dict[str, An
|
|
|
246
303
|
misses: List[Dict[str, Any]] = []
|
|
247
304
|
for post in posts:
|
|
248
305
|
(matches if _matches(post) else misses).append(post)
|
|
249
|
-
return matches + misses
|
|
306
|
+
return _by_comments(matches) + _by_comments(misses)
|
|
250
307
|
except Exception:
|
|
251
308
|
return posts
|
|
252
309
|
|
|
@@ -275,7 +332,9 @@ def search_and_enrich(
|
|
|
275
332
|
with top_comments/comment_insights attached on enriched posts.
|
|
276
333
|
Empty list when all keyless tiers fail (so SC backup can engage).
|
|
277
334
|
"""
|
|
278
|
-
posts = _discover(
|
|
335
|
+
posts = _discover(
|
|
336
|
+
topic, depth, subreddits, dedicated_subreddits, from_date=from_date, to_date=to_date
|
|
337
|
+
)
|
|
279
338
|
if not posts:
|
|
280
339
|
return []
|
|
281
340
|
|
|
@@ -287,7 +346,7 @@ def search_and_enrich(
|
|
|
287
346
|
|
|
288
347
|
# Relevance floor: strip zero-overlap posts (relevance exactly 0 = no
|
|
289
348
|
# title/body token match at all) when anything relevant remains, so
|
|
290
|
-
#
|
|
349
|
+
# high-upvote listing posts can't bury on-topic search
|
|
291
350
|
# hits. Keep all only when nothing scored above zero.
|
|
292
351
|
before = len(posts)
|
|
293
352
|
# Dedicated-sub posts are floor-exempt: their whole subreddit is the topic,
|
|
@@ -303,7 +362,8 @@ def search_and_enrich(
|
|
|
303
362
|
_log(f"Relevance floor dropped {before - len(posts)} off-topic posts")
|
|
304
363
|
|
|
305
364
|
# Provisional score-first order so enrichment-slot selection has a stable
|
|
306
|
-
# within-tier order to preserve
|
|
365
|
+
# within-tier tiebreak order to preserve: within each entity tier, slots go
|
|
366
|
+
# to the most-commented threads first, and equal counts keep score order.
|
|
307
367
|
posts.sort(
|
|
308
368
|
key=lambda p: (
|
|
309
369
|
p.get("engagement", {}).get("score", 0) or 0,
|
|
@@ -313,9 +373,10 @@ def search_and_enrich(
|
|
|
313
373
|
reverse=True,
|
|
314
374
|
)
|
|
315
375
|
|
|
316
|
-
# Enrichment slot selection is
|
|
317
|
-
# claim the scarce comment slots first
|
|
318
|
-
# each tier
|
|
376
|
+
# Enrichment slot selection is comment-aware within entity tiers:
|
|
377
|
+
# entity-matching posts claim the scarce comment slots first, and within
|
|
378
|
+
# each tier the most-commented threads get slots first (score order is the
|
|
379
|
+
# stable tiebreak for equal counts).
|
|
319
380
|
posts = _enrich(_slot_priority(topic, posts), depth)
|
|
320
381
|
|
|
321
382
|
# Final display order ranks relevance-first with a bounded engagement bonus,
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
The subreddit listing partial
|
|
4
4
|
``/svc/shreddit/community-more-posts/{sort}/?name={sub}[&t={range}]`` serves
|
|
5
5
|
HTTP 200 with no API key and **server-renders each post's upvote score**, which
|
|
6
|
-
|
|
6
|
+
the comments endpoint does not provide. Each post is a
|
|
7
7
|
``<shreddit-post>`` element whose start-tag attributes carry ``score``,
|
|
8
8
|
``comment-count``, ``post-title``, ``permalink``, ``author``, ``subreddit-name``
|
|
9
9
|
and ``created-timestamp``.
|
|
10
10
|
|
|
11
11
|
This is the keyless source of post-level upvotes. It works for normal users on
|
|
12
12
|
ordinary connections (verified), so reddit_keyless uses it both as a scored
|
|
13
|
-
discovery source and to backfill scores onto
|
|
13
|
+
discovery source and to backfill scores onto posts discovered elsewhere.
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
16
|
import html as _html
|
|
@@ -18,10 +18,16 @@ import re
|
|
|
18
18
|
import sys
|
|
19
19
|
from datetime import datetime, timezone
|
|
20
20
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
21
|
-
from typing import Any, Dict, List, Optional
|
|
21
|
+
from typing import Any, Dict, List, Optional, Set
|
|
22
22
|
|
|
23
23
|
from . import http
|
|
24
|
-
from .relevance import token_overlap_relevance
|
|
24
|
+
from .relevance import token_overlap_relevance, tokenize
|
|
25
|
+
|
|
26
|
+
# Generic domain terms that are excluded from the keyword gate — matches
|
|
27
|
+
# pipeline._DISCOVERY_GENERIC_DOMAIN_TERMS (duplicated to avoid circular import).
|
|
28
|
+
_DISCOVERY_GENERIC_DOMAIN_TERMS: Set[str] = {
|
|
29
|
+
"ai", "artificial", "intelligence", "tech", "technology", "trending", "trend",
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
# Listing sorts pulled per subreddit, by depth.
|
|
27
33
|
LISTING_SORTS = {
|
|
@@ -35,6 +41,19 @@ MAX_WORKERS = 4
|
|
|
35
41
|
LISTING_TIMEOUT = 15
|
|
36
42
|
|
|
37
43
|
_POST_CARD = re.compile(r"<shreddit-post(?=[\s>])[^>]*>")
|
|
44
|
+
_BLOCK_MARKERS = (
|
|
45
|
+
"verify you are human",
|
|
46
|
+
"confirm you are a human",
|
|
47
|
+
"just a moment",
|
|
48
|
+
"attention required",
|
|
49
|
+
"cf-browser-verification",
|
|
50
|
+
"challenge-platform",
|
|
51
|
+
"/svc/shreddit/js-challenge",
|
|
52
|
+
"please wait for verification",
|
|
53
|
+
"enable javascript and reload",
|
|
54
|
+
"you've been blocked",
|
|
55
|
+
"whoa there",
|
|
56
|
+
)
|
|
38
57
|
|
|
39
58
|
|
|
40
59
|
def _log(msg: str) -> None:
|
|
@@ -42,6 +61,26 @@ def _log(msg: str) -> None:
|
|
|
42
61
|
sys.stderr.flush()
|
|
43
62
|
|
|
44
63
|
|
|
64
|
+
def _matches_discovery_domain(domain: str, text: str) -> bool:
|
|
65
|
+
"""Require a distinctive domain term, not a generic token such as ``AI``.
|
|
66
|
+
|
|
67
|
+
Duplicated from pipeline._matches_discovery_domain to avoid circular imports.
|
|
68
|
+
The rule must stay in sync: pipeline.py owns the authoritative version and
|
|
69
|
+
test_reddit_listing.py verifies parity.
|
|
70
|
+
"""
|
|
71
|
+
def terms(value: str) -> Set[str]:
|
|
72
|
+
words: Set[str] = set()
|
|
73
|
+
for word in tokenize(value):
|
|
74
|
+
words.add(word)
|
|
75
|
+
if len(word) > 4 and word.endswith("s") and not word.endswith("ss"):
|
|
76
|
+
words.add(word[:-1])
|
|
77
|
+
return words
|
|
78
|
+
|
|
79
|
+
domain_terms = terms(domain)
|
|
80
|
+
anchors = domain_terms - _DISCOVERY_GENERIC_DOMAIN_TERMS
|
|
81
|
+
return bool((anchors or domain_terms) & terms(text))
|
|
82
|
+
|
|
83
|
+
|
|
45
84
|
def _attr(tag: str, name: str) -> Optional[str]:
|
|
46
85
|
m = re.search(rf'\b{name}="([^"]*)"', tag)
|
|
47
86
|
return _html.unescape(m.group(1)) if m else None
|
|
@@ -73,6 +112,21 @@ def _post_id(permalink: str) -> str:
|
|
|
73
112
|
return m.group(1) if m else ""
|
|
74
113
|
|
|
75
114
|
|
|
115
|
+
_ERROR_PATTERN = re.compile(r"^r/(\S+)\s+(\S+):", re.IGNORECASE)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _shreddit_error_recovered(error: str, successes: Set[tuple[str, str]]) -> bool:
|
|
119
|
+
"""Return True if the error's (sub, sort) pair is in the successes set.
|
|
120
|
+
|
|
121
|
+
Error format: "r/{sub} {sort}: {message}".
|
|
122
|
+
"""
|
|
123
|
+
m = _ERROR_PATTERN.match(error)
|
|
124
|
+
if not m:
|
|
125
|
+
return False
|
|
126
|
+
sub, sort = m.group(1).lower(), m.group(2).lower()
|
|
127
|
+
return (sub, sort) in successes
|
|
128
|
+
|
|
129
|
+
|
|
76
130
|
def parse_cards(html_text: str, query: str = "") -> List[Dict[str, Any]]:
|
|
77
131
|
"""Parse <shreddit-post> cards into normalized post dicts with real scores."""
|
|
78
132
|
posts: List[Dict[str, Any]] = []
|
|
@@ -118,22 +172,69 @@ def parse_cards(html_text: str, query: str = "") -> List[Dict[str, Any]]:
|
|
|
118
172
|
return posts
|
|
119
173
|
|
|
120
174
|
|
|
121
|
-
def _listing_url(subreddit: str, sort: str) -> str:
|
|
175
|
+
def _listing_url(subreddit: str, sort: str, timeframe: str = TIMEFRAME) -> str:
|
|
122
176
|
sub = subreddit.removeprefix("r/").strip()
|
|
177
|
+
if sub.lower() == "all":
|
|
178
|
+
url = f"https://www.reddit.com/r/all/{sort}/"
|
|
179
|
+
if sort == "top":
|
|
180
|
+
url += f"?t={timeframe}"
|
|
181
|
+
return url
|
|
123
182
|
url = f"https://www.reddit.com/svc/shreddit/community-more-posts/{sort}/?name={sub}"
|
|
124
183
|
if sort == "top":
|
|
125
|
-
url += f"&t={
|
|
184
|
+
url += f"&t={timeframe}"
|
|
126
185
|
return url
|
|
127
186
|
|
|
128
187
|
|
|
129
|
-
def
|
|
188
|
+
def _listing_body_problem(subreddit: str, body: str) -> Optional[str]:
|
|
189
|
+
lower = body.lower()
|
|
190
|
+
is_blocked = any(marker in lower for marker in _BLOCK_MARKERS)
|
|
191
|
+
is_all = subreddit.removeprefix("r/").strip().lower() == "all"
|
|
192
|
+
if not is_all and not is_blocked:
|
|
193
|
+
return None
|
|
194
|
+
if parse_cards(body):
|
|
195
|
+
return None
|
|
196
|
+
if is_blocked:
|
|
197
|
+
return "Reddit listing interstitial: HTTP 2xx with no post cards"
|
|
198
|
+
return "Reddit r/all listing schema drift: HTTP 2xx with no post cards"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _fetch_one(
|
|
202
|
+
subreddit: str,
|
|
203
|
+
sort: str,
|
|
204
|
+
query: str,
|
|
205
|
+
timeframe: str = TIMEFRAME,
|
|
206
|
+
) -> List[Dict[str, Any]]:
|
|
207
|
+
items, _ = _fetch_one_with_status(subreddit, sort, query, timeframe)
|
|
208
|
+
return items
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fetch_one_with_status(
|
|
212
|
+
subreddit: str,
|
|
213
|
+
sort: str,
|
|
214
|
+
query: str,
|
|
215
|
+
timeframe: str = TIMEFRAME,
|
|
216
|
+
) -> tuple[List[Dict[str, Any]], Optional[str]]:
|
|
130
217
|
try:
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
218
|
+
# retry_429 records a terminal miss into the pipeline sink (issue #899)
|
|
219
|
+
# and retries a 429 once through the limiter (issue #985). Empty
|
|
220
|
+
# dedicated-subreddit fragments can be valid; r/all cannot be empty.
|
|
221
|
+
text, error = http.reddit_keyless_get_text_retry_429(
|
|
222
|
+
_listing_url(subreddit, sort, timeframe),
|
|
223
|
+
timeout=LISTING_TIMEOUT,
|
|
224
|
+
accept="text/html",
|
|
225
|
+
validate=lambda body: _listing_body_problem(subreddit, body),
|
|
226
|
+
)
|
|
227
|
+
if text is None:
|
|
228
|
+
return [], (error or "no response")
|
|
229
|
+
return parse_cards(text, query), None
|
|
134
230
|
except Exception as e:
|
|
135
231
|
_log(f"listing fetch failed r/{subreddit} {sort}: {e}")
|
|
136
|
-
return []
|
|
232
|
+
return [], str(e)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _result_timeout(batch_size: int) -> float:
|
|
236
|
+
"""Per-future wait: the fetch's own timeout plus the bucket's queue depth."""
|
|
237
|
+
return LISTING_TIMEOUT + 5 + http.reddit_keyless_wait_allowance(batch_size)
|
|
137
238
|
|
|
138
239
|
|
|
139
240
|
def fetch_listings(
|
|
@@ -141,6 +242,7 @@ def fetch_listings(
|
|
|
141
242
|
depth: str = "default",
|
|
142
243
|
query: str = "",
|
|
143
244
|
sorts: Optional[List[str]] = None,
|
|
245
|
+
timeframe: str = TIMEFRAME,
|
|
144
246
|
) -> List[Dict[str, Any]]:
|
|
145
247
|
"""Fetch scored post cards across subreddits × sorts.
|
|
146
248
|
|
|
@@ -157,11 +259,13 @@ def fetch_listings(
|
|
|
157
259
|
jobs = [(sub, sort) for sub in subreddits for sort in sorts]
|
|
158
260
|
all_posts: List[Dict[str, Any]] = []
|
|
159
261
|
with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(jobs)) or 1) as executor:
|
|
160
|
-
|
|
262
|
+
# submit_with_context, not executor.submit — see the note in
|
|
263
|
+
# fetch_discovery_listings below (issue #899).
|
|
264
|
+
futures = {http.submit_with_context(executor, _fetch_one, sub, sort, query, timeframe): (sub, sort)
|
|
161
265
|
for sub, sort in jobs}
|
|
162
266
|
for future in futures:
|
|
163
267
|
try:
|
|
164
|
-
all_posts.extend(future.result(timeout=
|
|
268
|
+
all_posts.extend(future.result(timeout=_result_timeout(len(jobs))))
|
|
165
269
|
except (Exception, FuturesTimeoutError) as e:
|
|
166
270
|
_log(f"listing future failed: {e}")
|
|
167
271
|
|
|
@@ -174,10 +278,107 @@ def fetch_listings(
|
|
|
174
278
|
return unique
|
|
175
279
|
|
|
176
280
|
|
|
281
|
+
def fetch_discovery_listings(
|
|
282
|
+
subreddits: List[str],
|
|
283
|
+
*,
|
|
284
|
+
query: str,
|
|
285
|
+
depth: str = "default",
|
|
286
|
+
) -> Dict[str, Any]:
|
|
287
|
+
"""Fetch rising/top-week listings while preserving per-feed failures.
|
|
288
|
+
|
|
289
|
+
When shreddit fails and arctic-shift recovers, errors are cleared only for
|
|
290
|
+
subreddits whose posts survive the keyword gate. If query is empty (global
|
|
291
|
+
``--discover`` with no domain), the gate is skipped and any arctic result
|
|
292
|
+
counts as recovery.
|
|
293
|
+
"""
|
|
294
|
+
if not subreddits:
|
|
295
|
+
return {"items": [], "errors": []}
|
|
296
|
+
jobs = [(subreddit, sort) for subreddit in subreddits for sort in ("rising", "top")]
|
|
297
|
+
items: List[Dict[str, Any]] = []
|
|
298
|
+
errors: List[str] = []
|
|
299
|
+
# Track which (sub, sort) pairs shreddit successfully delivered posts for.
|
|
300
|
+
# Used to decide which errors to clear — Arctic can supplement but cannot
|
|
301
|
+
# "recover" a failed hot/top/new/rising lane (it's recency-only).
|
|
302
|
+
shreddit_successes: Set[tuple[str, str]] = set()
|
|
303
|
+
with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(jobs)) or 1) as executor:
|
|
304
|
+
# submit_with_context, not executor.submit: a plain submit starts the
|
|
305
|
+
# worker with an empty context, dropping the pipeline's
|
|
306
|
+
# capture_failures() sink so a listing's 429/403 is silently discarded
|
|
307
|
+
# and the source reports a clean no-results (issue #899).
|
|
308
|
+
futures = {
|
|
309
|
+
http.submit_with_context(
|
|
310
|
+
executor, _fetch_one_with_status, subreddit, sort, query, "week"
|
|
311
|
+
): (subreddit, sort)
|
|
312
|
+
for subreddit, sort in jobs
|
|
313
|
+
}
|
|
314
|
+
for future, (subreddit, sort) in futures.items():
|
|
315
|
+
try:
|
|
316
|
+
fetched, error = future.result(timeout=_result_timeout(len(jobs)))
|
|
317
|
+
except (Exception, FuturesTimeoutError) as exc:
|
|
318
|
+
errors.append(f"r/{subreddit} {sort}: {exc}")
|
|
319
|
+
continue
|
|
320
|
+
items.extend(fetched)
|
|
321
|
+
if error:
|
|
322
|
+
errors.append(f"r/{subreddit} {sort}: {error}")
|
|
323
|
+
elif fetched:
|
|
324
|
+
# Shreddit succeeded for this (sub, sort) lane.
|
|
325
|
+
shreddit_successes.add((subreddit.lower(), sort.lower()))
|
|
326
|
+
|
|
327
|
+
seen: set[str] = set()
|
|
328
|
+
unique = []
|
|
329
|
+
for item in items:
|
|
330
|
+
if item["url"] in seen:
|
|
331
|
+
continue
|
|
332
|
+
seen.add(item["url"])
|
|
333
|
+
unique.append(item)
|
|
334
|
+
|
|
335
|
+
# Supplement with arctic-shift for all requested subreddits. Shreddit's
|
|
336
|
+
# per-sort success/failure is opaque (individual rising/top lanes can fail
|
|
337
|
+
# while others succeed), so arctic provides coverage for any failed lanes.
|
|
338
|
+
# Deduplication ensures no redundant posts when shreddit fully succeeded.
|
|
339
|
+
from . import reddit_arctic
|
|
340
|
+
arctic_items = reddit_arctic.fetch_listings(
|
|
341
|
+
subreddits, depth=depth, query=query, sorts=("rising", "top")
|
|
342
|
+
)
|
|
343
|
+
if arctic_items:
|
|
344
|
+
_log(f"discovery arctic supplement: {len(arctic_items)} posts")
|
|
345
|
+
# Apply the same keyword gate that pipeline._fetch_discovery_source
|
|
346
|
+
# uses downstream. When query is empty (global --discover), skip the
|
|
347
|
+
# gate — there's no keyword to match, and the river feed IS the signal.
|
|
348
|
+
if query:
|
|
349
|
+
arctic_items = [
|
|
350
|
+
item for item in arctic_items
|
|
351
|
+
if _matches_discovery_domain(
|
|
352
|
+
query,
|
|
353
|
+
f"{item.get('title') or ''} {item.get('selftext') or ''}",
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
# Merge arctic items into unique list, deduping by URL.
|
|
357
|
+
added = 0
|
|
358
|
+
for item in arctic_items:
|
|
359
|
+
if item["url"] not in seen:
|
|
360
|
+
seen.add(item["url"])
|
|
361
|
+
unique.append(item)
|
|
362
|
+
added += 1
|
|
363
|
+
if added:
|
|
364
|
+
_log(f"discovery arctic supplement added {added} new posts")
|
|
365
|
+
|
|
366
|
+
# Clear errors only for (sub, sort) pairs where shreddit succeeded.
|
|
367
|
+
# Arctic supplements recency posts but cannot "recover" a failed hot/top/
|
|
368
|
+
# rising lane — it has no sort lanes. Errors for failed shreddit lanes are
|
|
369
|
+
# preserved even when another sort for the same subreddit succeeded.
|
|
370
|
+
if errors and shreddit_successes:
|
|
371
|
+
errors = [
|
|
372
|
+
e for e in errors
|
|
373
|
+
if not _shreddit_error_recovered(e, shreddit_successes)
|
|
374
|
+
]
|
|
375
|
+
return {"items": unique, "errors": errors}
|
|
376
|
+
|
|
377
|
+
|
|
177
378
|
def score_index(subreddits: List[str], depth: str = "default") -> Dict[str, Dict[str, int]]:
|
|
178
379
|
"""Build a {post_id: {score, num_comments}} map from subreddit listings.
|
|
179
380
|
|
|
180
|
-
Used to backfill real scores onto posts discovered
|
|
381
|
+
Used to backfill real scores onto posts discovered by a lane that carries
|
|
181
382
|
no engagement numbers.
|
|
182
383
|
"""
|
|
183
384
|
index: Dict[str, Dict[str, int]] = {}
|