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
@@ -4,8 +4,8 @@ Reddit's public ``.json`` endpoints now return HTTP 403 from most contexts
4
4
  (shreddit anti-bot), so this is no longer the primary free path. The keyless
5
5
  pipeline (see reddit_keyless.py) still calls ``search`` as a cheap one-shot
6
6
  Tier 0 attempt — a residential machine may occasionally get a 200 — before
7
- falling through to RSS discovery (reddit_rss.py) and shreddit comment
8
- enrichment (reddit_shreddit.py).
7
+ falling through to site search discovery (reddit_search.py) and shreddit
8
+ comment enrichment (reddit_shreddit.py).
9
9
 
10
10
  ``search_reddit_public`` is retained as a compatibility shim that delegates to
11
11
  the keyless pipeline, so existing callers (pipeline.py) need no change.
@@ -27,6 +27,8 @@ import urllib.parse
27
27
  import urllib.request
28
28
  from typing import Any, Dict, List, Optional
29
29
 
30
+ from lib import http
31
+
30
32
 
31
33
  USER_AGENT = (
32
34
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
@@ -86,15 +88,12 @@ def _fetch_json(url: str, timeout: int = 15) -> Optional[Dict[str, Any]]:
86
88
 
87
89
  except urllib.error.HTTPError as e:
88
90
  if e.code == 429:
89
- delay = BASE_BACKOFF * (2 ** attempt)
90
- retry_after = None
91
- if hasattr(e, "headers"):
92
- retry_after = e.headers.get("Retry-After")
93
- if retry_after:
94
- try:
95
- delay = float(retry_after)
96
- except ValueError:
97
- pass
91
+ # Reddit answers an anonymous 429 with x-ratelimit-reset and no
92
+ # Retry-After; honour either. See http.retry_delay_from_headers.
93
+ delay = http.retry_delay_from_headers(
94
+ getattr(e, "headers", None),
95
+ BASE_BACKOFF * (2 ** attempt),
96
+ )
98
97
  _log(f"429 rate limited, retry {attempt + 1}/{MAX_RETRIES} after {delay:.1f}s")
99
98
  if attempt < MAX_RETRIES - 1:
100
99
  time.sleep(delay)
@@ -248,8 +247,8 @@ def search_reddit_public(
248
247
 
249
248
  Thin compatibility shim over the keyless pipeline: the legacy ``.json``
250
249
  search/enrichment endpoints now return HTTP 403, so this delegates to
251
- ``reddit_keyless.search_and_enrich`` (dedicated-sub listings + RSS discovery
252
- → shreddit comment enrichment; no ``.json`` search). The name and signature
250
+ ``reddit_keyless.search_and_enrich`` (dedicated-sub listings + site search
251
+ discovery → shreddit comment enrichment; no ``.json`` search). The name and signature
253
252
  are preserved so ``pipeline.py`` and other callers need no change and the
254
253
  ScrapeCreators backup still engages when this returns empty.
255
254
 
@@ -0,0 +1,304 @@
1
+ """Keyless Reddit discovery via Reddit's own site search fragment.
2
+
3
+ ``/svc/shreddit/search/?q=...&type=posts`` is the server-rendered HTML the
4
+ reddit.com search page loads. It serves HTTP 200 with no API key, and
5
+ ``/svc/shreddit/r/{sub}/search/`` restricts the same search to one subreddit.
6
+ Each post result unit carries:
7
+
8
+ - a ``<search-telemetry-tracker>`` whose JSON tracking context has
9
+ ``action_info.type == "post"``, the post id, title, and subreddit,
10
+ - the post-title link (the canonical permalink),
11
+ - a ``<faceplate-timeago ts=...>`` post date,
12
+ - a counter row of ``<faceplate-number>`` votes and comments.
13
+
14
+ So every discovered post arrives dated and scored, with no extra listing fetch.
15
+ The next page is a lazy ``<faceplate-partial>`` whose ``src`` carries a
16
+ ``cursor``. Output dicts match ``reddit_listing.parse_cards`` so downstream
17
+ code is unaffected.
18
+ """
19
+
20
+ import html as _html
21
+ import json
22
+ import re
23
+ import sys
24
+ from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
25
+ from typing import Any, Dict, List, Optional, Tuple
26
+ from urllib.parse import parse_qs, quote, urlencode, urlsplit
27
+
28
+ from . import http, reddit_listing
29
+ from .relevance import token_overlap_relevance
30
+
31
+ BASE = "https://www.reddit.com"
32
+
33
+ # Post caps per run by depth.
34
+ DEPTH_LIMITS = {"quick": 10, "default": 25, "deep": 50}
35
+ # Global search pages per depth (about 7 posts per page).
36
+ GLOBAL_PAGE_CAPS = {"quick": 2, "default": 4, "deep": 8}
37
+ # Targeted subreddits get one page each.
38
+ SUB_PAGE_CAP = 1
39
+ MAX_WORKERS = 4
40
+ SEARCH_TIMEOUT = 15
41
+
42
+ # A recognizable page has post result units or Reddit's explicit no-results
43
+ # unit. Anything else (challenge page, changed markup) is a lane failure.
44
+ RESULTS_MARKER = 'view-events="search/view/post"'
45
+ NO_RESULTS_MARKER = 'view-events="search/view/no_results"'
46
+
47
+ _TRACKER = re.compile(
48
+ r'<search-telemetry-tracker\b[^>]*?\bdata-faceplate-tracking-context="([^"]*)"'
49
+ )
50
+ _TITLE_HREF = re.compile(
51
+ r'<a\b[^>]*?data-testid="post-title"[^>]*?\bhref="([^"]*)"'
52
+ r'|<a\b[^>]*?\bhref="([^"]*)"[^>]*?data-testid="post-title"'
53
+ )
54
+ _TIMEAGO = re.compile(r'<faceplate-timeago\b[^>]*?\bts="([^"]+)"')
55
+ _COUNTER = re.compile(
56
+ r'<faceplate-number\b[^>]*?\bnumber="(\d+)"[^>]*>\s*</faceplate-number>\s*(vote|comment)'
57
+ )
58
+ _NEXT_PAGE = re.compile(
59
+ r'<faceplate-partial\b[^>]*?\bsrc="([^"]*/svc/shreddit/[^"]*search/[^"]*)"'
60
+ )
61
+
62
+
63
+ def _log(msg: str) -> None:
64
+ sys.stderr.write(f"[RedditSearch] {msg}\n")
65
+ sys.stderr.flush()
66
+
67
+
68
+ def unrecognized_body(text: str) -> Optional[str]:
69
+ """Validator for the fetch hook: None for a search page, else a reason."""
70
+ if NO_RESULTS_MARKER in text:
71
+ return None
72
+ if RESULTS_MARKER in text:
73
+ # Marker kept but tracking context changed shape: a clean zero-result
74
+ # parse would hide the drift, so treat it as a lane failure.
75
+ if _post_units(text):
76
+ return None
77
+ return "Reddit search result units no longer parse"
78
+ return "not a Reddit search results page"
79
+
80
+
81
+ def search_url(
82
+ query: str,
83
+ time_filter: str = "month",
84
+ subreddit: Optional[str] = None,
85
+ cursor: Optional[str] = None,
86
+ ) -> str:
87
+ """Build a deterministic search URL so concurrent streams share the memo."""
88
+ if subreddit:
89
+ sub = subreddit.strip().removeprefix("r/").strip("/")
90
+ path = f"/svc/shreddit/r/{quote(sub, safe='')}/search/"
91
+ else:
92
+ path = "/svc/shreddit/search/"
93
+ params = [("q", query), ("type", "posts"), ("t", time_filter), ("sort", "relevance")]
94
+ if cursor:
95
+ params.append(("cursor", cursor))
96
+ return f"{BASE}{path}?{urlencode(params)}"
97
+
98
+
99
+ def _post_units(html_text: str) -> List[Tuple[int, Dict[str, Any]]]:
100
+ """(offset, tracking context) for each post-title tracker, in page order."""
101
+ units = []
102
+ for m in _TRACKER.finditer(html_text):
103
+ try:
104
+ ctx = json.loads(_html.unescape(m.group(1)))
105
+ except (ValueError, TypeError):
106
+ continue
107
+ if not isinstance(ctx, dict):
108
+ continue
109
+ action = ctx.get("action_info")
110
+ post = ctx.get("post")
111
+ # A field that is no longer an object is drift: skip the unit, and a
112
+ # page left with none is flagged by unrecognized_body.
113
+ if not isinstance(action, dict) or not isinstance(post, dict):
114
+ continue
115
+ if action.get("type") == "post" and post.get("id"):
116
+ units.append((m.start(), ctx))
117
+ return units
118
+
119
+
120
+ def _next_cursor(html_text: str) -> Optional[str]:
121
+ for m in _NEXT_PAGE.finditer(html_text):
122
+ query = urlsplit(_html.unescape(m.group(1))).query
123
+ cursor = (parse_qs(query).get("cursor") or [None])[0]
124
+ if cursor:
125
+ return cursor
126
+ return None
127
+
128
+
129
+ def parse_page(html_text: str, query: str = "") -> Tuple[List[Dict[str, Any]], Optional[str]]:
130
+ """Parse one search fragment into (normalized posts, next-page cursor)."""
131
+ html_text = html_text or ""
132
+ units = _post_units(html_text)
133
+ posts: List[Dict[str, Any]] = []
134
+ seen: set = set()
135
+ for i, (start, ctx) in enumerate(units):
136
+ post_id = str(ctx["post"]["id"]).removeprefix("t3_")
137
+ if post_id in seen:
138
+ continue
139
+ seen.add(post_id)
140
+ end = units[i + 1][0] if i + 1 < len(units) else len(html_text)
141
+ chunk = html_text[start:end]
142
+
143
+ sub_ctx = ctx.get("subreddit")
144
+ subreddit = str(sub_ctx.get("name") or "") if isinstance(sub_ctx, dict) else ""
145
+ title = str(ctx["post"].get("title") or "")
146
+ href_match = _TITLE_HREF.search(chunk)
147
+ href = _html.unescape((href_match.group(1) or href_match.group(2))) if href_match else ""
148
+ href = href.split("?", 1)[0]
149
+ if f"/comments/{post_id}/" in href and href.startswith("/r/"):
150
+ url = f"{BASE}{href}"
151
+ else:
152
+ url = f"{BASE}/r/{subreddit}/comments/{post_id}/"
153
+
154
+ ts_match = _TIMEAGO.search(chunk)
155
+ ts = ts_match.group(1) if ts_match else None
156
+ # First counter of each kind wins: this post's own counters precede any
157
+ # that belong to a skipped (malformed) neighbour sharing the chunk.
158
+ counts: Dict[str, int] = {}
159
+ for number, label in _COUNTER.findall(chunk):
160
+ counts.setdefault(label, int(number))
161
+ score, num_comments = counts.get("vote", 0), counts.get("comment", 0)
162
+
163
+ posts.append({
164
+ "id": "",
165
+ "title": title,
166
+ "url": url,
167
+ "score": score,
168
+ "num_comments": num_comments,
169
+ "subreddit": subreddit,
170
+ "created_utc": reddit_listing._to_epoch(ts),
171
+ "author": "",
172
+ "selftext": "",
173
+ "date": reddit_listing._to_date(ts),
174
+ "engagement": {
175
+ "score": score,
176
+ "num_comments": num_comments,
177
+ "upvote_ratio": None,
178
+ },
179
+ "relevance": round(token_overlap_relevance(query, title), 3) if query else 0.0,
180
+ "why_relevant": "Reddit search",
181
+ "metadata": {"post_id": post_id},
182
+ })
183
+ return posts, _next_cursor(html_text)
184
+
185
+
186
+ def _fetch_page(url: str) -> Tuple[Optional[str], Optional[str]]:
187
+ """(body, error). Shared limiter, run memo, one 429 retry, failure sink."""
188
+ try:
189
+ return http.reddit_keyless_get_text_retry_429(
190
+ url,
191
+ timeout=SEARCH_TIMEOUT,
192
+ accept="text/html",
193
+ validate=unrecognized_body,
194
+ )
195
+ except Exception as e: # defensive: one bad page must not sink the run
196
+ _log(f"search fetch failed for {url}: {e}")
197
+ return None, str(e)
198
+
199
+
200
+ def _search_stream(
201
+ query: str,
202
+ time_filter: str,
203
+ max_pages: int,
204
+ subreddit: Optional[str] = None,
205
+ ) -> List[Dict[str, Any]]:
206
+ """Page one search sequentially. Keeps earlier pages when a later one fails."""
207
+ label = f"r/{subreddit}" if subreddit else "global"
208
+ posts: List[Dict[str, Any]] = []
209
+ seen_ids: set = set()
210
+ used_cursors: set = set()
211
+ cursor: Optional[str] = None
212
+ for page in range(1, max_pages + 1):
213
+ text, error = _fetch_page(search_url(query, time_filter, subreddit, cursor))
214
+ if text is None:
215
+ _log(f"{label} page {page} failed: {error or 'no response'}")
216
+ break
217
+ page_posts, next_cursor = parse_page(text, query)
218
+ new = [p for p in page_posts if p["metadata"]["post_id"] not in seen_ids]
219
+ for p in new:
220
+ seen_ids.add(p["metadata"]["post_id"])
221
+ posts.extend(new)
222
+ if not new or not next_cursor or next_cursor in used_cursors:
223
+ break
224
+ used_cursors.add(next_cursor)
225
+ cursor = next_cursor
226
+ return posts
227
+
228
+
229
+ def _result_timeout(batch_size: int, pages: int = 1) -> float:
230
+ """Per-future wait: each sequential page's own timeout plus the bucket's queue."""
231
+ return pages * (SEARCH_TIMEOUT + 5) + http.reddit_keyless_wait_allowance(batch_size)
232
+
233
+
234
+ def _time_filter(from_date: Optional[str], to_date: Optional[str]) -> str:
235
+ # Same mapping as the ScrapeCreators path; falls back to "month".
236
+ from .reddit import _window_to_time_filter
237
+ return _window_to_time_filter(from_date or "", to_date or "")
238
+
239
+
240
+ def search(
241
+ query: str,
242
+ depth: str = "default",
243
+ subreddits: Optional[List[str]] = None,
244
+ from_date: Optional[str] = None,
245
+ to_date: Optional[str] = None,
246
+ ) -> List[Dict[str, Any]]:
247
+ """Discover Reddit posts for a query via the site search fragment.
248
+
249
+ Runs a paged global search plus one page of per-subreddit search for each
250
+ targeted subreddit. Returns normalized, dated, scored posts, deduped by URL
251
+ and capped by depth. Streams are interleaved round-robin before the cap so
252
+ neither the targeted subreddits nor the global search crowds out the other. Returns ``[]`` on total failure and never raises; failures
253
+ land in the pipeline's failure sink.
254
+ """
255
+ try:
256
+ limit = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
257
+ global_pages = GLOBAL_PAGE_CAPS.get(depth, GLOBAL_PAGE_CAPS["default"])
258
+ time_filter = _time_filter(from_date, to_date)
259
+ subs = []
260
+ for raw in subreddits or []:
261
+ sub = (raw or "").strip().removeprefix("r/").strip("/")
262
+ if sub and sub.lower() not in {s.lower() for s in subs}:
263
+ subs.append(sub)
264
+
265
+ jobs = [(sub, SUB_PAGE_CAP) for sub in subs] + [(None, global_pages)]
266
+ batch = sum(pages for _sub, pages in jobs)
267
+ streams: List[List[Dict[str, Any]]] = []
268
+ with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(jobs))) as executor:
269
+ # submit_with_context, not executor.submit: a plain submit starts the
270
+ # worker with an empty context, dropping the pipeline's
271
+ # capture_failures() sink so a 429/403 or a challenge page is
272
+ # silently discarded (issue #899).
273
+ futures = [
274
+ (http.submit_with_context(executor, _search_stream, query, time_filter, pages, sub), sub, pages)
275
+ for sub, pages in jobs
276
+ ]
277
+ for future, sub, pages in futures:
278
+ try:
279
+ streams.append(future.result(timeout=_result_timeout(batch, pages)))
280
+ except (Exception, FuturesTimeoutError) as e:
281
+ _log(f"{'r/' + sub if sub else 'global'} search future failed: {e}")
282
+
283
+ # Round-robin across streams, each in its own relevance order, so the
284
+ # depth cap keeps a share of every stream.
285
+ results = [
286
+ stream[i]
287
+ for i in range(max((len(s) for s in streams), default=0))
288
+ for stream in streams
289
+ if i < len(stream)
290
+ ]
291
+ seen: set = set()
292
+ unique: List[Dict[str, Any]] = []
293
+ for post in results:
294
+ if post["url"] not in seen:
295
+ seen.add(post["url"])
296
+ unique.append(post)
297
+ unique = unique[:limit]
298
+ for i, post in enumerate(unique):
299
+ post["id"] = f"R{i + 1}"
300
+ _log(f"{len(unique)} posts (t={time_filter}, {len(subs)} targeted subs)")
301
+ return unique
302
+ except Exception as e: # defensive: discovery must never raise into the pipeline
303
+ _log(f"search failed: {e}")
304
+ return []
@@ -23,18 +23,47 @@ from typing import Any, Dict, List, Optional
23
23
  from . import http
