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
@@ -271,7 +271,15 @@ def _user_reels(
271
271
 
272
272
  raw_items = data.get("items") or data.get("reels") or data.get("data") or []
273
273
  _log(f" -> {len(raw_items)} reels from @{handle}")
274
- return raw_items
274
+
275
+ # User-reels responses wrap each reel in a ``media`` envelope, while
276
+ # other Instagram endpoints already return flat reel dictionaries.
277
+ return [
278
+ item["media"]
279
+ if isinstance(item, dict) and isinstance(item.get("media"), dict)
280
+ else item
281
+ for item in raw_items
282
+ ]
275
283
 
276
284
 
277
285
  def search_instagram(
@@ -415,13 +423,17 @@ def fetch_captions(
415
423
  if not url:
416
424
  continue
417
425
  try:
418
- data = http.get(
419
- f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
420
- params={"url": url},
421
- headers=http.scrapecreators_headers(token),
422
- timeout=transcript_timeout,
423
- retries=1,
424
- )
426
+ # Isolate transcript fetch errors from the pipeline-level
427
+ # capture_failures() context so an individual reel's 400 doesn't
428
+ # poison the entire source outcome (#829).
429
+ with http.capture_failures() as _tf:
430
+ data = http.get(
431
+ f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
432
+ params={"url": url},
433
+ headers=http.scrapecreators_headers(token),
434
+ timeout=transcript_timeout,
435
+ retries=1,
436
+ )
425
437
  transcripts = data.get("transcripts") or []
426
438
  if transcripts and isinstance(transcripts, list):
427
439
  transcript_text = " ".join(
@@ -494,7 +506,7 @@ def search_and_enrich(
494
506
  items.append(item)
495
507
 
496
508
  # Sort merged results by views descending
497
- items.sort(key=lambda x: x.get("engagement", {}).get("views", 0), reverse=True)
509
+ items.sort(key=lambda x: x.get("engagement", {}).get("views") or 0, reverse=True)
498
510
 
499
511
  if not items:
500
512
  return {"items": [], "error": last_error}
@@ -519,3 +531,114 @@ def parse_instagram_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
519
531
  List of item dicts ready for normalization.
520
532
  """
521
533
  return response.get("items", [])
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # Comments (ScrapeCreators, opt-in via INCLUDE_SOURCES=instagram_comments)
538
+ # ---------------------------------------------------------------------------
539
+
540
+
541
+ def _ig_total_engagement(item: Dict[str, Any]) -> int:
542
+ """Sum an Instagram item's engagement for picking which posts to enrich."""
543
+ eng = item.get("engagement", {}) or {}
544
+ return (eng.get("views") or 0) + (eng.get("likes") or 0) + (eng.get("comments") or 0)
545
+
546
+
547
+ def enrich_with_comments(
548
+ items: List[Dict[str, Any]],
549
+ token: str,
550
+ max_posts: int = 3,
551
+ max_comments: int = 5,
552
+ ) -> List[Dict[str, Any]]:
553
+ """Enrich top Instagram posts with comment data from ScrapeCreators.
554
+
555
+ Mirrors ``tiktok.enrich_with_comments`` / ``youtube_yt.enrich_with_comments``:
556
+ for the top N posts by engagement, fetch comments and attach them as a
557
+ ``top_comments`` field (highest-liked first). Failures never crash the run.
558
+ """
559
+ if not items or not token or max_posts <= 0:
560
+ return items
561
+
562
+ ranked = sorted(items, key=_ig_total_engagement, reverse=True)
563
+ top_items = ranked[:max_posts]
564
+ _log(f"Enriching comments for {len(top_items)} Instagram posts")
565
+
566
+ from concurrent.futures import ThreadPoolExecutor, as_completed
567
+
568
+ def _enrich_one(item: dict) -> bool:
569
+ post_url = item.get("url", "")
570
+ if not post_url:
571
+ return False
572
+ try:
573
+ comments = _fetch_post_comments(post_url, token, max_comments)
574
+ if comments:
575
+ item["top_comments"] = comments
576
+ return True
577
+ except Exception as exc:
578
+ _log(f"Comment enrichment failed for {post_url}: {exc}")
579
+ return False
580
+
581
+ enriched_count = 0
582
+ with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
583
+ futures = {http.submit_with_context(executor, _enrich_one, item): item for item in top_items}
584
+ for future in as_completed(futures):
585
+ if future.result():
586
+ enriched_count += 1
587
+
588
+ _log(f"Enriched {enriched_count}/{len(top_items)} posts with comments")
589
+ return items
590
+
591
+
592
+ def _fetch_post_comments(
593
+ post_url: str,
594
+ token: str,
595
+ max_comments: int = 5,
596
+ ) -> List[Dict[str, Any]]:
597
+ """Fetch comments for a single Instagram post/reel via ScrapeCreators.
598
+
599
+ SC endpoint: GET /v2/instagram/post/comments?url=<post_or_reel_url>
600
+ Response shape: { comments: [{text, comment_like_count, child_comment_count,
601
+ created_at, user{username, ...}}], cursor }
602
+
603
+ Returns:
604
+ List of comment dicts with author, text, comment_like_count (likes), date,
605
+ highest-liked first. Empty list on any error — never crashes the pipeline.
606
+ """
607
+ try:
608
+ data = http.get(
609
+ f"{SCRAPECREATORS_BASE}/v2/instagram/post/comments",
610
+ params={"url": post_url},
611
+ headers=http.scrapecreators_headers(token),
612
+ timeout=30,
613
+ retries=2,
614
+ )
615
+ except Exception as exc:
616
+ _log(f"Comment fetch error for {post_url}: {exc}")
617
+ return []
618
+
619
+ raw_comments = data.get("comments") or data.get("data") or []
620
+ # Sort by like count desc so normalize sees the highest-signal first.
621
+ raw_comments = sorted(
622
+ raw_comments,
623
+ key=lambda c: c.get("comment_like_count", 0) or 0,
624
+ reverse=True,
625
+ )
626
+ out: List[Dict[str, Any]] = []
627
+ for c in raw_comments[:max_comments]:
628
+ if not isinstance(c, dict):
629
+ continue
630
+ text = c.get("text") or ""
631
+ if not text:
632
+ continue
633
+ user = c.get("user") if isinstance(c.get("user"), dict) else {}
634
+ author = user.get("username") or ""
635
+ created_at = c.get("created_at") or ""
636
+ # created_at is ISO 8601 (e.g. "2026-07-04T14:27:58.000Z"); take the date.
637
+ date_str = created_at[:10] if isinstance(created_at, str) and len(created_at) >= 10 else ""
638
+ out.append({
639
+ "author": author,
640
+ "text": text[:400],
641
+ "comment_like_count": c.get("comment_like_count", 0) or 0,
642
+ "date": date_str,
643
+ })
644
+ return out
@@ -141,7 +141,8 @@ def _resolve_careers_page(
141
141
  candidates.extend([f"https://{host}/careers", f"https://{host}/jobs"])
142
142
 
143
143
  for url in candidates:
144
- html = http.get_text(url, accept="text/html", retries=1)
144
+ with http.expected_misses(403, 404):
145
+ html = http.get_text(url, accept="text/html", retries=1)
145
146
  if html and _looks_like_careers_html(html):
146
147
  return html, url
147
148
 
@@ -209,7 +210,8 @@ def _probe_ats(company: str) -> tuple[str | None, str | None, list[str]]:
209
210
  for provider in (ATS_PROVIDER_GREENHOUSE, ATS_PROVIDER_ASHBY, ATS_PROVIDER_LEVER):
210
211
  attempts.append(f"probe:{provider}:{slug}")
211
212
  try:
212
- items = _fetch_ats(provider, slug)
213
+ with http.expected_misses(400, 401, 403, 404):
214
+ items = _fetch_ats(provider, slug)
213
215
  except http.HTTPError as exc:
214
216
  if exc.status_code in {400, 401, 403, 404}:
215
217
  continue
@@ -0,0 +1,308 @@
1
+ """Scan saved last30days research artifacts into a deterministic library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ import uuid
9
+ from dataclasses import dataclass
10
+ from datetime import date, datetime, timezone
11
+ from pathlib import Path
12
+
13
+
14
+ DEFAULT_MEMORY_DIR = Path.home() / "Documents" / "Last30Days"
15
+ DEFAULT_BRIEFS_DIR = Path.home() / ".local" / "share" / "last30days" / "briefs"
16
+ LIBRARY_ID_FILENAME = ".last30days-library-id"
17
+
18
+ _REPORT_TITLE = re.compile(r"^#\s+last30days(?:\s+v[^:]+)?:\s*(.+?)\s*$", re.MULTILINE | re.IGNORECASE)
19
+ _FIRST_TITLE = re.compile(r"^#\s+(.+?)\s*$", re.MULTILINE)
20
+ _DATE_RANGE = re.compile(
21
+ r"^-\s*Date range:\s*\d{4}-\d{2}-\d{2}\s+to\s+(\d{4}-\d{2}-\d{2})\s*$",
22
+ re.MULTILINE | re.IGNORECASE,
23
+ )
24
+ _DATED_FILENAME = re.compile(r"-(\d{4}-\d{2}-\d{2})(?:-\d+)?$")
25
+ _RANKED_HEADLINE = re.compile(r"^###\s+1[.)]\s+(.+?)\s*$", re.MULTILINE)
26
+ _SCORE_SUFFIX = re.compile(r"\s+\(score\s+[^)]*\)\s*$", re.IGNORECASE)
27
+ _MARKDOWN_LINK = re.compile(r"\[([^]]+)]\([^)]+\)")
28
+ _LIBRARY_ID = re.compile(r"[0-9a-f]{32}")
29
+ _GENERATED_BRIEF_NAME = re.compile(
30
+ r"[a-z0-9]+(?:-[a-z0-9]+)*-[0-9a-f]{8}-\d{4}-\d{2}-\d{2}\.html"
31
+ )
32
+ _PRIVATE_CORPUS_BLOCK = re.compile(
33
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
34
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
35
+ re.DOTALL,
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class LibraryEntry:
41
+ """Metadata and source content for one saved research artifact."""
42
+
43
+ slug: str
44
+ topic: str
45
+ published_date: date
46
+ headline: str
47
+ summary: str
48
+ source_path: Path
49
+ content: str
50
+ source_updated_at: datetime
51
+ source_format: str = "markdown"
52
+
53
+ @property
54
+ def entry_id(self) -> str:
55
+ return f"urn:last30days:{self.slug}:{self.identity_hash}:{self.published_date.isoformat()}"
56
+
57
+ @property
58
+ def output_name(self) -> str:
59
+ return f"{self.slug}-{self.identity_hash}-{self.published_date.isoformat()}.html"
60
+
61
+ @property
62
+ def identity_hash(self) -> str:
63
+ # Include the source filename stem so per-suffix runs of the same
64
+ # topic on the same date (--save-suffix per-client workflow) stay
65
+ # distinct entries instead of collapsing to one.
66
+ seed = f"{self.topic}\n{self.source_path.stem}"
67
+ return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:8]
68
+
69
+
70
+ def slugify(value: str) -> str:
71
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
72
+ return slug or "last30days"
73
+
74
+
75
+ def get_or_create_library_id(memory_dir: Path | str) -> str:
76
+ """Return the persisted random namespace for one research library."""
77
+ memory_path = Path(memory_dir).expanduser()
78
+ memory_path.mkdir(parents=True, exist_ok=True)
79
+ id_path = memory_path / LIBRARY_ID_FILENAME
80
+ try:
81
+ library_id = id_path.read_text(encoding="utf-8").strip()
82
+ except FileNotFoundError:
83
+ library_id = uuid.uuid4().hex
84
+ try:
85
+ with id_path.open("x", encoding="utf-8") as handle:
86
+ handle.write(f"{library_id}\n")
87
+ except FileExistsError:
88
+ library_id = id_path.read_text(encoding="utf-8").strip()
89
+ if not _LIBRARY_ID.fullmatch(library_id):
90
+ raise ValueError(f"invalid library ID in {id_path}")
91
+ return library_id
92
+
93
+
94
+ def is_generated_brief_name(name: str) -> bool:
95
+ """Return whether a filename has the exact library-renderer output shape."""
96
+ return _GENERATED_BRIEF_NAME.fullmatch(name) is not None
97
+
98
+
99
+ def scan_library(
100
+ memory_dir: Path | str = DEFAULT_MEMORY_DIR,
101
+ briefs_dir: Path | str = DEFAULT_BRIEFS_DIR,
102
+ ) -> tuple[list[LibraryEntry], list[str]]:
103
+ """Return valid saved entries and notes for files that could not be read.
104
+
105
+ Hand-edited and foreign files are tolerated: a generic Markdown heading is
106
+ enough to include a file, while unreadable or unrecognizable files are
107
+ skipped with a note instead of aborting the entire feed generation.
108
+ """
109
+ entries: dict[str, LibraryEntry] = {}
110
+ notes: list[str] = []
111
+ memory_path = Path(memory_dir).expanduser()
112
+ briefs_path = Path(briefs_dir).expanduser()
113
+
114
+ if memory_path.is_dir():
115
+ for path in sorted(memory_path.glob("*.md")):
116
+ try:
117
+ entry = _parse_markdown(path)
118
+ _keep_preferred(entries, entry)
119
+ except (OSError, UnicodeError, ValueError) as exc:
120
+ notes.append(f"Skipped {path}: {exc}")
121
+ continue
122
+
123
+ if briefs_path.is_dir():
124
+ for path in sorted(briefs_path.glob("*.json")):
125
+ try:
126
+ entry = _parse_briefing(path)
127
+ _keep_preferred(entries, entry)
128
+ except (OSError, UnicodeError, ValueError, json.JSONDecodeError) as exc:
129
+ notes.append(f"Skipped {path}: {exc}")
130
+ continue
131
+
132
+ ordered = sorted(
133
+ entries.values(),
134
+ key=lambda entry: (entry.published_date, entry.topic.casefold(), entry.source_path.name),
135
+ reverse=True,
136
+ )
137
+ return ordered, notes
138
+
139
+
140
+ def _keep_preferred(entries: dict[str, LibraryEntry], entry: LibraryEntry) -> None:
141
+ existing = entries.get(entry.entry_id)
142
+ if existing is None or entry.source_updated_at > existing.source_updated_at:
143
+ entries[entry.entry_id] = entry
144
+
145
+
146
+ def _parse_markdown(path: Path) -> LibraryEntry:
147
+ content = path.read_text(encoding="utf-8")
148
+ public_content = _PRIVATE_CORPUS_BLOCK.sub("", content)
149
+ title_match = _REPORT_TITLE.search(public_content) or _FIRST_TITLE.search(public_content)
150
+ if not title_match:
151
+ raise ValueError("no Markdown title found")
152
+ topic = _clean_inline(title_match.group(1))
153
+ if not topic:
154
+ raise ValueError("empty Markdown title")
155
+ published_date = _markdown_date(public_content, path)
156
+ headline = _markdown_headline(public_content) or topic
157
+ summary = _markdown_summary(public_content) or headline
158
+ return LibraryEntry(
159
+ slug=slugify(topic),
160
+ topic=topic,
161
+ published_date=published_date,
162
+ headline=headline,
163
+ summary=summary,
164
+ source_path=path,
165
+ content=content,
166
+ source_updated_at=_source_updated_at(path),
167
+ )
168
+
169
+
170
+ def _markdown_date(content: str, path: Path) -> date:
171
+ if match := _DATE_RANGE.search(content):
172
+ return date.fromisoformat(match.group(1))
173
+ if match := _DATED_FILENAME.search(path.stem):
174
+ return date.fromisoformat(match.group(1))
175
+ return datetime.fromtimestamp(path.stat().st_mtime).date()
176
+
177
+
178
+ def _markdown_headline(content: str) -> str:
179
+ if match := _RANKED_HEADLINE.search(content):
180
+ return _clean_inline(_SCORE_SUFFIX.sub("", match.group(1)))
181
+ return ""
182
+
183
+
184
+ def _markdown_summary(content: str) -> str:
185
+ learned = re.search(
186
+ r"^##\s+What I learned\s*$\n+(.+?)(?=\n#{1,3}\s|\n---|\Z)",
187
+ content,
188
+ re.MULTILINE | re.DOTALL | re.IGNORECASE,
189
+ )
190
+ if learned:
191
+ for paragraph in re.split(r"\n\s*\n", learned.group(1)):
192
+ cleaned = _clean_inline(paragraph)
193
+ if cleaned:
194
+ return cleaned[:500]
195
+ evidence = re.search(r"^\s*-\s*Evidence:\s*(.+?)\s*$", content, re.MULTILINE | re.IGNORECASE)
196
+ if evidence:
197
+ return _clean_inline(evidence.group(1))[:500]
198
+ return ""
199
+
200
+
201
+ def _parse_briefing(path: Path) -> LibraryEntry:
202
+ data = json.loads(path.read_text(encoding="utf-8"))
203
+ if not isinstance(data, dict):
204
+ raise ValueError("briefing JSON is not an object")
205
+ is_weekly = data.get("type") == "weekly" or path.stem.endswith("-weekly")
206
+ raw_date = path.stem[:10] if is_weekly else data.get("date") or path.stem[:10]
207
+ try:
208
+ published_date = date.fromisoformat(str(raw_date))
209
+ except ValueError as exc:
210
+ raise ValueError("briefing has no valid date") from exc
211
+ topic = "Weekly research briefing" if is_weekly else "Daily research briefing"
212
+ top = data.get("top_finding") if isinstance(data.get("top_finding"), dict) else {}
213
+ headline = _briefing_public_headline(data, topic)
214
+ summary = _briefing_summary(data, headline)
215
+ markdown = _briefing_markdown(data, topic, published_date, summary)
216
+ local_title = str(top.get("title") or "")
217
+ if local_title and local_title != headline:
218
+ local_title = local_title.replace("LAST30DAYS_PRIVATE_CORPUS", "LAST30DAYS_PRIVATE-CORPUS")
219
+ markdown += (
220
+ "\n<!-- LAST30DAYS_PRIVATE_CORPUS_START -->\n"
221
+ "## Local briefing highlight\n\n"
222
+ f"{local_title}\n"
223
+ "<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\n"
224
+ )
225
+ return LibraryEntry(
226
+ slug=slugify(topic),
227
+ topic=topic,
228
+ published_date=published_date,
229
+ headline=headline,
230
+ summary=summary,
231
+ source_path=path,
232
+ content=markdown,
233
+ source_updated_at=_source_updated_at(path),
234
+ source_format="json",
235
+ )
236
+
237
+
238
+ def _briefing_public_headline(data: dict[str, object], fallback: str) -> str:
239
+ top = data.get("top_finding")
240
+ candidates = [top] if isinstance(top, dict) else []
241
+ # Legacy daily archives lack source on top_finding but retain it on findings.
242
+ topics = data.get("topics")
243
+ if isinstance(topics, list):
244
+ for topic in topics:
245
+ if not isinstance(topic, dict):
246
+ continue
247
+ for field in ("findings", "top_findings"):
248
+ findings = topic.get(field)
249
+ if isinstance(findings, list):
250
+ candidates.extend(findings)
251
+
252
+ public_titles = []
253
+ for finding in candidates:
254
+ if not isinstance(finding, dict):
255
+ continue
256
+ source = str(finding.get("source") or "").strip().lower()
257
+ url = str(finding.get("source_url") or "").strip().lower()
258
+ if not source or source == "corpus" or url.startswith("corpus:"):
259
+ continue
260
+ title = finding.get("source_title") or finding.get("title")
261
+ if title:
262
+ engagement = finding.get("engagement_score", finding.get("engagement"))
263
+ score = engagement if isinstance(engagement, (int, float)) else 0
264
+ public_titles.append((score, str(title)))
265
+ return max(public_titles, key=lambda item: item[0])[1] if public_titles else fallback
266
+
267
+
268
+ def _source_updated_at(path: Path) -> datetime:
269
+ seconds, nanoseconds = divmod(path.stat().st_mtime_ns, 1_000_000_000)
270
+ return datetime.fromtimestamp(seconds, tz=timezone.utc).replace(
271
+ microsecond=nanoseconds // 1_000
272
+ )
273
+
274
+
275
+ def _briefing_summary(data: dict[str, object], fallback: str) -> str:
276
+ total_new = data.get("total_new")
277
+ total_topics = data.get("total_topics")
278
+ if total_new is not None and total_topics is not None:
279
+ return f"{total_new} new findings across {total_topics} monitored topics. {fallback}"[:500]
280
+ topics = data.get("topics")
281
+ if isinstance(topics, list):
282
+ return f"Updates across {len(topics)} monitored topics. {fallback}"[:500]
283
+ return fallback[:500]
284
+
285
+
286
+ def _briefing_markdown(data: dict[str, object], topic: str, published_date: date, summary: str) -> str:
287
+ lines = [f"# {topic}", "", f"- Date: {published_date.isoformat()}", "", summary]
288
+ if data.get("type") == "weekly" and data.get("week_of"):
289
+ lines[3:3] = [f"- Week of: {data['week_of']}"]
290
+ topics = data.get("topics")
291
+ if isinstance(topics, list):
292
+ lines.extend(["", "## Topics", ""])
293
+ for item in topics:
294
+ if not isinstance(item, dict):
295
+ continue
296
+ name = str(item.get("name") or "Untitled topic")
297
+ count = item.get("new_count", item.get("this_week_count", 0))
298
+ lines.append(f"- **{name}** — {count} new findings")
299
+ return "\n".join(lines).strip() + "\n"
300
+
301
+
302
+ def _clean_inline(value: str) -> str:
303
+ value = _MARKDOWN_LINK.sub(r"\1", value)
304
+ value = re.sub(r"^\s*>\s?", "", value)
305
+ value = re.sub(r"(?<!\w)(\*\*|__)(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", value)
306
+ value = re.sub(r"(?<!\w)([*_])(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", value)
307
+ value = re.sub(r"(?<!\w)`(?=\S)(.+?)(?<=\S)`(?!\w)", r"\1", value)
308
+ return re.sub(r"\s+", " ", value).strip()