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
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
"""Subprocess helpers: safe timeout + process-
|
|
1
|
+
"""Subprocess helpers: safe timeout + process-tree cleanup.
|
|
2
2
|
|
|
3
|
-
Used by
|
|
4
|
-
|
|
5
|
-
dance on timeout to avoid orphaning child processes.
|
|
3
|
+
Used by Bird/X search and other external-source clients. POSIX children use
|
|
4
|
+
their own process group; Windows children run inside a kill-on-close job.
|
|
6
5
|
"""
|
|
7
6
|
|
|
8
7
|
from __future__ import annotations
|
|
@@ -10,6 +9,10 @@ from __future__ import annotations
|
|
|
10
9
|
import os
|
|
11
10
|
import signal
|
|
12
11
|
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import ExitStack
|
|
13
16
|
from dataclasses import dataclass
|
|
14
17
|
from typing import Optional, Sequence
|
|
15
18
|
|
|
@@ -17,6 +20,138 @@ from typing import Optional, Sequence
|
|
|
17
20
|
class SubprocTimeout(Exception):
|
|
18
21
|
"""Raised when a subprocess exceeds its timeout and is killed."""
|
|
19
22
|
|
|
23
|
+
def __init__(self, message: str = "", *, started: bool = True):
|
|
24
|
+
super().__init__(message)
|
|
25
|
+
self.started = started
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Live run_with_timeout children, process-wide. Each child is a session
|
|
29
|
+
# leader (os.setsid), so a group kill aimed at the engine never reaches it;
|
|
30
|
+
# the engine's SIGTERM handler and atexit hook drain this set instead. It
|
|
31
|
+
# lives here, not in last30days.py, because the entrypoint runs as
|
|
32
|
+
# __main__: importing it by name from lib/ executes a second module copy
|
|
33
|
+
# with its own empty registry, and on a worker thread that copy cannot
|
|
34
|
+
# install its signal handler either. RLock because cleanup_children runs
|
|
35
|
+
# inside a signal handler on the main thread, which may already hold the
|
|
36
|
+
# lock in register_child_pid.
|
|
37
|
+
_child_pids: set[int] = set()
|
|
38
|
+
_child_pids_lock = threading.RLock()
|
|
39
|
+
_child_jobs: dict[int, object] = {}
|
|
40
|
+
# Set once cleanup_children starts. Worker threads keep running while the
|
|
41
|
+
# handler sleeps through the grace, so a source can spawn a child after the
|
|
42
|
+
# snapshot; register_child_pid kills such late children itself.
|
|
43
|
+
_shutting_down = False
|
|
44
|
+
_WINDOWS = os.name == "nt"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def register_child_pid(pid: int) -> None:
|
|
48
|
+
with _child_pids_lock:
|
|
49
|
+
_child_pids.add(pid)
|
|
50
|
+
late = _shutting_down
|
|
51
|
+
if late:
|
|
52
|
+
_kill_child_group(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def unregister_child_pid(pid: int) -> None:
|
|
56
|
+
with _child_pids_lock:
|
|
57
|
+
_child_pids.discard(pid)
|
|
58
|
+
job = _child_jobs.pop(pid, None)
|
|
59
|
+
if job is not None:
|
|
60
|
+
job.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Upper bound on how long cleanup_children waits for SIGTERMed groups before
|
|
64
|
+
# SIGKILLing them. It runs inside the engine's SIGTERM handler, so it must
|
|
65
|
+
# finish well inside the MCP server's termGracePeriod
|
|
66
|
+
# (mcp/internal/engine/run.go), after which the engine group is SIGKILLed and
|
|
67
|
+
# the handler never reaches the escalation. Pinned by tests/test_subproc.py.
|
|
68
|
+
CLEANUP_TERM_GRACE_SECONDS = 0.8
|
|
69
|
+
_CLEANUP_POLL_SECONDS = 0.05
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _signal_group(pgid: int, sig: int) -> bool:
|
|
73
|
+
"""Signal a child's process group; False once the group has no members."""
|
|
74
|
+
try:
|
|
75
|
+
os.killpg(pgid, sig)
|
|
76
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _taskkill_pid(pid: int, timeout: float = 2) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(
|
|
84
|
+
["taskkill", "/F", "/T", "/PID", str(pid)],
|
|
85
|
+
stdin=subprocess.DEVNULL,
|
|
86
|
+
stdout=subprocess.DEVNULL,
|
|
87
|
+
stderr=subprocess.DEVNULL,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
check=False,
|
|
90
|
+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
|
91
|
+
)
|
|
92
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
93
|
+
return False
|
|
94
|
+
return result.returncode == 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _kill_windows_tree(pid: int) -> bool:
|
|
98
|
+
return _taskkill_pid(pid)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _kill_windows_managed_tree(pid: int) -> bool:
|
|
102
|
+
with _child_pids_lock:
|
|
103
|
+
job = _child_jobs.get(pid)
|
|
104
|
+
if job is not None and job.terminate():
|
|
105
|
+
return True
|
|
106
|
+
return _kill_windows_tree(pid)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _kill_child_group(pid: int, sig: int) -> None:
|
|
110
|
+
if hasattr(os, "setsid") and hasattr(os, "killpg"):
|
|
111
|
+
_signal_group(pid, sig)
|
|
112
|
+
return
|
|
113
|
+
if _WINDOWS and _kill_windows_managed_tree(pid):
|
|
114
|
+
return
|
|
115
|
+
try:
|
|
116
|
+
os.kill(pid, sig)
|
|
117
|
+
except (ProcessLookupError, PermissionError, OSError):
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cleanup_children(grace: float = CLEANUP_TERM_GRACE_SECONDS) -> None:
|
|
122
|
+
"""Terminate the process group of every registered child.
|
|
123
|
+
|
|
124
|
+
Windows jobs terminate immediately. On POSIX, SIGTERM every group, wait
|
|
125
|
+
up to ``grace`` seconds for the groups to empty, then SIGKILL survivors.
|
|
126
|
+
run_with_timeout starts each POSIX child with os.setsid, so the child's
|
|
127
|
+
pid is its pgid; signalling the pgid directly still reaches grandchildren
|
|
128
|
+
after the leader has been reaped,
|
|
129
|
+
and the kernel does not reuse a pid while a group of that id has
|
|
130
|
+
members. A group whose only member is an unreaped zombie still reads as
|
|
131
|
+
live, which at worst costs the full grace and a harmless SIGKILL.
|
|
132
|
+
Children registered after this call starts are SIGKILLed as they
|
|
133
|
+
register, since the caller is about to exit.
|
|
134
|
+
"""
|
|
135
|
+
global _shutting_down
|
|
136
|
+
with _child_pids_lock:
|
|
137
|
+
_shutting_down = True
|
|
138
|
+
pids = list(_child_pids)
|
|
139
|
+
if not pids:
|
|
140
|
+
return
|
|
141
|
+
if not (hasattr(os, "setsid") and hasattr(os, "killpg")):
|
|
142
|
+
for pid in pids:
|
|
143
|
+
with _child_pids_lock:
|
|
144
|
+
if pid in _child_pids:
|
|
145
|
+
_kill_child_group(pid, signal.SIGTERM)
|
|
146
|
+
return
|
|
147
|
+
live = [pgid for pgid in pids if _signal_group(pgid, signal.SIGTERM)]
|
|
148
|
+
deadline = time.monotonic() + grace
|
|
149
|
+
while live and time.monotonic() < deadline:
|
|
150
|
+
time.sleep(_CLEANUP_POLL_SECONDS)
|
|
151
|
+
live = [pgid for pgid in live if _signal_group(pgid, 0)]
|
|
152
|
+
for pgid in live:
|
|
153
|
+
_signal_group(pgid, signal.SIGKILL)
|
|
154
|
+
|
|
20
155
|
|
|
21
156
|
@dataclass
|
|
22
157
|
class SubprocResult:
|
|
@@ -30,84 +165,203 @@ class SubprocResult:
|
|
|
30
165
|
def run_with_timeout(
|
|
31
166
|
cmd: Sequence[str],
|
|
32
167
|
*,
|
|
33
|
-
timeout:
|
|
168
|
+
timeout: float,
|
|
34
169
|
env: Optional[dict] = None,
|
|
35
170
|
on_pid: Optional[callable] = None,
|
|
171
|
+
input_text: Optional[str] = None,
|
|
172
|
+
deadline_monotonic: Optional[float] = None,
|
|
173
|
+
cancel: Optional[threading.Event] = None,
|
|
174
|
+
cleanup_grace: float = 5.0,
|
|
175
|
+
capture_limit_bytes: Optional[int] = None,
|
|
36
176
|
) -> SubprocResult:
|
|
37
177
|
"""Run a subprocess with process-group cleanup on timeout.
|
|
38
178
|
|
|
39
|
-
Spawns ``cmd`` inside its own process group via ``
|
|
40
|
-
available.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
179
|
+
Spawns ``cmd`` inside its own process group via ``start_new_session`` where
|
|
180
|
+
available. On Windows, the child enters a kill-on-close job before it runs,
|
|
181
|
+
so cleanup reaches descendants that inherit the job after launchers exit.
|
|
182
|
+
Closing the job on normal return also stops detached descendants. Job setup
|
|
183
|
+
failure aborts the suspended child. POSIX signals the group with SIGTERM,
|
|
184
|
+
then escalates to SIGKILL if needed.
|
|
44
185
|
|
|
45
186
|
Args:
|
|
46
187
|
cmd: Command and arguments to spawn.
|
|
47
188
|
timeout: Timeout in seconds passed to ``communicate()``.
|
|
48
189
|
env: Optional environment dict. If None, inherits parent env.
|
|
49
190
|
on_pid: Optional callable invoked with the child PID right after
|
|
50
|
-
spawn.
|
|
51
|
-
|
|
191
|
+
spawn. Exceptions raised by the callback are suppressed. The
|
|
192
|
+
child is registered for cleanup_children() regardless.
|
|
193
|
+
input_text: Optional UTF-8 text supplied on stdin, never argv.
|
|
194
|
+
deadline_monotonic: Absolute operation deadline, including startup.
|
|
195
|
+
cancel: Optional event checked while waiting for the command.
|
|
196
|
+
cleanup_grace: Maximum TERM wait and subsequent KILL/reap wait, each.
|
|
197
|
+
capture_limit_bytes: Capture to temporary files instead of pipes,
|
|
198
|
+
reading at most this many bytes from each stream after exit.
|
|
52
199
|
|
|
53
200
|
Returns:
|
|
54
201
|
SubprocResult with returncode, stdout, and stderr as strings.
|
|
55
202
|
|
|
56
203
|
Raises:
|
|
57
|
-
SubprocTimeout: If the process
|
|
204
|
+
SubprocTimeout: If the process timed out, was cancelled, or its
|
|
205
|
+
absolute deadline elapsed.
|
|
58
206
|
FileNotFoundError: If the executable is not found.
|
|
59
207
|
OSError: For other spawn failures.
|
|
60
208
|
"""
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
on_pid(proc.pid)
|
|
77
|
-
except Exception:
|
|
78
|
-
pass
|
|
209
|
+
if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
|
|
210
|
+
raise SubprocTimeout("Command deadline exceeded before spawn", started=False)
|
|
211
|
+
if cancel is not None and cancel.is_set():
|
|
212
|
+
raise SubprocTimeout("Command cancelled before spawn", started=False)
|
|
213
|
+
if capture_limit_bytes is not None and capture_limit_bytes < 0:
|
|
214
|
+
raise ValueError("capture limit must be nonnegative")
|
|
215
|
+
own_group = hasattr(os, "setsid") and hasattr(os, "killpg")
|
|
216
|
+
native_windows = _WINDOWS
|
|
217
|
+
capture = ExitStack()
|
|
218
|
+
job = None
|
|
219
|
+
try:
|
|
220
|
+
stdout_file = capture.enter_context(tempfile.TemporaryFile()) if capture_limit_bytes is not None else None
|
|
221
|
+
stderr_file = capture.enter_context(tempfile.TemporaryFile()) if capture_limit_bytes is not None else None
|
|
222
|
+
if native_windows:
|
|
223
|
+
from .windows_job import CREATE_SUSPENDED, WindowsJob
|
|
79
224
|
|
|
225
|
+
job = WindowsJob()
|
|
226
|
+
proc = subprocess.Popen(
|
|
227
|
+
list(cmd),
|
|
228
|
+
stdout=stdout_file if stdout_file is not None else subprocess.PIPE,
|
|
229
|
+
stderr=stderr_file if stderr_file is not None else subprocess.PIPE,
|
|
230
|
+
stdin=subprocess.PIPE if input_text is not None else None,
|
|
231
|
+
text=True,
|
|
232
|
+
encoding="utf-8",
|
|
233
|
+
errors="replace",
|
|
234
|
+
start_new_session=own_group,
|
|
235
|
+
env=env,
|
|
236
|
+
**({"creationflags": CREATE_SUSPENDED} if native_windows else {}),
|
|
237
|
+
)
|
|
238
|
+
except BaseException as exc:
|
|
239
|
+
if isinstance(exc, OSError):
|
|
240
|
+
exc._last30days_subproc_launch_failed = True
|
|
241
|
+
if job is not None:
|
|
242
|
+
job.close()
|
|
243
|
+
capture.close()
|
|
244
|
+
raise
|
|
80
245
|
try:
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
246
|
+
if native_windows:
|
|
247
|
+
from .windows_job import resume_suspended_process
|
|
248
|
+
|
|
249
|
+
try:
|
|
250
|
+
job.assign(proc._handle)
|
|
251
|
+
with _child_pids_lock:
|
|
252
|
+
_child_pids.add(proc.pid)
|
|
253
|
+
_child_jobs[proc.pid] = job
|
|
254
|
+
late = _shutting_down
|
|
255
|
+
if late:
|
|
256
|
+
if not job.terminate():
|
|
257
|
+
proc.kill()
|
|
258
|
+
else:
|
|
259
|
+
resume_suspended_process(proc.pid)
|
|
260
|
+
if late:
|
|
261
|
+
raise SubprocTimeout("Command cancelled during shutdown")
|
|
262
|
+
except BaseException as exc:
|
|
263
|
+
if isinstance(exc, OSError):
|
|
264
|
+
exc._last30days_subproc_launch_failed = True
|
|
265
|
+
if proc.poll() is None:
|
|
266
|
+
try:
|
|
267
|
+
proc.kill()
|
|
268
|
+
except OSError:
|
|
269
|
+
pass
|
|
270
|
+
try:
|
|
271
|
+
proc.wait(timeout=min(cleanup_grace, 2))
|
|
272
|
+
except subprocess.TimeoutExpired:
|
|
273
|
+
pass
|
|
274
|
+
raise
|
|
275
|
+
else:
|
|
276
|
+
register_child_pid(proc.pid)
|
|
277
|
+
if on_pid is not None:
|
|
278
|
+
try:
|
|
279
|
+
on_pid(proc.pid)
|
|
280
|
+
except Exception:
|
|
281
|
+
pass
|
|
90
282
|
try:
|
|
91
|
-
|
|
283
|
+
deadline = time.monotonic() + timeout
|
|
284
|
+
if deadline_monotonic is not None:
|
|
285
|
+
deadline = min(deadline, deadline_monotonic)
|
|
286
|
+
first = True
|
|
287
|
+
while True:
|
|
288
|
+
remaining = deadline - time.monotonic()
|
|
289
|
+
if remaining <= 0 or (cancel is not None and cancel.is_set()):
|
|
290
|
+
raise subprocess.TimeoutExpired(cmd, timeout)
|
|
291
|
+
try:
|
|
292
|
+
stdout, stderr = proc.communicate(
|
|
293
|
+
input=input_text if first else None,
|
|
294
|
+
timeout=min(remaining, 0.05) if cancel is not None else remaining,
|
|
295
|
+
)
|
|
296
|
+
if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
|
|
297
|
+
raise subprocess.TimeoutExpired(cmd, timeout)
|
|
298
|
+
break
|
|
299
|
+
except subprocess.TimeoutExpired:
|
|
300
|
+
if cancel is None or time.monotonic() >= deadline or cancel.is_set():
|
|
301
|
+
raise
|
|
302
|
+
finally:
|
|
303
|
+
first = False
|
|
92
304
|
except subprocess.TimeoutExpired:
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
# they are POSIX-only and raise AttributeError on Windows. The
|
|
96
|
-
# primary path was hardened in #552; this mirrors that guard on the
|
|
97
|
-
# escalation path (added later in #433) so the same crash can't
|
|
98
|
-
# re-surface here (#588).
|
|
305
|
+
term_deadline = time.monotonic() + cleanup_grace
|
|
306
|
+
pgid = None
|
|
99
307
|
try:
|
|
100
|
-
if
|
|
101
|
-
|
|
102
|
-
|
|
308
|
+
if own_group:
|
|
309
|
+
pgid = proc.pid
|
|
310
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
311
|
+
elif not (_WINDOWS and _kill_windows_managed_tree(proc.pid)):
|
|
103
312
|
proc.kill()
|
|
104
313
|
except (ProcessLookupError, PermissionError, OSError, AttributeError):
|
|
314
|
+
pgid = None
|
|
105
315
|
proc.kill()
|
|
106
316
|
try:
|
|
107
|
-
proc.wait(timeout=
|
|
317
|
+
proc.wait(timeout=cleanup_grace)
|
|
318
|
+
except subprocess.TimeoutExpired:
|
|
319
|
+
# Child ignored SIGTERM (or our killpg lost the race); escalate.
|
|
320
|
+
try:
|
|
321
|
+
if pgid is not None:
|
|
322
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
323
|
+
elif not (_WINDOWS and _kill_windows_managed_tree(proc.pid)):
|
|
324
|
+
proc.kill()
|
|
325
|
+
except (ProcessLookupError, PermissionError, OSError, AttributeError):
|
|
326
|
+
proc.kill()
|
|
327
|
+
try:
|
|
328
|
+
proc.wait(timeout=cleanup_grace)
|
|
329
|
+
except subprocess.TimeoutExpired:
|
|
330
|
+
pass # process unkillable (e.g. D-state); leave as zombie
|
|
331
|
+
else:
|
|
332
|
+
if pgid is not None:
|
|
333
|
+
# Reaping the leader does not end its process group. Keep
|
|
334
|
+
# the remaining members inside the original TERM grace.
|
|
335
|
+
while _signal_group(pgid, 0):
|
|
336
|
+
remaining = term_deadline - time.monotonic()
|
|
337
|
+
if remaining <= 0:
|
|
338
|
+
_signal_group(pgid, signal.SIGKILL)
|
|
339
|
+
break
|
|
340
|
+
time.sleep(min(_CLEANUP_POLL_SECONDS, remaining))
|
|
341
|
+
raise SubprocTimeout(f"Command {cmd[0]} timed out after {timeout}s")
|
|
342
|
+
if capture_limit_bytes is not None:
|
|
343
|
+
stdout_file.seek(0)
|
|
344
|
+
stderr_file.seek(0)
|
|
345
|
+
stdout = stdout_file.read(capture_limit_bytes).decode("utf-8", errors="replace")
|
|
346
|
+
stderr = stderr_file.read(capture_limit_bytes).decode("utf-8", errors="replace")
|
|
347
|
+
except SubprocTimeout:
|
|
348
|
+
raise
|
|
349
|
+
except BaseException:
|
|
350
|
+
if proc.poll() is None:
|
|
351
|
+
_kill_child_group(proc.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
|
|
352
|
+
try:
|
|
353
|
+
proc.wait(timeout=cleanup_grace)
|
|
108
354
|
except subprocess.TimeoutExpired:
|
|
109
|
-
pass
|
|
110
|
-
raise
|
|
355
|
+
pass
|
|
356
|
+
raise
|
|
357
|
+
finally:
|
|
358
|
+
unregister_child_pid(proc.pid)
|
|
359
|
+
if job is not None:
|
|
360
|
+
job.close()
|
|
361
|
+
for pipe in (proc.stdin, proc.stdout, proc.stderr):
|
|
362
|
+
if pipe is not None:
|
|
363
|
+
pipe.close()
|
|
364
|
+
capture.close()
|
|
111
365
|
|
|
112
366
|
return SubprocResult(
|
|
113
367
|
returncode=proc.returncode,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""Techmeme tech-news source for last30days.
|
|
2
2
|
|
|
3
|
-
Shells out to ``techmeme-pp-cli`` (no auth)
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Shells out to ``techmeme-pp-cli`` (no auth). The CLI's ``search`` command hits
|
|
4
|
+
Techmeme's live archive search endpoint (results back to ~2005) -- it never
|
|
5
|
+
reads the locally synced headline cache, so the adapter performs no ``sync``.
|
|
6
6
|
|
|
7
7
|
Activation gate: only available when ``techmeme-pp-cli`` is on PATH.
|
|
8
8
|
``pipeline.available_sources`` checks ``shutil.which`` before including
|
|
@@ -11,23 +11,31 @@ Activation gate: only available when ``techmeme-pp-cli`` is on PATH.
|
|
|
11
11
|
Surface choice: ``search "<topic>" --json`` (NOT ``--agent``). ``--agent``
|
|
12
12
|
implies ``--compact``, which on older binaries stripped headline records to
|
|
13
13
|
``{}`` (fixed upstream in printing-press-library PR #1383); ``--json`` without
|
|
14
|
-
``--compact`` returns the populated
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
``--compact`` returns the populated record shape on every binary version, so
|
|
15
|
+
the adapter is robust regardless of the installed build.
|
|
16
|
+
|
|
17
|
+
Dates: current binaries emit ``{num, source, headline, link, date}`` where
|
|
18
|
+
``date`` is ISO ``YYYY-MM-DD`` (or ``""`` when Techmeme's markup was
|
|
19
|
+
unparseable). The adapter windows records to the research range
|
|
20
|
+
(``from_date <= date <= to_date``) so archive hits from years past never
|
|
21
|
+
masquerade as current news. Records with no usable date -- old binaries emit
|
|
22
|
+
no ``date`` key at all -- are kept but flow downstream with no date, so
|
|
23
|
+
``normalize._normalize_techmeme`` assigns ``date_confidence: low``. Headlines
|
|
24
|
+
are never stamped with today's date. (This deliberately diverges from
|
|
25
|
+
``lib/arxiv.py``, which drops entries with unparseable dates -- arXiv's feed
|
|
26
|
+
reliably carries dates, so an unparseable one is anomalous; Techmeme's old
|
|
27
|
+
binaries emit no ``date`` key at all, so dropping would zero out the source
|
|
28
|
+
for every user on an old binary.) Old binaries also print prose
|
|
29
|
+
(``No results for "q"``) to stdout on zero hits; that parses as an empty
|
|
30
|
+
result set, not a decode failure. Publication-name header rows (very short
|
|
31
|
+
``headline`` values) are dropped; ranking is topic relevance plus rank decay.
|
|
22
32
|
"""
|
|
23
33
|
|
|
24
34
|
from __future__ import annotations
|
|
25
35
|
|
|
26
36
|
import json
|
|
37
|
+
import re
|
|
27
38
|
import shutil
|
|
28
|
-
import threading
|
|
29
|
-
import time
|
|
30
|
-
from datetime import datetime, timezone
|
|
31
39
|
from typing import Any, Dict, List
|
|
32
40
|
|
|
33
41
|
from . import log, subproc
|
|
@@ -48,19 +56,12 @@ DEPTH_CONFIG = {
|
|
|
48
56
|
MIN_HEADLINE_WORDS = 4
|
|
49
57
|
|
|
50
58
|
SEARCH_TIMEOUT = 30
|
|
51
|
-
SYNC_TIMEOUT = 40
|
|
52
59
|
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
# misrepresent old news as current).
|
|
59
|
-
_SYNC_LOCK = threading.Lock()
|
|
60
|
-
_SYNC_TTL = 600.0 # seconds
|
|
61
|
-
_LAST_SYNC = 0.0
|
|
62
|
-
# Sentinel used by tests to force a sync regardless of the TTL window.
|
|
63
|
-
_NEVER_SYNCED = float("-inf")
|
|
60
|
+
# Old binaries print this prose to stdout (exit 0) on zero hits, even in JSON
|
|
61
|
+
# mode. It is a zero-result response, not malformed output.
|
|
62
|
+
_NO_RESULTS_PREFIX = "No results"
|
|
63
|
+
|
|
64
|
+
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
64
65
|
|
|
65
66
|
|
|
66
67
|
def _log(msg: str) -> None:
|
|
@@ -72,33 +73,10 @@ def _is_available() -> bool:
|
|
|
72
73
|
return shutil.which(CLI_BIN) is not None
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
def _today_iso() -> str:
|
|
76
|
-
return datetime.now(timezone.utc).date().isoformat()
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def _ensure_synced() -> None:
|
|
80
|
-
"""Refresh the local headline cache at most once per TTL window.
|
|
81
|
-
|
|
82
|
-
Lock-guarded so concurrent fan-out threads do not double-sync. Best-effort:
|
|
83
|
-
a sync failure is logged and ignored (search can still run on an existing
|
|
84
|
-
cache), and the timestamp is stamped before the call so a failing sync does
|
|
85
|
-
not retry on every subquery within the window."""
|
|
86
|
-
global _LAST_SYNC
|
|
87
|
-
with _SYNC_LOCK:
|
|
88
|
-
if time.monotonic() - _LAST_SYNC < _SYNC_TTL:
|
|
89
|
-
return
|
|
90
|
-
_LAST_SYNC = time.monotonic() # stamp first: a failure does not retry
|
|
91
|
-
try:
|
|
92
|
-
subproc.run_with_timeout([CLI_BIN, "sync", "--agent"], timeout=SYNC_TIMEOUT)
|
|
93
|
-
_log("synced headline cache")
|
|
94
|
-
except (subproc.SubprocTimeout, FileNotFoundError, OSError) as exc:
|
|
95
|
-
_log(f"sync skipped: {exc}")
|
|
96
|
-
|
|
97
|
-
|
|
98
76
|
def _build_search_args(topic: str) -> List[str]:
|
|
99
77
|
# --json (not --agent) avoids --compact, which blanks headline records on
|
|
100
78
|
# pre-PR-1383 binaries. Techmeme's `search` has no result-limit flag, so the
|
|
101
|
-
# depth cap is applied client-side after
|
|
79
|
+
# depth cap is applied client-side after windowing.
|
|
102
80
|
return [CLI_BIN, "search", topic, "--json"]
|
|
103
81
|
|
|
104
82
|
|
|
@@ -114,6 +92,18 @@ def _coerce_list(data: Any) -> List[Dict[str, Any]]:
|
|
|
114
92
|
return []
|
|
115
93
|
|
|
116
94
|
|
|
95
|
+
def _record_iso_date(rec: Dict[str, Any]) -> str | None:
|
|
96
|
+
"""The record's ``date`` as a valid ISO YYYY-MM-DD string, else None.
|
|
97
|
+
|
|
98
|
+
Old binaries emit no ``date`` key; current binaries emit ``""`` when
|
|
99
|
+
Techmeme's markup was unparseable. Anything that isn't a clean ISO date is
|
|
100
|
+
treated as absent."""
|
|
101
|
+
value = rec.get("date")
|
|
102
|
+
if isinstance(value, str) and _ISO_DATE_RE.match(value.strip()):
|
|
103
|
+
return value.strip()
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
117
107
|
def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
|
|
118
108
|
"""Invoke techmeme-pp-cli and return ``{"results": [...records...]}``.
|
|
119
109
|
Never raises."""
|
|
@@ -140,6 +130,10 @@ def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
|
|
|
140
130
|
stdout = result.stdout or ""
|
|
141
131
|
if not stdout.strip():
|
|
142
132
|
return {"results": []}
|
|
133
|
+
# Old binaries print `No results for "q"` prose (exit 0) even in JSON
|
|
134
|
+
# mode: a legitimate zero-hit response, not a decode failure.
|
|
135
|
+
if stdout.strip().startswith(_NO_RESULTS_PREFIX):
|
|
136
|
+
return {"results": []}
|
|
143
137
|
try:
|
|
144
138
|
data = json.loads(stdout)
|
|
145
139
|
except json.JSONDecodeError as exc:
|
|
@@ -155,24 +149,55 @@ def search_techmeme(
|
|
|
155
149
|
to_date: str,
|
|
156
150
|
depth: str = "default",
|
|
157
151
|
) -> Dict[str, Any]:
|
|
158
|
-
"""Search Techmeme
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
152
|
+
"""Search Techmeme's live archive via techmeme-pp-cli.
|
|
153
|
+
|
|
154
|
+
Windows records to ``from_date..to_date`` on each record's own ISO date
|
|
155
|
+
(lexicographic compare is exact for ISO strings). Records with no usable
|
|
156
|
+
date are kept -- their recency is resolved downstream as low confidence.
|
|
157
|
+
There is deliberately no keep-all fallback when nothing is in-window:
|
|
158
|
+
Techmeme's archive reaches back decades, so zero in-window records means
|
|
159
|
+
zero results, not "serve stale news". Returns a dict with a ``results``
|
|
160
|
+
list of raw records; on failure ``results`` is empty.
|
|
162
161
|
"""
|
|
163
162
|
if not topic or not topic.strip():
|
|
164
163
|
return {"results": []}
|
|
165
164
|
if not _is_available():
|
|
166
165
|
return {"results": [], "error": f"{CLI_BIN} not on PATH"}
|
|
167
|
-
_ensure_synced()
|
|
168
166
|
limit = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
|
169
167
|
cmd = _build_search_args(topic)
|
|
170
168
|
_log(f"search '{topic}' (cap={limit})")
|
|
171
169
|
response = _run_cli(cmd, timeout=SEARCH_TIMEOUT)
|
|
172
|
-
# Techmeme returns all matches; apply the depth cap client-side.
|
|
173
170
|
records = response.get("results") or []
|
|
174
|
-
if isinstance(records, list)
|
|
175
|
-
|
|
171
|
+
if isinstance(records, list):
|
|
172
|
+
# Hard date window: drop records whose date falls outside the research
|
|
173
|
+
# range; keep undated records (old binaries / unparseable markup).
|
|
174
|
+
dated_in_window = []
|
|
175
|
+
undated = []
|
|
176
|
+
dropped = 0
|
|
177
|
+
for rec in records:
|
|
178
|
+
iso = _record_iso_date(rec)
|
|
179
|
+
if iso is None:
|
|
180
|
+
undated.append(rec)
|
|
181
|
+
elif from_date <= iso <= to_date:
|
|
182
|
+
dated_in_window.append(rec)
|
|
183
|
+
else:
|
|
184
|
+
dropped += 1
|
|
185
|
+
if dropped:
|
|
186
|
+
_log(f"dropped {dropped} records outside {from_date}..{to_date}")
|
|
187
|
+
if records and not dated_in_window and not dropped:
|
|
188
|
+
# Every record lacks a usable date: old techmeme-pp-cli (no date
|
|
189
|
+
# key) or a Techmeme markup change upstream. Windowing is inactive.
|
|
190
|
+
_log(
|
|
191
|
+
"no records carry usable dates; date windowing inactive "
|
|
192
|
+
"(old techmeme-pp-cli binary or upstream markup change; upgrade "
|
|
193
|
+
"via `npx -y @mvanhorn/printing-press-library install techmeme "
|
|
194
|
+
"--cli-only`)"
|
|
195
|
+
)
|
|
196
|
+
# Techmeme returns all matches; apply the depth cap after windowing.
|
|
197
|
+
# Dated in-window records take cap slots first so undated archive
|
|
198
|
+
# hits can never evict confirmed-fresh stories; undated records fill
|
|
199
|
+
# whatever slots remain.
|
|
200
|
+
response["results"] = (dated_in_window + undated)[:limit]
|
|
176
201
|
_log(f"found {len(response.get('results') or [])} records")
|
|
177
202
|
return response
|
|
178
203
|
|
|
@@ -195,16 +220,16 @@ def parse_techmeme_response(
|
|
|
195
220
|
) -> List[Dict[str, Any]]:
|
|
196
221
|
"""Parse a Techmeme search envelope into normalized item dicts.
|
|
197
222
|
|
|
198
|
-
Drops publication-name header rows and records missing a link.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
223
|
+
Drops publication-name header rows and records missing a link. Each item
|
|
224
|
+
carries the record's own ISO date, or None when the record has no usable
|
|
225
|
+
date (never today's date -- undated items get ``date_confidence: low``
|
|
226
|
+
downstream). Computes a token-overlap relevance hint. Returns dicts ready
|
|
227
|
+
for ``normalize._normalize_techmeme``.
|
|
202
228
|
"""
|
|
203
229
|
raw = response.get("results") if isinstance(response, dict) else None
|
|
204
230
|
if not isinstance(raw, list):
|
|
205
231
|
return []
|
|
206
232
|
|
|
207
|
-
today = _today_iso()
|
|
208
233
|
items: List[Dict[str, Any]] = []
|
|
209
234
|
for i, rec in enumerate(raw):
|
|
210
235
|
if not isinstance(rec, dict):
|
|
@@ -227,7 +252,7 @@ def parse_techmeme_response(
|
|
|
227
252
|
"title": headline,
|
|
228
253
|
"url": link,
|
|
229
254
|
"source_name": source_name,
|
|
230
|
-
"date":
|
|
255
|
+
"date": _record_iso_date(rec),
|
|
231
256
|
"engagement": {},
|
|
232
257
|
"relevance": round(relevance, 2),
|
|
233
258
|
"why_relevant": (
|