luckiest-co 1.0.46 → 1.0.48

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 (266) 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/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,313 @@
1
+ """dev.to (Forem) source for last30days.
2
+
3
+ Uses the public Forem API at dev.to (no key):
4
+
5
+ - ``/api/articles?tag=<tag>&top=<days>`` returns the most-engaged articles for
6
+ a tag in the window. Precise, but only as good as the tag.
7
+ - ``/api/articles/search?q=<term>&top=<days>`` is free-text search sorted by
8
+ recency. Single distinctive terms match well; multi-word queries match
9
+ loosely, so every search hit must pass the engine's token-overlap relevance
10
+ check before it is kept.
11
+ - ``/api/comments?a_id=<id>`` returns the comment tree for an article.
12
+
13
+ The source is off by default. Host-inferred tags (``--devto-tags``) turn it
14
+ on for a run; ``INCLUDE_SOURCES=devto`` or ``--search devto`` turn it on
15
+ without tags, in which case only the keyword search runs. An unknown tag
16
+ returns ``[]`` with HTTP 200, so a wrong tag stays quiet instead of failing.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import datetime
22
+ import html
23
+ import re
24
+ import threading
25
+ import time
26
+ import urllib.parse
27
+ from concurrent.futures import ThreadPoolExecutor, as_completed
28
+ from concurrent.futures import TimeoutError as FuturesTimeout
29
+ from typing import Any
30
+
31
+ from . import http, log
32
+ from .relevance import token_overlap_relevance
33
+
34
+ ARTICLES_URL = "https://dev.to/api/articles"
35
+ SEARCH_URL = "https://dev.to/api/articles/search"
36
+ COMMENTS_URL = "https://dev.to/api/comments"
37
+ _SEARCH_HEADERS = {"Accept": "application/vnd.forem.api-v1+json"}
38
+
39
+ TAG_LIMITS = {"quick": 1, "default": 2, "deep": 3}
40
+ SEARCH_PER_PAGE = {"quick": 10, "default": 20, "deep": 30}
41
+ TAG_PER_PAGE = 30
42
+ ENRICH_LIMIT = 3
43
+ MAX_COMMENTS = 5
44
+ # Search hits below this token-overlap score are dropped. Same bar as the
45
+ # keyless Reddit lane: it keeps "kubernetes" hits and drops the newest-posts
46
+ # noise a loose multi-word query ("claude code") returns.
47
+ SEARCH_RELEVANCE_FLOOR = 0.25
48
+ # Total wall-clock cap per request. The socket timeout alone bounds each
49
+ # read, so a response trickling in small chunks could otherwise run on.
50
+ REQUEST_DEADLINE_SECONDS = 20.0
51
+ ENRICH_WAIT_SECONDS = 25.0
52
+
53
+ _TAG_RE = re.compile(r"[^a-z0-9]")
54
+ # Bot comments on dev.to ("Official Platform Update ... tr.ee/dev-to") hide
55
+ # phishing behind link shorteners; real discussion rarely needs one.
56
+ _SHORTENER_RE = re.compile(
57
+ r"\b(?:bit\.ly|tinyurl\.com|tr\.ee|shorturl\.at|t\.co|rb\.gy|cutt\.ly|is\.gd|ow\.ly|tiny\.cc)/",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def _log(msg: str) -> None:
63
+ log.source_log("dev.to", msg, tty_only=False)
64
+
65
+
66
+ def normalize_tag(value: str) -> str:
67
+ return _TAG_RE.sub("", (value or "").lower().lstrip("#"))
68
+
69
+
70
+ def resolve_tags(tags: list[str] | None, depth: str = "default") -> list[str]:
71
+ """Normalize and dedupe host-supplied tags, capped by depth."""
72
+ resolved: list[str] = []
73
+ for tag in (normalize_tag(t) for t in (tags or [])):
74
+ if tag and tag not in resolved:
75
+ resolved.append(tag)
76
+ return resolved[: TAG_LIMITS.get(depth, TAG_LIMITS["default"])]
77
+
78
+
79
+ def _window_days(from_date: str | None, to_date: str | None) -> int:
80
+ try:
81
+ start = datetime.date.fromisoformat(from_date or "")
82
+ end = datetime.date.fromisoformat(to_date or "") if to_date else datetime.date.today()
83
+ return max(1, (end - start).days)
84
+ except ValueError:
85
+ return 30
86
+
87
+
88
+ # The pipeline calls a source once per subquery, and the tag feed does not
89
+ # depend on the subquery, so identical URLs are fetched once per process.
90
+ _RESPONSE_CACHE: dict[str, list[dict[str, Any]]] = {}
91
+ _URL_LOCKS: dict[str, threading.Lock] = {}
92
+ _LOCKS_GUARD = threading.Lock()
93
+
94
+
95
+ def clear_cache() -> None:
96
+ with _LOCKS_GUARD:
97
+ _RESPONSE_CACHE.clear()
98
+ _URL_LOCKS.clear()
99
+
100
+
101
+ def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str, Any]]:
102
+ # Concurrent subquery streams can request the same URL; the per-URL lock
103
+ # makes the second caller wait for the first fetch instead of repeating it.
104
+ with _LOCKS_GUARD:
105
+ lock = _URL_LOCKS.setdefault(url, threading.Lock())
106
+ with lock:
107
+ if url not in _RESPONSE_CACHE:
108
+ data = http.request(
109
+ "GET", url, headers=headers, timeout=15, retries=2,
110
+ deadline_monotonic=time.monotonic() + REQUEST_DEADLINE_SECONDS,
111
+ owned_get=True,
112
+ )
113
+ if not isinstance(data, list):
114
+ # Forem list endpoints always return a JSON array; anything
115
+ # else is a changed or error response, not "no matches".
116
+ raise ValueError(f"unexpected dev.to response shape: {type(data).__name__}")
117
+ _RESPONSE_CACHE[url] = [row for row in data if isinstance(row, dict)]
118
+ return [dict(row) for row in _RESPONSE_CACHE[url]]
119
+
120
+
121
+ def _in_window(article: dict[str, Any], from_date: str | None, to_date: str | None) -> bool:
122
+ published = str(article.get("published_at") or "")[:10]
123
+ if not published:
124
+ return True
125
+ if from_date and published < from_date:
126
+ return False
127
+ if to_date and published > to_date:
128
+ return False
129
+ return True
130
+
131
+
132
+ def _article_text(article: dict[str, Any]) -> str:
133
+ tags = article.get("tag_list") or []
134
+ if isinstance(tags, str):
135
+ tags = [t.strip() for t in tags.split(",")]
136
+ return " ".join([str(article.get("title") or ""), str(article.get("description") or ""), " ".join(tags)])
137
+
138
+
139
+ def search_devto(
140
+ topic: str,
141
+ from_date: str | None = None,
142
+ to_date: str | None = None,
143
+ *,
144
+ depth: str = "default",
145
+ tags: list[str] | None = None,
146
+ search_query: str | None = None,
147
+ ) -> dict[str, Any]:
148
+ """Fetch tag-feed and keyword-search articles for the topic.
149
+
150
+ Returns ``{"articles": [...], "tags": [...], "errors": [...]}``. A failed
151
+ endpoint is recorded in ``errors`` and the other endpoint still counts.
152
+ """
153
+ days = _window_days(from_date, to_date)
154
+ resolved_tags = resolve_tags(tags, depth)
155
+ articles: dict[int, dict[str, Any]] = {}
156
+ errors: list[str] = []
157
+
158
+ for tag in resolved_tags:
159
+ url = f"{ARTICLES_URL}?{urllib.parse.urlencode({'tag': tag, 'top': days, 'per_page': TAG_PER_PAGE})}"
160
+ try:
161
+ rows = _get_list(url)
162
+ except Exception as exc:
163
+ errors.append(f"tag {tag}: {exc}")
164
+ _log(f"tag feed failed for '{tag}': {exc}")
165
+ continue
166
+ kept = 0
167
+ for row in rows:
168
+ if row.get("id") is not None and _in_window(row, from_date, to_date):
169
+ row.setdefault("_devto_match", f"tag:{tag}")
170
+ articles.setdefault(row["id"], row)
171
+ kept += 1
172
+ _log(f"tag '{tag}': {kept} articles in window")
173
+
174
+ # Planner queries quote phrases for engines with phrase syntax; Forem
175
+ # search treats the quotes as literal characters.
176
+ query = (search_query or topic or "").replace('"', " ").strip()
177
+ query = " ".join(query.split())
178
+ if query:
179
+ params = {"q": query, "top": days, "per_page": SEARCH_PER_PAGE.get(depth, SEARCH_PER_PAGE["default"])}
180
+ url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
181
+ try:
182
+ rows = _get_list(url, headers=_SEARCH_HEADERS)
183
+ except Exception as exc:
184
+ errors.append(f"search: {exc}")
185
+ _log(f"search failed for '{query}': {exc}")
186
+ rows = []
187
+ kept = dropped = 0
188
+ for row in rows:
189
+ if row.get("id") is None or not _in_window(row, from_date, to_date):
190
+ continue
191
+ if token_overlap_relevance(topic or query, _article_text(row)) < SEARCH_RELEVANCE_FLOOR:
192
+ dropped += 1
193
+ continue
194
+ row.setdefault("_devto_match", "search")
195
+ articles.setdefault(row["id"], row)
196
+ kept += 1
197
+ _log(f"search '{query}': kept {kept}, dropped {dropped} off-topic")
198
+
199
+ result: dict[str, Any] = {"articles": list(articles.values()), "tags": resolved_tags, "errors": errors}
200
+ attempted = len(resolved_tags) + (1 if query else 0)
201
+ if attempted and len(errors) == attempted:
202
+ # Every endpoint failed: surface it so the run reports a source
203
+ # failure instead of a quiet empty result.
204
+ result["error"] = "; ".join(errors)
205
+ return result
206
+
207
+
208
+ def parse_devto_response(response: dict[str, Any], query: str = "") -> list[dict[str, Any]]:
209
+ """Map Forem articles to engine item dicts (the HN item shape)."""
210
+ items: list[dict[str, Any]] = []
211
+ for article in response.get("articles") or []:
212
+ reactions = int(article.get("public_reactions_count") or article.get("positive_reactions_count") or 0)
213
+ comments = int(article.get("comments_count") or 0)
214
+ user = article.get("user") or {}
215
+ content_score = token_overlap_relevance(query, _article_text(article)) if query else 0.5
216
+ relevance = min(1.0, 0.7 * content_score + min(0.3, (reactions + comments) / 100))
217
+ tags = article.get("tag_list") or []
218
+ if isinstance(tags, str):
219
+ tags = [t.strip() for t in tags.split(",") if t.strip()]
220
+ items.append({
221
+ "id": str(article.get("id")),
222
+ "title": str(article.get("title") or "").strip(),
223
+ "url": str(article.get("url") or ""),
224
+ "author": str(user.get("username") or user.get("name") or ""),
225
+ "date": str(article.get("published_at") or "")[:10] or None,
226
+ "text": str(article.get("description") or "").strip(),
227
+ "engagement": {"reactions": reactions, "comments": comments},
228
+ "relevance": round(relevance, 2),
229
+ "why_relevant": f"dev.to article ({article.get('_devto_match', 'match')})",
230
+ "metadata": {
231
+ "tags": tags,
232
+ "reading_time_minutes": article.get("reading_time_minutes"),
233
+ "ai_disclosure_level": article.get("ai_disclosure_level"),
234
+ "match": article.get("_devto_match"),
235
+ },
236
+ })
237
+ return items
238
+
239
+
240
+ def _strip_html(text: str) -> str:
241
+ text = re.sub(r"<(br|/p)\s*/?>", "\n", text or "", flags=re.IGNORECASE)
242
+ text = re.sub(r"<[^>]+>", "", text)
243
+ return html.unescape(text).strip()
244
+
245
+
246
+ def _fetch_comments(article_id: str) -> list[dict[str, Any]]:
247
+ url = f"{COMMENTS_URL}?{urllib.parse.urlencode({'a_id': article_id})}"
248
+ try:
249
+ rows = _get_list(url)
250
+ except Exception as exc:
251
+ _log(f"comments failed for article {article_id}: {exc}")
252
+ return []
253
+ comments = []
254
+ for row in rows:
255
+ body_html = str(row.get("body_html") or "")
256
+ text = _strip_html(body_html)
257
+ # Check the raw HTML too: a shortener can hide in an href.
258
+ if not text or _SHORTENER_RE.search(html.unescape(body_html)):
259
+ continue
260
+ user = row.get("user") or {}
261
+ comments.append({
262
+ "author": str(user.get("username") or user.get("name") or ""),
263
+ "text": text[:300] + "..." if len(text) > 300 else text,
264
+ # The comments API exposes no like count; direct replies are the
265
+ # only engagement signal it returns.
266
+ "replies": len(row.get("children") or []),
267
+ "ai_disclosure_level": row.get("ai_disclosure_level"),
268
+ })
269
+ comments.sort(key=lambda c: c["replies"], reverse=True)
270
+ return comments[:MAX_COMMENTS]
271
+
272
+
273
+ def enrich_source_items(items: list[Any], top_k: int = ENRICH_LIMIT) -> list[Any]:
274
+ """Attach top comments to the first ``top_k`` ranked SourceItems with comments.
275
+
276
+ Runs from ``_finalize_items_by_source`` so the comment calls land on the
277
+ articles that survived relevance pruning and dedupe, not on high-engagement
278
+ tag-feed articles the ranking later drops.
279
+ """
280
+ from .normalize import _remap_comments
281
+
282
+ targets = []
283
+ for item in items:
284
+ if len(targets) >= top_k:
285
+ break
286
+ if getattr(item, "source", None) != "devto":
287
+ continue
288
+ if (item.metadata or {}).get("top_comments"):
289
+ continue
290
+ if not (item.engagement or {}).get("comments"):
291
+ continue
292
+ targets.append(item)
293
+ if not targets:
294
+ return items
295
+ executor = ThreadPoolExecutor(max_workers=len(targets))
296
+ futures = {http.submit_with_context(executor, _fetch_comments, item.item_id): item for item in targets}
297
+ try:
298
+ for future in as_completed(futures, timeout=ENRICH_WAIT_SECONDS):
299
+ item = futures[future]
300
+ item.metadata["top_comments"] = _remap_comments(
301
+ future.result(),
302
+ score_keys=("replies",),
303
+ excerpt_keys=("text", "excerpt"),
304
+ preserve_absent_score=True,
305
+ )
306
+ except FuturesTimeout:
307
+ _log("comment enrichment hit its time limit; keeping the comments that arrived")
308
+ finally:
309
+ # Never block report finalization on a straggling comment fetch.
310
+ executor.shutdown(wait=False, cancel_futures=True)
311
+ attached = sum(1 for item in targets if item.metadata.get("top_comments"))
312
+ _log(f"attached comments to {attached} of {len(targets)} top articles")
313
+ return items