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.
- 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 +8 -0
- package/skills/video-studio/SKILL.md +6 -5
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio/scripts/qa.mjs +64 -2
- package/skills/video-studio/scripts/qa.test.mjs +29 -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
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""Remote API client for last30days (optional hosted-backend mode).
|
|
2
|
+
|
|
3
|
+
When both LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are set, the engine
|
|
4
|
+
submits the topic to the configured remote API, polls until the run reaches a
|
|
5
|
+
terminal status, streams narration progress to stderr, and renders the
|
|
6
|
+
server's report. No local provider keys are required in this mode. The
|
|
7
|
+
endpoint comes only from LAST30DAYS_API_BASE - there is no built-in default.
|
|
8
|
+
|
|
9
|
+
Contract (API v1):
|
|
10
|
+
POST {base}/search Authorization: Bearer <key>
|
|
11
|
+
{"query": ..., "depth": "quick"|"default"|"deep",
|
|
12
|
+
"register"?: "exec"|"dev"|"creator"|"eli5"}
|
|
13
|
+
-> 200 {"search_id": "<uuid>", "status": "running"}
|
|
14
|
+
-> 200 clarify payload {"needs_clarification": true, ...}
|
|
15
|
+
-> 401 {"error"} / 402 {"error","requires_credits",
|
|
16
|
+
"balance","needed"} / 429 {"error"}
|
|
17
|
+
GET {base}/search?id=<uuid> same auth header; poll until status is
|
|
18
|
+
terminal ("complete" | "error"). Running rows carry
|
|
19
|
+
"stderr" (narration + engine lines) and "eta_ms";
|
|
20
|
+
terminal complete rows carry "synthesis_text" and
|
|
21
|
+
"raw_markdown" (stderr stripped).
|
|
22
|
+
|
|
23
|
+
This module carries ZERO pricing, rate-card, cost, or billing logic.
|
|
24
|
+
Balance/credit numbers are only ever displayed verbatim from API responses.
|
|
25
|
+
The API key is never printed, logged, or persisted by this module.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
|
|
36
|
+
from . import env, http, providers, usage
|
|
37
|
+
from .log import source_log
|
|
38
|
+
|
|
39
|
+
# Distinct exit code for the clarify gate so the invoking model can tell
|
|
40
|
+
# "re-run with a chosen angle" apart from a plain failure (1).
|
|
41
|
+
EXIT_CLARIFY = 3
|
|
42
|
+
|
|
43
|
+
POLL_INITIAL_DELAY = 3.0
|
|
44
|
+
POLL_MAX_DELAY = 10.0
|
|
45
|
+
POLL_TIMEOUT_SECONDS = 15 * 60
|
|
46
|
+
# GET is idempotent: retry a few times across network blips before giving up.
|
|
47
|
+
POLL_NETWORK_RETRIES = 3
|
|
48
|
+
# Cadence for the compact elapsed/eta progress line (seconds).
|
|
49
|
+
PROGRESS_LINE_INTERVAL = 15.0
|
|
50
|
+
|
|
51
|
+
NARRATE_PREFIX = "[narrate] step="
|
|
52
|
+
TERMINAL_STATUSES = {"complete", "error"}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _err(msg: str) -> None:
|
|
56
|
+
source_log("hosted", msg, tty_only=False)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _api_base() -> str:
|
|
60
|
+
# Endpoint comes only from the environment - no built-in default. Hosted
|
|
61
|
+
# mode is gated on this being set (see last30days.py), so by the time this
|
|
62
|
+
# is called it is populated; an empty value means "not configured".
|
|
63
|
+
base = (os.environ.get("LAST30DAYS_API_BASE") or "").rstrip("/")
|
|
64
|
+
if not providers.allowed_base_url_override(base):
|
|
65
|
+
raise ValueError(
|
|
66
|
+
"LAST30DAYS_API_BASE must use https:// "
|
|
67
|
+
"(http:// is allowed only for loopback hosts)."
|
|
68
|
+
)
|
|
69
|
+
return base
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _billing_url() -> str:
|
|
73
|
+
"""Derive a billing link from the configured base, so no URL is hardcoded.
|
|
74
|
+
Convention: the base is the API-version root (e.g. ends in /api/v1); drop
|
|
75
|
+
that segment and point at the account's billing page."""
|
|
76
|
+
base = _api_base()
|
|
77
|
+
root = re.sub(r"/api/v\d+$", "", base)
|
|
78
|
+
return f"{root}/dashboard/billing"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _auth_headers() -> dict[str, str]:
|
|
82
|
+
# Key is read at call time and placed only in the request header;
|
|
83
|
+
# it must never be interpolated into any log or output line.
|
|
84
|
+
key = env.read_secret_env("LAST30DAYS_API_KEY") or ""
|
|
85
|
+
return {"Authorization": f"Bearer {key}"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def submit(query: str, depth: str, register: str = "default") -> dict:
|
|
89
|
+
"""POST the search. retries=1: a blind POST retry could double-submit."""
|
|
90
|
+
payload = {"query": query, "depth": depth}
|
|
91
|
+
if register != "default":
|
|
92
|
+
payload["register"] = register
|
|
93
|
+
usage.begin("hosted")
|
|
94
|
+
return http.post(
|
|
95
|
+
f"{_api_base()}/search",
|
|
96
|
+
json_data=payload,
|
|
97
|
+
headers=_auth_headers(),
|
|
98
|
+
retries=1,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def poll(search_id: str) -> dict:
|
|
103
|
+
"""GET the search row once. Callers own the retry loop (GET is idempotent)."""
|
|
104
|
+
return http.get(
|
|
105
|
+
f"{_api_base()}/search",
|
|
106
|
+
headers=_auth_headers(),
|
|
107
|
+
params={"id": search_id},
|
|
108
|
+
retries=1,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_error_body(exc: http.HTTPError) -> dict:
|
|
113
|
+
if not exc.body:
|
|
114
|
+
return {}
|
|
115
|
+
try:
|
|
116
|
+
parsed = json.loads(exc.body)
|
|
117
|
+
except (json.JSONDecodeError, TypeError):
|
|
118
|
+
return {}
|
|
119
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _handle_http_error(exc: http.HTTPError) -> int:
|
|
123
|
+
body = _parse_error_body(exc)
|
|
124
|
+
if exc.status_code == 401:
|
|
125
|
+
_err(
|
|
126
|
+
"API key rejected: invalid or revoked. Check "
|
|
127
|
+
"LAST30DAYS_API_KEY (and LAST30DAYS_API_BASE), or unset them "
|
|
128
|
+
"to fall back to local sources."
|
|
129
|
+
)
|
|
130
|
+
return 1
|
|
131
|
+
if exc.status_code == 402:
|
|
132
|
+
_err(f"API: {body.get('error') or 'insufficient credits.'}")
|
|
133
|
+
if body.get("balance") is not None or body.get("needed") is not None:
|
|
134
|
+
_err(
|
|
135
|
+
f"Balance: {body.get('balance')} credits. "
|
|
136
|
+
f"Needed for this search: {body.get('needed')} credits."
|
|
137
|
+
)
|
|
138
|
+
_err(f"Add credits at {_billing_url()}")
|
|
139
|
+
return 1
|
|
140
|
+
if exc.status_code == 429:
|
|
141
|
+
_err(
|
|
142
|
+
f"API rate limit hit: "
|
|
143
|
+
f"{body.get('error') or 'too many requests.'} "
|
|
144
|
+
"Wait a minute and re-run."
|
|
145
|
+
)
|
|
146
|
+
return 1
|
|
147
|
+
_err(f"API request failed: {exc}")
|
|
148
|
+
return 1
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _handle_clarify(resp: dict) -> int:
|
|
152
|
+
question = resp.get("question") or "The API needs a clarification before searching."
|
|
153
|
+
options = resp.get("options") or []
|
|
154
|
+
_err(f"Clarification needed before this search runs: {question}")
|
|
155
|
+
for index, option in enumerate(options, 1):
|
|
156
|
+
label = option if isinstance(option, str) else json.dumps(option)
|
|
157
|
+
sys.stderr.write(f" {index}. {label}\n")
|
|
158
|
+
sys.stderr.flush()
|
|
159
|
+
_err(
|
|
160
|
+
"No search was started. Re-run last30days with the chosen angle "
|
|
161
|
+
"folded into the topic text."
|
|
162
|
+
)
|
|
163
|
+
return EXIT_CLARIFY
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_new_narration(stderr_blob: str, seen: set[str]) -> bool:
|
|
167
|
+
"""Print each '[narrate] step=' line once, verbatim. Returns True if any new."""
|
|
168
|
+
printed = False
|
|
169
|
+
for line in stderr_blob.splitlines():
|
|
170
|
+
if line.startswith(NARRATE_PREFIX) and line not in seen:
|
|
171
|
+
seen.add(line)
|
|
172
|
+
sys.stderr.write(f"{line}\n")
|
|
173
|
+
printed = True
|
|
174
|
+
if printed:
|
|
175
|
+
sys.stderr.flush()
|
|
176
|
+
return printed
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _print_progress_line(elapsed: float, eta_ms) -> None:
|
|
180
|
+
line = f"elapsed {int(elapsed)}s"
|
|
181
|
+
if isinstance(eta_ms, (int, float)) and eta_ms > 0:
|
|
182
|
+
line += f", eta ~{int(eta_ms / 1000)}s"
|
|
183
|
+
_err(line)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _poll_with_retry(search_id: str) -> dict | None:
|
|
187
|
+
"""Poll once, retrying transient network failures. None means give up
|
|
188
|
+
(a user-facing message has already been printed)."""
|
|
189
|
+
last_error: http.HTTPError | None = None
|
|
190
|
+
for attempt in range(POLL_NETWORK_RETRIES):
|
|
191
|
+
try:
|
|
192
|
+
return poll(search_id)
|
|
193
|
+
except http.HTTPError as exc:
|
|
194
|
+
if exc.status_code is not None and 400 <= exc.status_code < 500 and exc.status_code != 429:
|
|
195
|
+
_handle_http_error(exc)
|
|
196
|
+
return None
|
|
197
|
+
# Network blip / timeout / 5xx / 429: GET is idempotent, retry.
|
|
198
|
+
last_error = exc
|
|
199
|
+
if attempt < POLL_NETWORK_RETRIES - 1:
|
|
200
|
+
time.sleep(POLL_INITIAL_DELAY)
|
|
201
|
+
_err(
|
|
202
|
+
f"API unreachable while polling search {search_id} "
|
|
203
|
+
f"after {POLL_NETWORK_RETRIES} attempts: {last_error}"
|
|
204
|
+
)
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _slugify(value: str) -> str:
|
|
209
|
+
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
210
|
+
return slug or "last30days"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _save_output(topic: str, content: str, emit: str, save_dir: str, suffix: str):
|
|
214
|
+
"""Mirror local save_output() naming: <slug>-raw[-suffix].<ext>."""
|
|
215
|
+
from datetime import datetime
|
|
216
|
+
from pathlib import Path
|
|
217
|
+
|
|
218
|
+
path = Path(save_dir).expanduser().resolve()
|
|
219
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
slug = _slugify(topic)
|
|
221
|
+
extension = "json" if emit == "json" else "md"
|
|
222
|
+
suffix_part = f"-{suffix}" if suffix else ""
|
|
223
|
+
base = path / f"{slug}-raw{suffix_part}.{extension}"
|
|
224
|
+
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
225
|
+
candidates = [base]
|
|
226
|
+
candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}.{extension}")
|
|
227
|
+
for i in range(1, 100):
|
|
228
|
+
candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}-{i}.{extension}")
|
|
229
|
+
encoded = content.encode("utf-8")
|
|
230
|
+
for candidate in candidates:
|
|
231
|
+
try:
|
|
232
|
+
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
233
|
+
except FileExistsError:
|
|
234
|
+
continue
|
|
235
|
+
with os.fdopen(fd, "wb") as f:
|
|
236
|
+
f.write(encoded)
|
|
237
|
+
return candidate
|
|
238
|
+
# Fallback: all 101 candidates existed (extremely unlikely).
|
|
239
|
+
raise RuntimeError(
|
|
240
|
+
f"_save_output: could not find a unique filename after 101 attempts in {path}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _render_complete(row: dict, topic: str, emit: str, save_dir, save_suffix: str) -> int:
|
|
245
|
+
synthesis = row.get("synthesis_text") or ""
|
|
246
|
+
raw_markdown = row.get("raw_markdown") or ""
|
|
247
|
+
if emit == "json":
|
|
248
|
+
payload = {
|
|
249
|
+
key: row.get(key)
|
|
250
|
+
for key in ("id", "status", "synthesis_text", "raw_markdown")
|
|
251
|
+
if key in row
|
|
252
|
+
}
|
|
253
|
+
rendered = json.dumps(payload, indent=2, sort_keys=True)
|
|
254
|
+
save_content = rendered
|
|
255
|
+
else:
|
|
256
|
+
# The server report is the content source; it already synthesized.
|
|
257
|
+
# All markdown-ish emit modes print the synthesis text as-is.
|
|
258
|
+
rendered = synthesis or raw_markdown
|
|
259
|
+
save_content = raw_markdown or synthesis
|
|
260
|
+
if save_dir:
|
|
261
|
+
out_path = _save_output(topic, save_content, emit, save_dir, save_suffix)
|
|
262
|
+
sys.stderr.write(f"[last30days] Saved output to {out_path}\n")
|
|
263
|
+
sys.stderr.flush()
|
|
264
|
+
print(rendered)
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def run_hosted(
|
|
269
|
+
topic: str,
|
|
270
|
+
depth: str,
|
|
271
|
+
*,
|
|
272
|
+
emit: str = "compact",
|
|
273
|
+
save_dir=None,
|
|
274
|
+
save_suffix: str = "",
|
|
275
|
+
register: str = "default",
|
|
276
|
+
) -> int:
|
|
277
|
+
"""Submit topic to the remote API, poll to terminal status, render report."""
|
|
278
|
+
try:
|
|
279
|
+
base = _api_base()
|
|
280
|
+
except ValueError as exc:
|
|
281
|
+
_err(str(exc))
|
|
282
|
+
return 1
|
|
283
|
+
_err(f"Running via last30days API ({base}), depth={depth}")
|
|
284
|
+
try:
|
|
285
|
+
resp = submit(topic, depth, register=register)
|
|
286
|
+
except http.HTTPError as exc:
|
|
287
|
+
return _handle_http_error(exc)
|
|
288
|
+
|
|
289
|
+
if resp.get("needs_clarification"):
|
|
290
|
+
return _handle_clarify(resp)
|
|
291
|
+
|
|
292
|
+
search_id = resp.get("search_id")
|
|
293
|
+
if not search_id:
|
|
294
|
+
_err(f"Unexpected API response (no search_id): {json.dumps(resp)[:200]}")
|
|
295
|
+
return 1
|
|
296
|
+
_err(f"Search submitted (id: {search_id}). Polling for results...")
|
|
297
|
+
|
|
298
|
+
started = time.monotonic()
|
|
299
|
+
delay = POLL_INITIAL_DELAY
|
|
300
|
+
seen_narration: set[str] = set()
|
|
301
|
+
last_progress_line = 0.0
|
|
302
|
+
while True:
|
|
303
|
+
elapsed = time.monotonic() - started
|
|
304
|
+
if elapsed > POLL_TIMEOUT_SECONDS:
|
|
305
|
+
_err(
|
|
306
|
+
f"Search did not finish within "
|
|
307
|
+
f"{POLL_TIMEOUT_SECONDS // 60} minutes (id: {search_id}). "
|
|
308
|
+
"It may still complete server-side; check the dashboard."
|
|
309
|
+
)
|
|
310
|
+
return 1
|
|
311
|
+
time.sleep(delay)
|
|
312
|
+
delay = min(delay * 2, POLL_MAX_DELAY)
|
|
313
|
+
|
|
314
|
+
row = _poll_with_retry(search_id)
|
|
315
|
+
if row is None:
|
|
316
|
+
return 1
|
|
317
|
+
|
|
318
|
+
status = row.get("status")
|
|
319
|
+
narrated = _print_new_narration(row.get("stderr") or "", seen_narration)
|
|
320
|
+
elapsed = time.monotonic() - started
|
|
321
|
+
if status not in TERMINAL_STATUSES and (
|
|
322
|
+
narrated or elapsed - last_progress_line >= PROGRESS_LINE_INTERVAL or last_progress_line == 0.0
|
|
323
|
+
):
|
|
324
|
+
_print_progress_line(elapsed, row.get("eta_ms"))
|
|
325
|
+
last_progress_line = elapsed
|
|
326
|
+
|
|
327
|
+
if status == "error":
|
|
328
|
+
_err(f"Search failed: {row.get('error') or 'unknown server error'}")
|
|
329
|
+
return 1
|
|
330
|
+
if status == "complete":
|
|
331
|
+
_err(f"Search complete in {int(elapsed)}s.")
|
|
332
|
+
return _render_complete(row, topic, emit, save_dir, save_suffix)
|
|
333
|
+
# pending | running -> keep polling
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
from collections.abc import Mapping
|
|
6
7
|
from typing import Any, Callable
|
|
7
8
|
from urllib.error import HTTPError, URLError
|
|
8
9
|
from urllib.request import Request, urlopen
|
|
@@ -15,6 +16,14 @@ class HtmlPublishError(RuntimeError):
|
|
|
15
16
|
"""Raised when the hosted HTML publish endpoint rejects the artifact."""
|
|
16
17
|
|
|
17
18
|
|
|
19
|
+
class HtmlPublishBatchResult(dict[str, dict[str, Any]]):
|
|
20
|
+
"""Successful document publishes plus an optional later failure."""
|
|
21
|
+
|
|
22
|
+
def __init__(self) -> None:
|
|
23
|
+
super().__init__()
|
|
24
|
+
self.error: HtmlPublishError | None = None
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
def publish_html(
|
|
19
28
|
html_content: str,
|
|
20
29
|
*,
|
|
@@ -62,6 +71,31 @@ def publish_html(
|
|
|
62
71
|
return result
|
|
63
72
|
|
|
64
73
|
|
|
74
|
+
def publish_html_documents(
|
|
75
|
+
documents: Mapping[str, str],
|
|
76
|
+
*,
|
|
77
|
+
password: str | None = None,
|
|
78
|
+
endpoint: str = DEFAULT_ENDPOINT,
|
|
79
|
+
opener: Callable[..., Any] | None = None,
|
|
80
|
+
timeout: int = 30,
|
|
81
|
+
) -> HtmlPublishBatchResult:
|
|
82
|
+
"""Publish a named set of documents, preserving caller order in results."""
|
|
83
|
+
results = HtmlPublishBatchResult()
|
|
84
|
+
for name, content in documents.items():
|
|
85
|
+
try:
|
|
86
|
+
results[name] = publish_html(
|
|
87
|
+
content,
|
|
88
|
+
password=password,
|
|
89
|
+
endpoint=endpoint,
|
|
90
|
+
opener=opener,
|
|
91
|
+
timeout=timeout,
|
|
92
|
+
)
|
|
93
|
+
except HtmlPublishError as exc:
|
|
94
|
+
results.error = exc
|
|
95
|
+
break
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
65
99
|
def _error_message(status: int, detail: str) -> str:
|
|
66
100
|
try:
|
|
67
101
|
payload = json.loads(detail)
|
|
@@ -4,9 +4,12 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import html
|
|
6
6
|
import re
|
|
7
|
+
from collections import OrderedDict
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
7
9
|
from datetime import date
|
|
8
10
|
|
|
9
|
-
from . import render, schema
|
|
11
|
+
from . import registers, render, schema
|
|
12
|
+
from .library import LibraryEntry
|
|
10
13
|
|
|
11
14
|
|
|
12
15
|
PROSE_LABELS = [
|
|
@@ -274,6 +277,47 @@ td:first-child { color: var(--fg); font-weight: 500; }
|
|
|
274
277
|
font-size: 0.95em;
|
|
275
278
|
}
|
|
276
279
|
|
|
280
|
+
.library-hero {
|
|
281
|
+
padding: 1.5rem 0 2.5rem;
|
|
282
|
+
border-bottom: 1px solid var(--border);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.library-hero h1 { margin-bottom: 0.65rem; }
|
|
286
|
+
.library-hero p { max-width: 42rem; color: var(--fg-muted); }
|
|
287
|
+
.library-hero .subscribe { font-weight: 700; }
|
|
288
|
+
|
|
289
|
+
.library-topic { margin-top: 3rem; }
|
|
290
|
+
|
|
291
|
+
.library-topic-heading {
|
|
292
|
+
display: flex;
|
|
293
|
+
align-items: baseline;
|
|
294
|
+
justify-content: space-between;
|
|
295
|
+
gap: 1rem;
|
|
296
|
+
border-bottom: 1px solid var(--border);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.library-topic-heading h2 { margin-bottom: 0.65rem; }
|
|
300
|
+
.library-topic-heading a { font-size: 0.85rem; }
|
|
301
|
+
|
|
302
|
+
.library-entry {
|
|
303
|
+
display: grid;
|
|
304
|
+
grid-template-columns: 7rem minmax(0, 1fr);
|
|
305
|
+
column-gap: 1.25rem;
|
|
306
|
+
padding: 1.35rem 0;
|
|
307
|
+
border-bottom: 1px solid var(--border);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.library-entry time {
|
|
311
|
+
grid-row: 1 / span 2;
|
|
312
|
+
color: var(--fg-subtle);
|
|
313
|
+
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
|
|
314
|
+
font-size: 0.8rem;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.library-entry h3 { margin: 0 0 0.35rem; font-size: 1.1rem; }
|
|
318
|
+
.library-entry p { margin: 0; color: var(--fg-muted); }
|
|
319
|
+
.library-empty { padding: 3rem 0; }
|
|
320
|
+
|
|
277
321
|
@media print {
|
|
278
322
|
:root {
|
|
279
323
|
--bg: #ffffff;
|
|
@@ -319,6 +363,8 @@ td:first-child { color: var(--fg); font-weight: 500; }
|
|
|
319
363
|
h1 { font-size: 25px; }
|
|
320
364
|
.badge { font-size: 12px; }
|
|
321
365
|
th, td { padding: 0.65rem 0.5rem; }
|
|
366
|
+
.library-entry { grid-template-columns: 1fr; }
|
|
367
|
+
.library-entry time { grid-row: auto; margin-bottom: 0.5rem; }
|
|
322
368
|
}
|
|
323
369
|
""".strip()
|
|
324
370
|
|
|
@@ -346,9 +392,15 @@ def render_html(
|
|
|
346
392
|
fun_level: str = "medium",
|
|
347
393
|
save_path: str | None = None,
|
|
348
394
|
synthesis_md: str | None = None,
|
|
395
|
+
register: str = "default",
|
|
349
396
|
) -> str:
|
|
350
|
-
|
|
351
|
-
|
|
397
|
+
md = render.render_for_html(
|
|
398
|
+
report,
|
|
399
|
+
synthesis_md=synthesis_md,
|
|
400
|
+
save_path=save_path,
|
|
401
|
+
fun_level=fun_level,
|
|
402
|
+
register=register,
|
|
403
|
+
)
|
|
352
404
|
md = _strip_evidence_block(md)
|
|
353
405
|
md = _strip_invitation(md)
|
|
354
406
|
md = _strip_canonical_boundary(md)
|
|
@@ -383,6 +435,120 @@ def render_html_comparison(
|
|
|
383
435
|
return _wrap_in_template(body, colophon, topic)
|
|
384
436
|
|
|
385
437
|
|
|
438
|
+
LIBRARY_BRIEF_MARKER = "<!-- generated by last30days library feed -->"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def render_library_brief(entry: LibraryEntry, *, include_private: bool = True) -> str:
|
|
442
|
+
"""Render a scanned Markdown or JSON briefing as a safe standalone page."""
|
|
443
|
+
md = _strip_invitation(entry.content)
|
|
444
|
+
md = _strip_canonical_boundary(md)
|
|
445
|
+
if not include_private:
|
|
446
|
+
md = _strip_private_corpus(md)
|
|
447
|
+
body = _markdown_to_html(md)
|
|
448
|
+
body = _wrap_engine_footer(body)
|
|
449
|
+
colophon = (
|
|
450
|
+
'<footer class="colophon">'
|
|
451
|
+
f"Saved research · {html.escape(entry.published_date.isoformat())} · "
|
|
452
|
+
f"{html.escape(entry.topic)}"
|
|
453
|
+
"</footer>"
|
|
454
|
+
)
|
|
455
|
+
rendered = scrub_publishable_digit_runs(
|
|
456
|
+
_wrap_in_template(body, colophon, entry.headline)
|
|
457
|
+
)
|
|
458
|
+
# Ownership marker consumed by the library-feed prune: a generated-looking
|
|
459
|
+
# filename alone must never be grounds for deletion.
|
|
460
|
+
return rendered.replace("</body>", f"{LIBRARY_BRIEF_MARKER}\n</body>", 1)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
_PRIVATE_CORPUS_BLOCK = re.compile(
|
|
464
|
+
r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
|
|
465
|
+
r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
|
|
466
|
+
re.DOTALL,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _strip_private_corpus(markdown: str) -> str:
|
|
471
|
+
"""Remove the renderer-marked local corpus section before publication."""
|
|
472
|
+
return _PRIVATE_CORPUS_BLOCK.sub("", markdown)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def render_library_index(
|
|
476
|
+
entries: Sequence[LibraryEntry],
|
|
477
|
+
*,
|
|
478
|
+
entry_urls: Mapping[str, str] | None = None,
|
|
479
|
+
feed_url: str | None = "feed.xml",
|
|
480
|
+
) -> str:
|
|
481
|
+
"""Render the reverse-chronological, topic-grouped research index."""
|
|
482
|
+
urls = entry_urls or {}
|
|
483
|
+
grouped: OrderedDict[str, list[LibraryEntry]] = OrderedDict()
|
|
484
|
+
for entry in entries:
|
|
485
|
+
grouped.setdefault(entry.topic, []).append(entry)
|
|
486
|
+
|
|
487
|
+
parts = [
|
|
488
|
+
'<header class="library-hero">',
|
|
489
|
+
'<span class="badge">RESEARCH LIBRARY</span>',
|
|
490
|
+
'<h1>What the community is learning</h1>',
|
|
491
|
+
]
|
|
492
|
+
if feed_url is None:
|
|
493
|
+
parts.append('<p>Saved last30days briefs, newest first.</p>')
|
|
494
|
+
else:
|
|
495
|
+
parts.extend([
|
|
496
|
+
'<p>Saved last30days briefs, newest first. Follow the Atom feed to keep up.</p>',
|
|
497
|
+
f'<p><a class="subscribe" href="{html.escape(feed_url, quote=True)}">Subscribe via Atom</a></p>',
|
|
498
|
+
])
|
|
499
|
+
parts.append('</header>')
|
|
500
|
+
if not entries:
|
|
501
|
+
parts.append('<section class="library-empty"><h2>No saved briefs yet</h2><p>Run last30days research and this library will fill itself.</p></section>')
|
|
502
|
+
for topic, topic_entries in grouped.items():
|
|
503
|
+
latest = topic_entries[0]
|
|
504
|
+
latest_url = urls.get(latest.entry_id, f"briefs/{latest.output_name}")
|
|
505
|
+
parts.extend([
|
|
506
|
+
'<section class="library-topic">',
|
|
507
|
+
'<div class="library-topic-heading">',
|
|
508
|
+
f'<h2>{html.escape(topic)}</h2>',
|
|
509
|
+
f'<a href="{html.escape(latest_url, quote=True)}">Latest</a>',
|
|
510
|
+
'</div>',
|
|
511
|
+
])
|
|
512
|
+
for entry in topic_entries:
|
|
513
|
+
url = urls.get(entry.entry_id, f"briefs/{entry.output_name}")
|
|
514
|
+
parts.extend([
|
|
515
|
+
'<article class="library-entry">',
|
|
516
|
+
f'<time datetime="{entry.published_date.isoformat()}">{entry.published_date.isoformat()}</time>',
|
|
517
|
+
f'<h3><a href="{html.escape(url, quote=True)}">{html.escape(entry.headline)}</a></h3>',
|
|
518
|
+
f'<p>{html.escape(entry.summary)}</p>',
|
|
519
|
+
'</article>',
|
|
520
|
+
])
|
|
521
|
+
parts.append('</section>')
|
|
522
|
+
colophon = '<footer class="colophon">Generated locally by <strong>last30days</strong>.</footer>'
|
|
523
|
+
rendered = _wrap_in_template("\n".join(parts), colophon, "Research library")
|
|
524
|
+
return scrub_publishable_digit_runs(rendered)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_HREF_PATTERN = re.compile(r'(?P<prefix>\bhref\s*=\s*)(?P<quote>["\'])(?P<url>.*?)(?P=quote)', re.IGNORECASE)
|
|
528
|
+
_LONG_DIGIT_RUN = re.compile(r"\d{13,19}")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def scrub_publishable_digit_runs(html_content: str) -> str:
|
|
532
|
+
"""Defuse payment-card-shaped digit runs before hosted publishing.
|
|
533
|
+
|
|
534
|
+
ht-ml.app rejects pages containing 13-19 digit runs during its safety scan.
|
|
535
|
+
Social post IDs commonly have that shape. Link targets are percent-encoded
|
|
536
|
+
so they still resolve; visible occurrences are shortened for readability.
|
|
537
|
+
"""
|
|
538
|
+
def scrub_href(match: re.Match[str]) -> str:
|
|
539
|
+
url = _LONG_DIGIT_RUN.sub(
|
|
540
|
+
lambda digits: "".join(f"%{ord(char):02X}" for char in digits.group(0)),
|
|
541
|
+
match.group("url"),
|
|
542
|
+
)
|
|
543
|
+
return f'{match.group("prefix")}{match.group("quote")}{url}{match.group("quote")}'
|
|
544
|
+
|
|
545
|
+
with_safe_hrefs = _HREF_PATTERN.sub(scrub_href, html_content)
|
|
546
|
+
return _LONG_DIGIT_RUN.sub(
|
|
547
|
+
lambda digits: f"{digits.group(0)[:6]}…{digits.group(0)[-4:]}",
|
|
548
|
+
with_safe_hrefs,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
|
|
386
552
|
def _strip_evidence_block(md: str) -> str:
|
|
387
553
|
return EVIDENCE_BLOCK_PATTERN.sub("", md)
|
|
388
554
|
|