24
24
  from . import reddit_enrich
25
25
 
26
- # Up to N posts enriched per run, by depth (mirrors reddit_public.ENRICH_LIMITS).
26
+ # Up to N posts enriched per subquery, by depth. Raised from 3/5/8 once the
27
+ # per-command memo (http.reddit_keyless_get_text) collapsed repeat shreddit
28
+ # fetches across subqueries and the 1 req/s bucket stopped the 429s: eight
29
+ # fetches fit inside ENRICH_BUDGET at four workers, and the comments are the
30
+ # lane's headline value.
27
31
  ENRICH_LIMITS = {
28
- "quick": 3,
29
- "default": 5,
30
- "deep": 8,
32
+ "quick": 4,
33
+ "default": 8,
34
+ "deep": 12,
31
35
  }
32
36
 
33
37
  # Max comments returned per post (independent of how many posts get enriched).
34
- MAX_COMMENTS = 10
38
+ # Twelve so a thousand-comment thread feeds more than ten candidates into the
39
+ # cross-platform Top Community Comments block.
40
+ MAX_COMMENTS = 12
35
41
 
36
42
  SVC_TIMEOUT = 12
37
43
 
44
+ # Known bots whose comments carry no community signal.
45
+ BOT_AUTHORS = frozenset({
46
+ "automoderator",
47
+ "remindmebot",
48
+ "repostsleuthbot",
49
+ "sneakpeekbot",
50
+ "savevideo",
51
+ "videodownloadbot",
52
+ "totesmessenger",
53
+ "b0trank",
54
+ "amputatorbot",
55
+ "stabbot",
56
+ "gifreversingbot",
57
+ "haikubotinaction",
58
+ "imagesofnetwork",
59
+ "botdefense",
60
+ })
61
+
62
+ _BOT_SUFFIXES = ("-bot", "_bot")
63
+
64
+ # CamelCase catches WikiTextBot without swallowing names such as Talbot.
65
+ _CAMEL_BOT = re.compile(r"[a-z0-9]Bot\d*$")
66
+
38
67
  # Match the exact <shreddit-comment> element start tag, not <shreddit-comment-tree>
