luckiest-co 1.0.47 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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) —
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
|
14
|
+
if is_debug():
|
|
12
15
|
sys.stderr.write(f"[DEBUG] {msg}\n")
|
|
13
16
|
sys.stderr.flush()
|
|
14
17
|
|