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.
- 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/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- 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/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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()
|