39
68
  # or <shreddit-comment-tree-stats> (lookahead requires whitespace or '>').
40
69
  _COMMENT_START = re.compile(r"<shreddit-comment(?=[\s>])[^>]*>")
@@ -106,13 +135,28 @@ def _body_for(html_text: str, thing_id: str) -> str:
106
135
  return _WS.sub(" ", _html.unescape(text)).strip()
107
136
 
108
137
 
138
+ def _is_bot_author(author: str) -> bool:
139
+ """Whether an author is a bot whose comments carry no community signal."""
140
+ raw = (author or "").strip()
141
+ if not raw:
142
+ return False
143
+ name = raw.lower()
144
+ return (name in BOT_AUTHORS
145
+ or name.endswith(_BOT_SUFFIXES)
146
+ or bool(_CAMEL_BOT.search(raw)))
147
+
148
+
109
149
  def parse_comments(html_text: str, limit: int = MAX_COMMENTS) -> List[Dict[str, Any]]:
110
- """Parse <shreddit-comment> elements into scored comment dicts (sorted desc)."""
150
+ """Parse <shreddit-comment> elements into scored comment dicts (sorted desc).
151
+
152
+ Deleted, removed, and bot authors are dropped: they occupy top-comment slots
153
+ on high-traffic threads without saying anything about the topic.
154
+ """
111
155
  comments: List[Dict[str, Any]] = []
