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
@@ -9,7 +9,10 @@ Requires SCRAPECREATORS_API_KEY environment variable.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ import datetime
13
+ import hashlib
12
14
  import re
15
+ import time
13
16
  from typing import Any, Dict, List
14
17
 
15
18
  from . import http, log
@@ -22,11 +25,139 @@ DEPTH_CONFIG: dict[str, dict[str, Any]] = {
22
25
  "deep": {"date_posted": "last-month", "max_results": 30},
23
26
  }
24
27
 
28
+ # The upstream endpoint only accepts these coarse buckets. Anything else is a
29
+ # hard 400, so an arbitrary day count has to be widened to the smallest bucket
30
+ # that still covers it; the caller's real from_date/to_date window is then
31
+ # enforced downstream by normalize.filter_by_date_range().
32
+ DATE_POSTED_BUCKETS: tuple[tuple[int, str], ...] = (
33
+ (1, "last-day"),
34
+ (7, "last-week"),
35
+ (31, "last-month"),
36
+ (366, "last-year"),
37
+ )
38
+
39
+ # This endpoint returns HTTP 404 with {"message": "No posts found"} for a query
40
+ # that simply matched nothing. That is an empty result, not a failure: treating
41
+ # it as an error marks a whole run partial and hides a legitimate null behind
42
+ # what looks like an outage.
43
+ EMPTY_RESULT_STATUS = 404
44
+
45
+ # Each cursor page returns ~10 posts; this bounds a runaway pagination loop.
46
+ MAX_PAGES = 10
47
+
48
+ # Consecutive all-duplicate pages tolerated before a bucket is abandoned. The
49
+ # covering bucket's first page routinely repeats the narrow bucket's top hits,
50
+ # so breaking on the first zero-add page would skip the older tail the second
51
+ # bucket exists to fetch.
52
+ MAX_EMPTY_ADD_PAGES = 3
53
+
54
+ # Whole-call wall-clock budget. Pagination bounds the request COUNT, not time:
55
+ # 2 buckets x MAX_PAGES pages x (30s timeout + retry) is ~10 minutes against a
56
+ # slow-but-alive endpoint, with no error to attribute the stall to. Each request
57
+ # is additionally clamped to the remaining budget so the bound actually holds.
58
+ SEARCH_BUDGET_SECONDS = 120.0
59
+ PAGE_TIMEOUT = 30.0
60
+ MIN_PAGE_TIMEOUT = 5.0
61
+
62
+ # Endpoint-scoped failures. These are properties of the credential or the
63
+ # account, not of one bucket, so retrying the next bucket only wastes a call
64
+ # and — for 429 — deepens the rate limit already being hit.
65
+ FATAL_STATUS_CODES = frozenset({401, 403, 429})
66
+
25
67
 
26
68
  def _log(msg: str) -> None:
27
69
  log.source_log("LinkedIn", msg, tty_only=False)
28
70
 
29
71
 
