luckiest-co 1.0.47 → 1.0.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -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 — floor-exempt — because the sub IS the topic.
10
- RSS lane reddit_rss breadth (incl. global keyword search) + broad-sub
11
- listing partials for real upvote scores. Relevance-floored.
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 collections import Counter
26
-
27
- from . import reddit_rss, reddit_shreddit, reddit_listing, reddit_arctic
28
- # Scores are backfilled from popular derived subreddits, so an engagement-first
29
- # final sort buries on-topic RSS hits under viral off-topic posts. A relevance
30
- # floor + relevance-first final ranking keeps the section on-topic. Thresholds
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 = reddit_listing.fetch_listings(
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 RSS
93
- # breadth (incl. global keyword search) + broad-sub listing partials for
94
- # real upvote scores.
95
- rss_posts = reddit_rss.search_rss(topic, depth=depth, subreddits=subreddits)
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
- if subreddits:
98
- # Targeted run: the caller chose these subreddits, so their listing cards
99
- # are on-topic — include them as scored discovery AND as a score source.
100
- listing_posts = reddit_listing.fetch_listings(subreddits, depth=depth, query=topic)
101
- score_source = listing_posts
102
- else:
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 (RSS) {len(rss_posts)} posts; "
112
- f"{'listing discovery ' + str(len(listing_posts)) if subreddits else 'score-only'}; "
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 score_source:
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 broad listing
124
- # posts (targeted only), then RSS breadth backfilled with real scores where
125
- # the post appears in a listing. First writer wins the dedupe, so a thread
126
- # in both the dedicated lane and a listing keeps its floor-exempt status.
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 rss_posts:
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 scores for RSS-only posts (no listing card scored them) from the
143
- # free arctic-shift archive. Posts already scored by a listing keep that
144
- # live score; arctic only fills the gap, and is best-effort (never raises).
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
- executor.submit(_enrich_one, post): i
227
+ http.submit_with_context(executor, _enrich_one, post): i
193
228
  for i, post in enumerate(to_enrich)
194
229
  }
195
- done, not_done = concurrent.futures.wait(futures, timeout=ENRICH_BUDGET)
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 the incoming
226
- (score-first) order is preserved. Never raises; on any failure the
227
- incoming order is returned unchanged.
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(topic, depth, subreddits, dedicated_subreddits)
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
- # backfilled high-upvote posts from popular subs can't bury on-topic RSS
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 relevance-aware: entity-matching posts
317
- # claim the scarce comment slots first (score order preserved within
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
- neither RSS nor the comments endpoint provides. Each post is a
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 RSS-discovered posts.
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={TIMEFRAME}"
184
+ url += f"&t={timeframe}"
126
185
  return url
127
186
 
128
187
 
129
- def _fetch_one(subreddit: str, sort: str, query: str) -> List[Dict[str, Any]]:
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
- text = http.reddit_keyless_get_text(_listing_url(subreddit, sort), timeout=LISTING_TIMEOUT,
132
- accept="text/html")
133
- return parse_cards(text, query) if text else []
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
- futures = {executor.submit(_fetch_one, sub, sort, query): (sub, sort)
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=LISTING_TIMEOUT + 5))
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 via RSS, which carries
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]] = {}