112
156
  for m in _COMMENT_START.finditer(html_text or ""):
113
157
  tag = m.group(0)
114
158
  author = _attr(tag, "author") or "[deleted]"
115
- if author in ("[deleted]", "[removed]"):
159
+ if author in ("[deleted]", "[removed]") or _is_bot_author(author):
116
160
  continue
117
161
  thing_id = _attr(tag, "thingId")
118
162
  body = _body_for(html_text, thing_id)
@@ -0,0 +1,134 @@
1
+ """Named audience registers for standard research brief synthesis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from types import MappingProxyType
7
+ from typing import Mapping
8
+
9
+
10
+ SectionName = str
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AudienceRegister:
15
+ """A bounded renderer/synthesis preset for one intended audience."""
16
+
17
+ name: str
18
+ section_order: tuple[SectionName, ...]
19
+ item_budgets: Mapping[SectionName, int]
20
+ emphasis_weights: Mapping[str, float]
21
+
22
+ def budget_for(self, section: SectionName, fallback: int) -> int:
23
+ return self.item_budgets.get(section, fallback)
24
+
25
+ def emphasis_for(self, source: str) -> float:
26
+ return self.emphasis_weights.get(source, 1.0)
27
+
28
+
29
+ _DEFAULT_ORDER = (
30
+ "hiring_signals",
31
+ "clusters",
32
+ "stats",
33
+ "best_takes",
34
+ "top_comments",
35
+ "source_outcomes",
36
+ "source_coverage",
37
+ )
38
+
39
+
40
+ def _preset(
41
+ name: str,
42
+ *,
43
+ section_order: tuple[SectionName, ...] = _DEFAULT_ORDER,
44
+ item_budgets: Mapping[SectionName, int] | None = None,
45
+ emphasis_weights: Mapping[str, float] | None = None,
46
+ ) -> AudienceRegister:
47
+ return AudienceRegister(
48
+ name=name,
49
+ section_order=section_order,
50
+ item_budgets=MappingProxyType(dict(item_budgets or {})),
51
+ emphasis_weights=MappingProxyType(dict(emphasis_weights or {})),
52
+ )
53
+
54
+
55
+ _REGISTERS = {
56
+ "default": _preset("default"),
57
+ "exec": _preset(
58
+ "exec",
59
+ section_order=(
60
+ "stats",
61
+ "clusters",
62
+ "hiring_signals",
63
+ "source_outcomes",
64
+ "source_coverage",
65
+ "best_takes",
66
+ "top_comments",
67
+ ),
68
+ item_budgets={"clusters": 5, "best_takes": 2, "top_comments": 3},
69
+ emphasis_weights={
70
+ "polymarket": 1.50,
71
+ "jobs": 1.30,
72
+ "github": 1.20,
73
+ "grounding": 1.10,
74
+ },
75
+ ),
76
+ "dev": _preset(
77
+ "dev",
78
+ section_order=(
79
+ "clusters",
80
+ "source_outcomes",
81
+ "source_coverage",
82
+ "hiring_signals",
83
+ "stats",
84
+ "top_comments",
85
+ "best_takes",
86
+ ),
87
+ item_budgets={"clusters": 10, "best_takes": 3, "top_comments": 4},
88
+ emphasis_weights={
89
+ "github": 1.60,
90
+ "hackernews": 1.35,
91
+ "arxiv": 1.30,
92
+ "devto": 1.25,
93
+ "grounding": 1.10,
94
+ },
95
+ ),
96
+ "creator": _preset(
97
+ "creator",
98
+ section_order=(
99
+ "best_takes",
100
+ "top_comments",
101
+ "stats",
102
+ "clusters",
103
+ "hiring_signals",
104
+ "source_outcomes",
105
+ "source_coverage",
106
+ ),
107
+ item_budgets={"clusters": 6, "best_takes": 5, "top_comments": 8},
108
+ emphasis_weights={
109
+ "tiktok": 1.60,
110
+ "instagram": 1.50,
111
+ "youtube": 1.40,
112
+ "x": 1.20,
113
+ "reddit": 1.10,
114
+ },
115
+ ),
116
+ # ELI5 historically changed only the agent's prose. Keep the renderer
117
+ # descriptor identical to default and express its voice in SKILL.md.
118
+ "eli5": _preset("eli5"),
119
+ }
120
+
121
+ REGISTER_NAMES = tuple(_REGISTERS)
122
+
123
+
124
+ def get_register(name: str | None = None) -> AudienceRegister:
125
+ """Return a named register, rejecting unsupported/free-form templates."""
126
+
127
+ normalized = (name or "default").strip().lower()
128
+ try:
129
+ return _REGISTERS[normalized]
130
+ except KeyError as exc:
131
+ choices = ", ".join(REGISTER_NAMES)
132
+ raise ValueError(
133
+ f"unknown audience register {name!r}; choose one of: {choices}"
134
+ ) from exc
@@ -59,6 +59,19 @@ SYNONYMS = {
59
59
 
60
60
  # Generic query words that should not carry relevance on their own.
61
61
  # They still help when paired with stronger entity/topic matches.
62
+ #
63
+ # The second group is scaffolding emitted by planner's ranking-query templates
64
+ # ("What recent evidence from the last 30 days is most relevant to X?" and its
65
+ # siblings). Those words are not the topic, but every one of them was being
66
+ # counted as an informative query token, which capped achievable coverage at the
67
+ # topic's share of the query and demoted on-topic posts. Kept here rather than
68
+ # stripped in the planner so any caller building a similar natural-language
69
+ # ranking query gets the same treatment.
70
+ #
71
+ # Domain nouns from those same templates (production, market, workflows,
72
+ # experience, signals, ...) are deliberately absent: they can legitimately be a
73
+ # user's topic, and demoting them globally would hurt every source.
74
+ # tests/test_ranking_query_scaffolding.py pins that split.
62
75
  LOW_SIGNAL_QUERY_TOKENS = frozenset({
63
76
  'advice', 'animation', 'animations', 'best', 'chance', 'chances',
64
77
  'code', 'compare', 'comparison', 'differences', 'explain', 'guide',
@@ -67,6 +80,10 @@ LOW_SIGNAL_QUERY_TOKENS = frozenset({
67
80
  'prompting', 'prompts', 'rate', 'review', 'reviews', 'thoughts',
68
81
  'tip', 'tips', 'tutorial', 'tutorials', 'update', 'updates', 'use',
69
82
  'using', 'versus', 'vs', 'worth',
83
+ # planner ranking-query scaffolding
84
+ '30', 'current', 'days', 'describing', 'especially', 'evidence', 'exist',
85
+ 'follow', 'hands', 'last', 'matter', 'most', 'new', 'people', 'real',
86
+ 'recent', 'relevant', 'running', 'up', 'world',
70
87
  })
71
88
 
72
89