72
+ def _coerce_date(raw: Any) -> datetime.date | None:
73
+ """Parse a YYYY-MM-DD date, tolerating a full ISO timestamp."""
74
+ try:
75
+ return datetime.date.fromisoformat(str(raw)[:10])
76
+ except (TypeError, ValueError):
77
+ return None
78
+
79
+
80
+ def _buckets_for_window(
81
+ from_date: str,
82
+ to_date: str,
83
+ fallback: str,
84
+ today: datetime.date | None = None,
85
+ ) -> List[str]:
86
+ """Buckets to query for a window, narrowest first.
87
+
88
+ Two properties of the upstream endpoint drive this:
89
+
90
+ 1. Buckets are relative to NOW, not to the requested window. So the bucket
91
+ has to be sized by how OLD the requested window is (today - from_date),
92
+ never by its span. Sizing by span would send a 31-day window from two
93
+ years ago to 'last-month', which cannot contain a single matching post.
94
+ 2. Results inside a bucket are relevance-ranked, not recency-ranked, so a
95
+ widened bucket does NOT contain the narrower one's yield: 'last-year'
96
+ for a 90-day window returned 30 posts spread across a full year, only 4
97
+ inside the window. So query the narrow bucket for dense recent coverage
98
+ AND the covering bucket for the older tail, then union them.
99
+
100
+ The caller's exact window is still enforced downstream by
101
+ filter_by_date_range(); these buckets only have to COVER it.
102
+ """
103
+ start = _coerce_date(from_date)
104
+ end = _coerce_date(to_date)
105
+ if start is None or end is None:
106
+ _log(f"Unparseable window ({from_date!r}..{to_date!r}), using {fallback}")
107
+ return [fallback]
108
+ if end < start:
109
+ _log(f"Inverted window ({from_date}..{to_date}), using {fallback}")
110
+ return [fallback]
111
+
112
+ today = today or datetime.date.today()
113
+ # Age of the OLDEST requested post, which is what the bucket must reach
114
+ # back to. +1 because the window is inclusive of from_date.
115
+ age_days = (today - start).days + 1
116
+ if age_days < 0:
117
+ _log(f"Window starts in the future ({from_date}), using {fallback}")
118
+ return [fallback]
119
+
120
+ widest_limit, widest_bucket = DATE_POSTED_BUCKETS[-1]
121
+ covering = widest_bucket
122
+ for limit, bucket in DATE_POSTED_BUCKETS:
123
+ if age_days <= limit:
124
+ covering = bucket
125
+ break
126
+ else:
127
+ _log(
128
+ f"Window reaches back {age_days}d but the widest bucket is "
129
+ f"{widest_bucket} ({widest_limit}d) — older results are unreachable"
130
+ )
131
+
132
+ buckets = [covering]
133
+ # Backfill the dense recent block that the wider bucket skips over.
134
+ for _, bucket in DATE_POSTED_BUCKETS:
135
+ if bucket == covering:
136
+ break
137
+ if bucket not in buckets:
138
+ buckets.insert(-1, bucket)
139
+ # Only the immediately-narrower bucket is worth the extra calls.
140
+ return buckets[-2:]
141
+
142
+
143
+ def _dedupe_key(post: Dict[str, Any]) -> str:
144
+ """Stable identity for a post.
145
+
146
+ Falls back to a content fingerprint when no identifier is present. Without
147
+ it, keyless posts are never deduped, so the union returns them once per
148
+ bucket AND a repeating cursor never trips the all-duplicates guard.
149
+ """
150
+ for field in ("url", "postUrl", "post_url", "urn", "id", "postId"):
151
+ val = post.get(field)
152
+ if val:
153
+ return str(val)
154
+ body = str(post.get("description") or post.get("text") or "")
155
+ author = str(post.get("author") or "")
156
+ if not body and not author:
157
+ return ""
158
+ return "sha1:" + hashlib.sha1(f"{author}\x00{body}".encode()).hexdigest()
159
+
160
+
30
161
  def search_linkedin(
31
162
  topic: str,
32
163
  from_date: str,
@@ -38,7 +169,7 @@ def search_linkedin(
38
169
 
39
170
  Args:
40
171
  topic: Search query / topic string.
41
- from_date: Window start date (YYYY-MM-DD) — used for depth mapping.
172
+ from_date: Window start date (YYYY-MM-DD) — sets the date_posted bucket.
42
173
  to_date: Window end date (YYYY-MM-DD).
43
174
  depth: Retrieval profile — 'quick', 'default', or 'deep'.
44
175
  token: ScrapeCreators API key.
@@ -51,30 +182,146 @@ def search_linkedin(
51
182
  return {"posts": []}
52
183
 
53
184
  cfg = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
54
- date_posted = cfg["date_posted"]
55
-
56
- _log(f"Searching for '{topic}' (date_posted={date_posted})")
57
-
58
- try:
59
- response = http.get(
60
- f"{SC_BASE}/search/posts",
61
- params={"query": topic, "date_posted": date_posted},
62
- headers=http.scrapecreators_headers(token),
63
- timeout=30,
64
- retries=2,
65
- )
66
- except http.HTTPError as exc:
67
- _log(f"Search failed (HTTP {exc.status_code}): {exc}")
68
- return {"posts": [], "error": str(exc)}
69
- except Exception as exc:
70
- _log(f"Search failed: {type(exc).__name__}: {exc}")
71
- return {"posts": [], "error": str(exc)}
72
-
73
- posts = _extract_posts(response)
185
+ buckets = _buckets_for_window(from_date, to_date, cfg["date_posted"])
74
186
  max_results = cfg["max_results"]
75
- posts = posts[:max_results]
76
- _log(f"Found {len(posts)} posts")
77
- return {"posts": posts}
187
+ query = topic.strip()
188
+ if not query:
189
+ _log("Empty query — skipping")
190
+ return {"posts": []}
191
+
192
+ # max_results bounds the RETURNED collection, not each retrieval lane, so
193
+ # split it across buckets rather than granting each the full budget.
194
+ quota = max(1, -(-max_results // len(buckets)))
195
+ deadline = time.monotonic() + SEARCH_BUDGET_SECONDS
196
+
197
+ _log(
198
+ f"Searching for '{query}' (date_posted={','.join(buckets)}, "
199
+ f"max_results={max_results}, quota/window={quota})"
200
+ )
201
+
202
+ posts: List[Dict[str, Any]] = []
203
+ seen: set[str] = set()
204
+ errors: List[str] = []
205
+ fatal = False
206
+
207
+ for date_posted in buckets:
208
+ if fatal or len(posts) >= max_results:
209
+ break
210
+
211
+ bucket_count = 0
212
+ pages = 0
213
+ empty_adds = 0
214
+ cursor: Any = None
215
+ prev_cursor: Any = None
216
+ stop = "pages exhausted"
217
+
218
+ for _ in range(MAX_PAGES):
219
+ remaining = deadline - time.monotonic()
220
+ if remaining <= 0:
221
+ stop = "wall-clock budget exceeded"
222
+ errors.append(f"{date_posted}: search budget of {SEARCH_BUDGET_SECONDS}s exceeded")
223
+ break
224
+
225
+ # Clamp the request to what is left of the budget, and drop the
226
+ # retry when there is not room for one. Checking the deadline only
227
+ # BETWEEN requests does not bound the call: a request starting a
228
+ # second before the deadline could still run its full
229
+ # timeout-times-retries budget past it.
230
+ page_timeout = max(MIN_PAGE_TIMEOUT, min(PAGE_TIMEOUT, remaining))
231
+ page_retries = 2 if remaining > (page_timeout * 2 + 2) else 1
232
+
233
+ params: Dict[str, Any] = {"query": query, "date_posted": date_posted}
234
+ if cursor:
235
+ params["cursor"] = cursor
236
+ try:
237
+ response = http.get(
238
+ f"{SC_BASE}/search/posts",
239
+ params=params,
240
+ headers=http.scrapecreators_headers(token),
241
+ timeout=page_timeout,
242
+ retries=page_retries,
243
+ )
244
+ except http.HTTPError as exc:
245
+ if exc.status_code == EMPTY_RESULT_STATUS:
246
+ # "No posts found" — a legitimate empty result, not a
247
+ # failure. Recording it as an error would mark the whole
248
+ # run partial and hide a real null behind a fake outage.
249
+ stop = "no posts found"
250
+ break
251
+ _log(f"Search failed (HTTP {exc.status_code}, {date_posted}): {exc}")
252
+ errors.append(f"{date_posted}: {exc}")
253
+ stop = f"HTTP {exc.status_code}"
254
+ if exc.status_code in FATAL_STATUS_CODES:
255
+ # Credential- or account-scoped: the next bucket would fail
256
+ # identically, and retrying a 429 deepens the rate limit.
257
+ fatal = True
258
+ break
259
+ except Exception as exc:
260
+ _log(f"Search failed ({date_posted}): {type(exc).__name__}: {exc}")
261
+ errors.append(f"{date_posted}: {type(exc).__name__}: {exc}")
262
+ stop = type(exc).__name__
263
+ fatal = True
264
+ break
265
+
266
+ pages += 1
267
+ page = _extract_posts(response)
268
+ if not page:
269
+ stop = "empty page"
270
+ break
271
+
272
+ added = 0
273
+ for post in page:
274
+ if len(posts) >= max_results:
275
+ break
276
+ key = _dedupe_key(post)
277
+ if key and key in seen:
278
+ continue
279
+ if key:
280
+ seen.add(key)
281
+ posts.append(post)
282
+ added += 1
283
+ bucket_count += 1
284
+
285
+ if len(posts) >= max_results:
286
+ stop = "max_results reached"
287
+ break
288
+ if bucket_count >= quota:
289
+ stop = "window quota reached"
290
+ break
291
+
292
+ # An all-duplicate page is expected where buckets overlap; only a
293
+ # run of them means the cursor has stopped yielding anything new.
294
+ empty_adds = empty_adds + 1 if added == 0 else 0
295
+ if empty_adds >= MAX_EMPTY_ADD_PAGES:
296
+ stop = f"{empty_adds} consecutive all-duplicate pages"
297
+ break
298
+
299
+ raw_cursor = response.get("cursor") if isinstance(response, dict) else None
300
+ cursor = raw_cursor if isinstance(raw_cursor, (str, int)) else None
301
+ if not cursor:
302
+ stop = "no cursor"
303
+ break
304
+ if cursor == prev_cursor:
305
+ stop = "cursor stopped advancing"
306
+ break
307
+ prev_cursor = cursor
308
+
309
+ _log(f" {date_posted}: {bucket_count} posts over {pages} page(s), stopped: {stop}")
310
+
311
+ if not posts and errors:
312
+ return {"posts": [], "error": errors[0]}
313
+
314
+ result: Dict[str, Any] = {"posts": posts}
315
+ if errors:
316
+ # A window that failed after another succeeded must not read as a
317
+ # complete result — downstream would treat thin coverage as a finding
318
+ # about LinkedIn rather than about the request that never landed.
319
+ result["partial"] = True
320
+ result["error"] = errors[0]
321
+ _log(f"PARTIAL — {len(errors)} window/page failure(s); first: {errors[0]}")
322
+
323
+ _log(f"Found {len(posts)} posts across {len(buckets)} window(s)")
324
+ return result
78
325
 
79
326
 
80
327
  def _extract_posts(response: Any) -> List[Dict[str, Any]]:
@@ -3,12 +3,15 @@
3
3
  import os
4
4
  import sys
5
5
 
6
- DEBUG = os.environ.get("LAST30DAYS_DEBUG", "").lower() in ("1", "true", "yes")
6
+
7
+ def is_debug() -> bool:
8
+ val = os.environ.get("LAST30DAYS_DEBUG", "")
9
+ return val.lower() in ("1", "true", "yes", "on")
7
10
 
8
11
 
9
12
  def debug(msg: str) -> None:
10
13
  """Log debug message to stderr (only when LAST30DAYS_DEBUG is set)."""
11
- if DEBUG:
14
+ if is_debug():
12
15
  sys.stderr.write(f"[DEBUG] {msg}\n")
13
16
  sys.stderr.flush()
14
17