luckiest-co 1.0.52 → 1.0.53
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 +4 -0
- package/skills/ab-testing/SKILL.md +3 -3
- package/skills/ab-testing/references/sample-size-guide.md +15 -2
- package/skills/ad-creative/CHANGELOG.md +8 -0
- package/skills/ad-creative/SKILL.md +23 -13
- package/skills/ads/CHANGELOG.md +6 -0
- package/skills/ads/SKILL.md +14 -10
- package/skills/ads/evals/evals.json +13 -0
- package/skills/ads/references/operating-check.md +75 -0
- package/skills/ai-seo/CHANGELOG.md +11 -0
- package/skills/ai-seo/SKILL.md +28 -27
- package/skills/ai-seo/references/agent-defaults.md +79 -0
- package/skills/ai-seo/references/ai-attribution.md +54 -0
- package/skills/ai-seo/references/answer-accuracy.md +84 -0
- package/skills/ai-seo/references/content-patterns.md +24 -0
- package/skills/ai-seo/references/multilingual-and-commerce.md +55 -0
- package/skills/ai-seo/references/platform-ranking-factors.md +29 -11
- package/skills/ai-seo/references/prompt-portfolio.md +83 -0
- package/skills/analytics/CHANGELOG.md +8 -0
- package/skills/analytics/SKILL.md +74 -18
- package/skills/analytics/references/event-library.md +2 -2
- package/skills/churn-prevention/CHANGELOG.md +7 -0
- package/skills/churn-prevention/SKILL.md +16 -8
- package/skills/churn-prevention/references/dunning-playbook.md +10 -7
- package/skills/co-marketing/CHANGELOG.md +3 -0
- package/skills/co-marketing/SKILL.md +9 -9
- package/skills/coder/CHANGELOG.md +19 -0
- package/skills/coder/SKILL.md +4 -3
- package/skills/coder-writing-plans/CHANGELOG.md +5 -0
- package/skills/coder-writing-plans/SKILL.md +4 -3
- package/skills/content-strategy/CHANGELOG.md +3 -0
- package/skills/content-strategy/SKILL.md +3 -3
- package/skills/content-strategy/references/headless-cms.md +4 -4
- package/skills/copy-editing/CHANGELOG.md +4 -0
- package/skills/copy-editing/SKILL.md +5 -3
- package/skills/copy-editing/evals/evals.json +13 -0
- package/skills/copy-editing/references/checklist.md +24 -0
- package/skills/copywriting/CHANGELOG.md +6 -0
- package/skills/copywriting/SKILL.md +57 -4
- package/skills/copywriting/evals/evals.json +14 -0
- package/skills/emails/CHANGELOG.md +4 -0
- package/skills/emails/SKILL.md +5 -3
- package/skills/marketing-plan/CHANGELOG.md +4 -0
- package/skills/marketing-plan/SKILL.md +8 -3
- package/skills/offers/CHANGELOG.md +4 -0
- package/skills/offers/SKILL.md +4 -4
- package/skills/onboarding/CHANGELOG.md +4 -0
- package/skills/onboarding/SKILL.md +13 -7
- package/skills/pricing/CHANGELOG.md +4 -0
- package/skills/pricing/SKILL.md +3 -3
- package/skills/pricing/references/research-methods.md +12 -2
- package/skills/product-marketing/CHANGELOG.md +5 -0
- package/skills/product-marketing/SKILL.md +76 -11
- package/skills/product-marketing/references/brand-voice.md +103 -0
- package/skills/prospecting/CHANGELOG.md +3 -0
- package/skills/prospecting/SKILL.md +22 -22
- package/skills/prospecting/references/data-sources.md +0 -18
- package/skills/prospecting/references/saas-prospecting.md +2 -2
- package/skills/referrals/CHANGELOG.md +4 -0
- package/skills/referrals/SKILL.md +3 -3
- package/skills/referrals/references/program-examples.md +9 -2
- package/skills/research/CHANGELOG.md +5 -0
- package/skills/research/SKILL.md +5 -5
- package/skills/research/references/visual-read.md +10 -4
- package/skills/revops/CHANGELOG.md +4 -0
- package/skills/revops/SKILL.md +6 -6
- package/skills/revops/references/scoring-models.md +14 -13
- package/skills/seo-audit/CHANGELOG.md +4 -0
- package/skills/seo-audit/SKILL.md +7 -7
- package/skills/seo-audit/references/international-seo.md +3 -1
- package/skills/social/CHANGELOG.md +4 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/social/references/listening.md +13 -0
- package/skills/social/references/platforms.md +35 -0
- package/skills/social/references/short-form-video.md +10 -0
- package/skills/trends/CHANGELOG.md +7 -0
- package/skills/trends/SKILL.md +5 -5
- package/skills/trends/agents/openai.yaml +8 -0
- package/skills/trends/references/comparison.md +5 -2
- package/skills/trends/references/engine-contract.md +2 -2
- package/skills/trends/references/synthesis.md +2 -2
- package/skills/trends/scripts/build-skill.sh +39 -0
- package/skills/trends/scripts/compare.sh +61 -0
- package/skills/trends/scripts/evaluate_search_quality.py +584 -0
- package/skills/trends/scripts/lib/arxiv.py +10 -1
- package/skills/trends/scripts/lib/bluesky.py +22 -2
- package/skills/trends/scripts/lib/devto.py +108 -18
- package/skills/trends/scripts/lib/digg.py +79 -17
- package/skills/trends/scripts/lib/github.py +9 -4
- package/skills/trends/scripts/lib/hackernews.py +74 -2
- package/skills/trends/scripts/lib/html_render.py +15 -1
- package/skills/trends/scripts/lib/pipeline.py +1 -1
- package/skills/trends/scripts/lib/planner.py +5 -0
- package/skills/trends/scripts/lib/providers.py +1 -1
- package/skills/trends/scripts/lib/reddit_shreddit.py +4 -1
- package/skills/trends/scripts/lib/render.py +49 -45
- package/skills/trends/scripts/lib/youtube_yt.py +10 -7
- package/skills/trends/scripts/test-v1-vs-v2.sh +220 -0
- package/skills/trends/scripts/test_device_auth.py +128 -0
- package/skills/trends/scripts/verify_v3.py +200 -0
- package/skills/video/CHANGELOG.md +1 -1
|
@@ -4,16 +4,21 @@ Uses the public Forem API at dev.to (no key):
|
|
|
4
4
|
|
|
5
5
|
- ``/api/articles?tag=<tag>&top=<days>`` returns the most-engaged articles for
|
|
6
6
|
a tag in the window. Precise, but only as good as the tag.
|
|
7
|
-
- ``/api/articles/search?q=<term>&top=<days>`` is free-text search sorted
|
|
8
|
-
recency. Single distinctive terms match well; multi-word queries
|
|
9
|
-
loosely, so every search hit must pass the engine's token-overlap
|
|
10
|
-
check before it is kept.
|
|
7
|
+
- ``/api/articles/search?q=<term>&top=<days>`` is free-text search sorted
|
|
8
|
+
roughly by recency. Single distinctive terms match well; multi-word queries
|
|
9
|
+
match loosely, so every search hit must pass the engine's token-overlap
|
|
10
|
+
relevance check before it is kept.
|
|
11
11
|
- ``/api/comments?a_id=<id>`` returns the comment tree for an article.
|
|
12
12
|
|
|
13
13
|
The source is off by default. Host-inferred tags (``--devto-tags``) turn it
|
|
14
14
|
on for a run; ``INCLUDE_SOURCES=devto`` or ``--search devto`` turn it on
|
|
15
15
|
without tags, in which case only the keyword search runs. An unknown tag
|
|
16
16
|
returns ``[]`` with HTTP 200, so a wrong tag stays quiet instead of failing.
|
|
17
|
+
|
|
18
|
+
Both endpoints count ``top`` back from now and accept no end date, sort, or
|
|
19
|
+
date-range parameter. A window that ends before today therefore pages
|
|
20
|
+
through everything published since its start and filters locally, within
|
|
21
|
+
``HISTORICAL_MAX_PAGES`` and ``HISTORICAL_BUDGET_SECONDS``.
|
|
17
22
|
"""
|
|
18
23
|
|
|
19
24
|
from __future__ import annotations
|
|
@@ -48,6 +53,11 @@ SEARCH_RELEVANCE_FLOOR = 0.25
|
|
|
48
53
|
# Total wall-clock cap per request. The socket timeout alone bounds each
|
|
49
54
|
# read, so a response trickling in small chunks could otherwise run on.
|
|
50
55
|
REQUEST_DEADLINE_SECONDS = 20.0
|
|
56
|
+
# Forem's per_page ceiling. A busy term ("kubernetes") has ~4,000 search hits
|
|
57
|
+
# over 78 days, about 2 MB and 4-5 s per full page.
|
|
58
|
+
HISTORICAL_PER_PAGE = 1000
|
|
59
|
+
HISTORICAL_MAX_PAGES = 10
|
|
60
|
+
HISTORICAL_BUDGET_SECONDS = 45.0
|
|
51
61
|
ENRICH_WAIT_SECONDS = 25.0
|
|
52
62
|
|
|
53
63
|
_TAG_RE = re.compile(r"[^a-z0-9]")
|
|
@@ -76,11 +86,22 @@ def resolve_tags(tags: list[str] | None, depth: str = "default") -> list[str]:
|
|
|
76
86
|
return resolved[: TAG_LIMITS.get(depth, TAG_LIMITS["default"])]
|
|
77
87
|
|
|
78
88
|
|
|
89
|
+
def _today() -> datetime.date:
|
|
90
|
+
return datetime.datetime.now(datetime.timezone.utc).date()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_historical(to_date: str | None) -> bool:
|
|
94
|
+
return bool(to_date) and str(to_date) < _today().isoformat()
|
|
95
|
+
|
|
96
|
+
|
|
79
97
|
def _window_days(from_date: str | None, to_date: str | None) -> int:
|
|
80
98
|
try:
|
|
81
99
|
start = datetime.date.fromisoformat(from_date or "")
|
|
82
|
-
|
|
83
|
-
|
|
100
|
+
# Forem counts top=N back from now, including for historical windows,
|
|
101
|
+
# so the reach always runs to today and _in_window enforces to_date.
|
|
102
|
+
# The extra day keeps all of from_date: a now-anchored cutoff of
|
|
103
|
+
# exactly N days lands partway through it.
|
|
104
|
+
return max(1, (_today() - start).days + 1)
|
|
84
105
|
except ValueError:
|
|
85
106
|
return 30
|
|
86
107
|
|
|
@@ -98,7 +119,9 @@ def clear_cache() -> None:
|
|
|
98
119
|
_URL_LOCKS.clear()
|
|
99
120
|
|
|
100
121
|
|
|
101
|
-
def _get_list(
|
|
122
|
+
def _get_list(
|
|
123
|
+
url: str, headers: dict[str, str] | None = None, deadline: float | None = None,
|
|
124
|
+
) -> list[dict[str, Any]]:
|
|
102
125
|
# Concurrent subquery streams can request the same URL; the per-URL lock
|
|
103
126
|
# makes the second caller wait for the first fetch instead of repeating it.
|
|
104
127
|
with _LOCKS_GUARD:
|
|
@@ -107,7 +130,10 @@ def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str,
|
|
|
107
130
|
if url not in _RESPONSE_CACHE:
|
|
108
131
|
data = http.request(
|
|
109
132
|
"GET", url, headers=headers, timeout=15, retries=2,
|
|
110
|
-
deadline_monotonic=
|
|
133
|
+
deadline_monotonic=min(
|
|
134
|
+
time.monotonic() + REQUEST_DEADLINE_SECONDS,
|
|
135
|
+
deadline if deadline is not None else float("inf"),
|
|
136
|
+
),
|
|
111
137
|
owned_get=True,
|
|
112
138
|
)
|
|
113
139
|
if not isinstance(data, list):
|
|
@@ -118,8 +144,58 @@ def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str,
|
|
|
118
144
|
return [dict(row) for row in _RESPONSE_CACHE[url]]
|
|
119
145
|
|
|
120
146
|
|
|
147
|
+
def _published(article: dict[str, Any]) -> str:
|
|
148
|
+
return str(article.get("published_at") or "")[:10]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fetch_rows(
|
|
152
|
+
base_url: str,
|
|
153
|
+
params: dict[str, Any],
|
|
154
|
+
to_date: str | None,
|
|
155
|
+
*,
|
|
156
|
+
label: str,
|
|
157
|
+
deadline: float,
|
|
158
|
+
headers: dict[str, str] | None = None,
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""Fetch one page for a current window, or page through a past one.
|
|
161
|
+
|
|
162
|
+
Every page after the first stops at ``deadline``, which the caller shares
|
|
163
|
+
across all endpoints so a deep run's tags cannot each spend the budget.
|
|
164
|
+
"""
|
|
165
|
+
if not _is_historical(to_date):
|
|
166
|
+
return _get_list(f"{base_url}?{urllib.parse.urlencode(params)}", headers=headers)
|
|
167
|
+
rows: list[dict[str, Any]] = []
|
|
168
|
+
for page in range(1, HISTORICAL_MAX_PAGES + 1):
|
|
169
|
+
if page > 1 and time.monotonic() >= deadline:
|
|
170
|
+
_log(f"{label}: stopped after {page - 1} pages at the time budget; older in-window articles may be missing")
|
|
171
|
+
return rows
|
|
172
|
+
page_params = {**params, "per_page": HISTORICAL_PER_PAGE, "page": page}
|
|
173
|
+
url = f"{base_url}?{urllib.parse.urlencode(page_params)}"
|
|
174
|
+
try:
|
|
175
|
+
batch = _get_list(url, headers=headers, deadline=deadline if page > 1 else None)
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
if page == 1:
|
|
178
|
+
raise
|
|
179
|
+
_log(f"{label}: page {page} failed ({exc}); keeping the {page - 1} pages fetched")
|
|
180
|
+
return rows
|
|
181
|
+
rows.extend(batch)
|
|
182
|
+
# top=N bounds the result set at from_date, so a short page is the end.
|
|
183
|
+
if len(batch) < HISTORICAL_PER_PAGE:
|
|
184
|
+
return rows
|
|
185
|
+
_log(f"{label}: stopped at {HISTORICAL_MAX_PAGES} pages; older in-window articles may be missing")
|
|
186
|
+
return rows
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _top_engaged(rows: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
|
|
190
|
+
return sorted(
|
|
191
|
+
rows,
|
|
192
|
+
key=lambda row: int(row.get("public_reactions_count") or row.get("positive_reactions_count") or 0),
|
|
193
|
+
reverse=True,
|
|
194
|
+
)[:limit]
|
|
195
|
+
|
|
196
|
+
|
|
121
197
|
def _in_window(article: dict[str, Any], from_date: str | None, to_date: str | None) -> bool:
|
|
122
|
-
published =
|
|
198
|
+
published = _published(article)
|
|
123
199
|
if not published:
|
|
124
200
|
return True
|
|
125
201
|
if from_date and published < from_date:
|
|
@@ -151,24 +227,28 @@ def search_devto(
|
|
|
151
227
|
endpoint is recorded in ``errors`` and the other endpoint still counts.
|
|
152
228
|
"""
|
|
153
229
|
days = _window_days(from_date, to_date)
|
|
230
|
+
historical = _is_historical(to_date)
|
|
231
|
+
paging_deadline = time.monotonic() + HISTORICAL_BUDGET_SECONDS
|
|
154
232
|
resolved_tags = resolve_tags(tags, depth)
|
|
155
233
|
articles: dict[int, dict[str, Any]] = {}
|
|
156
234
|
errors: list[str] = []
|
|
157
235
|
|
|
158
236
|
for tag in resolved_tags:
|
|
159
|
-
|
|
237
|
+
params = {"tag": tag, "top": days, "per_page": TAG_PER_PAGE}
|
|
160
238
|
try:
|
|
161
|
-
rows =
|
|
239
|
+
rows = _fetch_rows(ARTICLES_URL, params, to_date, label=f"tag '{tag}'", deadline=paging_deadline)
|
|
162
240
|
except Exception as exc:
|
|
163
241
|
errors.append(f"tag {tag}: {exc}")
|
|
164
242
|
_log(f"tag feed failed for '{tag}': {exc}")
|
|
165
243
|
continue
|
|
244
|
+
rows = [row for row in rows if row.get("id") is not None and _in_window(row, from_date, to_date)]
|
|
245
|
+
if historical:
|
|
246
|
+
rows = _top_engaged(rows, TAG_PER_PAGE)
|
|
166
247
|
kept = 0
|
|
167
248
|
for row in rows:
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
kept += 1
|
|
249
|
+
row.setdefault("_devto_match", f"tag:{tag}")
|
|
250
|
+
articles.setdefault(row["id"], row)
|
|
251
|
+
kept += 1
|
|
172
252
|
_log(f"tag '{tag}': {kept} articles in window")
|
|
173
253
|
|
|
174
254
|
# Planner queries quote phrases for engines with phrase syntax; Forem
|
|
@@ -176,21 +256,31 @@ def search_devto(
|
|
|
176
256
|
query = (search_query or topic or "").replace('"', " ").strip()
|
|
177
257
|
query = " ".join(query.split())
|
|
178
258
|
if query:
|
|
179
|
-
|
|
180
|
-
|
|
259
|
+
per_page = SEARCH_PER_PAGE.get(depth, SEARCH_PER_PAGE["default"])
|
|
260
|
+
params = {"q": query, "top": days, "per_page": per_page}
|
|
181
261
|
try:
|
|
182
|
-
rows =
|
|
262
|
+
rows = _fetch_rows(
|
|
263
|
+
SEARCH_URL, params, to_date, label=f"search '{query}'",
|
|
264
|
+
deadline=paging_deadline, headers=_SEARCH_HEADERS,
|
|
265
|
+
)
|
|
183
266
|
except Exception as exc:
|
|
184
267
|
errors.append(f"search: {exc}")
|
|
185
268
|
_log(f"search failed for '{query}': {exc}")
|
|
186
269
|
rows = []
|
|
187
270
|
kept = dropped = 0
|
|
271
|
+
relevant = []
|
|
188
272
|
for row in rows:
|
|
189
273
|
if row.get("id") is None or not _in_window(row, from_date, to_date):
|
|
190
274
|
continue
|
|
191
275
|
if token_overlap_relevance(topic or query, _article_text(row)) < SEARCH_RELEVANCE_FLOOR:
|
|
192
276
|
dropped += 1
|
|
193
277
|
continue
|
|
278
|
+
relevant.append(row)
|
|
279
|
+
if historical:
|
|
280
|
+
# A current window keeps the newest page; a past one keeps the
|
|
281
|
+
# most-engaged in-window hits so the item count stays comparable.
|
|
282
|
+
relevant = _top_engaged(relevant, per_page)
|
|
283
|
+
for row in relevant:
|
|
194
284
|
row.setdefault("_devto_match", "search")
|
|
195
285
|
articles.setdefault(row["id"], row)
|
|
196
286
|
kept += 1
|
|
@@ -19,9 +19,10 @@ top-ranked X posts to each cluster's ``posts`` field.
|
|
|
19
19
|
from __future__ import annotations
|
|
20
20
|
|
|
21
21
|
import json
|
|
22
|
+
import re
|
|
22
23
|
import shutil
|
|
23
24
|
from datetime import datetime, timedelta, timezone
|
|
24
|
-
from typing import Any, Dict, List, Optional
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
26
|
from urllib.parse import urlparse
|
|
26
27
|
|
|
27
28
|
from . import log, subproc
|
|
@@ -133,47 +134,104 @@ def _build_posts_args(cluster_url_id: str, posts_per: int) -> List[str]:
|
|
|
133
134
|
]
|
|
134
135
|
|
|
135
136
|
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
_LOCAL_FALLBACK_MARKER = "falling back to local FTS5:"
|
|
138
|
+
_FALLBACK_REASON_MAX = 200
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _local_fallback_reason(stderr: str) -> str:
|
|
142
|
+
"""Extract the live-search failure from digg-pp-cli's fallback stderr.
|
|
138
143
|
|
|
139
|
-
|
|
140
|
-
|
|
144
|
+
The CLI prints ``falling back to local FTS5: <error>`` and the error can
|
|
145
|
+
embed the full HTML body of a failed response, so the reason is cut at
|
|
146
|
+
the HTTP status when present and length-bounded otherwise.
|
|
147
|
+
"""
|
|
148
|
+
for line in (stderr or "").splitlines():
|
|
149
|
+
_, marker, rest = line.partition(_LOCAL_FALLBACK_MARKER)
|
|
150
|
+
if not marker:
|
|
151
|
+
continue
|
|
152
|
+
rest = rest.strip()
|
|
153
|
+
status = re.match(r".*?\bHTTP \d{3}\b", rest)
|
|
154
|
+
if status:
|
|
155
|
+
return status.group(0)
|
|
156
|
+
return rest[:_FALLBACK_REASON_MAX].rstrip()
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _empty_local_fallback_error(data: Dict[str, Any], stderr: str = "") -> Optional[str]:
|
|
161
|
+
"""Return an error when the CLI answered from an empty local store.
|
|
162
|
+
|
|
163
|
+
``digg-pp-cli search`` is live by default, but it falls back to its FTS5
|
|
164
|
+
store on a network error and exits 0 with ``meta.source == "local"``.
|
|
165
|
+
That store only holds rows for users who ran ``digg-pp-cli sync``
|
|
166
|
+
themselves, which last30days never does - so a broken live search
|
|
167
|
+
reaches the user as ``Digg: 0 items (no results)`` instead of an error.
|
|
168
|
+
A non-empty local store is real data and is left alone.
|
|
169
|
+
|
|
170
|
+
The CLI reports the live failure only on stderr; ``meta.reason`` is
|
|
171
|
+
preferred if a future CLI release adds it to the envelope.
|
|
172
|
+
"""
|
|
173
|
+
meta = data.get("meta")
|
|
174
|
+
if not isinstance(meta, dict) or meta.get("source") != "local":
|
|
175
|
+
return None
|
|
176
|
+
if data.get("results"):
|
|
177
|
+
return None
|
|
178
|
+
reason = str(meta.get("reason") or "").strip()[:_FALLBACK_REASON_MAX]
|
|
179
|
+
detail = (
|
|
180
|
+
reason
|
|
181
|
+
or _local_fallback_reason(stderr)
|
|
182
|
+
or "live search fell back to an empty local store"
|
|
183
|
+
)
|
|
184
|
+
return f"live-search-unavailable: {detail}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _invoke_cli(cmd: List[str], timeout: int) -> Tuple[Dict[str, Any], str]:
|
|
188
|
+
"""Invoke digg-pp-cli and return the parsed envelope with its stderr.
|
|
189
|
+
|
|
190
|
+
The envelope is ``{"results": [...]}`` on success and
|
|
191
|
+
``{"results": [], "error": "..."}`` on failure. Never raises; the
|
|
192
|
+
pipeline relies on shape consistency.
|
|
141
193
|
"""
|
|
142
194
|
if not _is_available():
|
|
143
|
-
return {"results": [], "error": f"{CLI_BIN} not on PATH"}
|
|
195
|
+
return {"results": [], "error": f"{CLI_BIN} not on PATH"}, ""
|
|
144
196
|
try:
|
|
145
197
|
result = subproc.run_with_timeout(cmd, timeout=timeout)
|
|
146
198
|
except subproc.SubprocTimeout as exc:
|
|
147
199
|
_log(f"Timeout: {exc}")
|
|
148
|
-
return {"results": [], "error": str(exc)}
|
|
200
|
+
return {"results": [], "error": str(exc)}, ""
|
|
149
201
|
except FileNotFoundError as exc:
|
|
150
202
|
_log(f"Binary missing: {exc}")
|
|
151
|
-
return {"results": [], "error": str(exc)}
|
|
203
|
+
return {"results": [], "error": str(exc)}, ""
|
|
152
204
|
except OSError as exc:
|
|
153
205
|
_log(f"Spawn failed: {exc}")
|
|
154
|
-
return {"results": [], "error": str(exc)}
|
|
206
|
+
return {"results": [], "error": str(exc)}, ""
|
|
155
207
|
|
|
208
|
+
stderr = result.stderr or ""
|
|
156
209
|
if result.returncode != 0:
|
|
157
|
-
snippet =
|
|
210
|
+
snippet = stderr.strip().splitlines()[:1]
|
|
158
211
|
first = snippet[0] if snippet else f"exit {result.returncode}"
|
|
159
212
|
_log(f"CLI exit {result.returncode}: {first}")
|
|
160
|
-
return {"results": [], "error": first}
|
|
213
|
+
return {"results": [], "error": first}, stderr
|
|
161
214
|
|
|
162
215
|
stdout = result.stdout or ""
|
|
163
216
|
if not stdout.strip():
|
|
164
|
-
return {"results": []}
|
|
217
|
+
return {"results": []}, stderr
|
|
165
218
|
try:
|
|
166
219
|
data = json.loads(stdout)
|
|
167
220
|
except json.JSONDecodeError as exc:
|
|
168
221
|
_log(f"JSON decode failed: {exc}")
|
|
169
|
-
return {"results": [], "error": f"json decode: {exc}"}
|
|
222
|
+
return {"results": [], "error": f"json decode: {exc}"}, stderr
|
|
170
223
|
|
|
171
224
|
if not isinstance(data, dict):
|
|
172
|
-
return {"results": []}
|
|
225
|
+
return {"results": []}, stderr
|
|
173
226
|
results = data.get("results")
|
|
174
227
|
if not isinstance(results, list):
|
|
175
|
-
return {"results": []}
|
|
176
|
-
return data
|
|
228
|
+
return {"results": []}, stderr
|
|
229
|
+
return data, stderr
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
|
|
233
|
+
"""Invoke digg-pp-cli and return only the parsed envelope."""
|
|
234
|
+
return _invoke_cli(cmd, timeout)[0]
|
|
177
235
|
|
|
178
236
|
|
|
179
237
|
def search_digg(
|
|
@@ -199,7 +257,11 @@ def search_digg(
|
|
|
199
257
|
return {"results": []}
|
|
200
258
|
cmd = _build_search_args(topic, limit)
|
|
201
259
|
_log(f"search '{topic}' (limit={limit}, since=30d)")
|
|
202
|
-
response =
|
|
260
|
+
response, stderr = _invoke_cli(cmd, timeout=SEARCH_TIMEOUT)
|
|
261
|
+
fallback_error = _empty_local_fallback_error(response, stderr)
|
|
262
|
+
if fallback_error:
|
|
263
|
+
_log(fallback_error)
|
|
264
|
+
return {"results": [], "error": fallback_error}
|
|
203
265
|
n = len(response.get("results") or [])
|
|
204
266
|
_log(f"found {n} clusters")
|
|
205
267
|
return response
|
|
@@ -179,7 +179,7 @@ def _compute_relevance(
|
|
|
179
179
|
# the qualifier so no stray `()`/`""` residue reaches the query (issue #952).
|
|
180
180
|
# The plain regex's boundary also accepts wrapper openers so a qualifier with
|
|
181
181
|
# a missing closer ("(created:>2025-03-20") is still stripped rather than
|
|
182
|
-
# leaking into the query; only
|
|
182
|
+
# leaking into the query; qualifier-only wrapper residue is discarded below.
|
|
183
183
|
QUALIFIER_KEYS = frozenset({
|
|
184
184
|
"archived", "assignee", "author", "base", "closed", "comments", "commenter",
|
|
185
185
|
"created", "fork", "forks", "head", "in", "interactions", "involves", "is",
|
|
@@ -220,6 +220,11 @@ def strip_search_qualifiers(text: str) -> str:
|
|
|
220
220
|
"""
|
|
221
221
|
stripped = _WRAPPED_QUALIFIER_RE.sub(" ", text)
|
|
222
222
|
stripped = _QUALIFIER_RE.sub(" ", stripped)
|
|
223
|
+
# Malformed/nested wrappers can leave only punctuation after a qualifier
|
|
224
|
+
# is removed (#969). There is no subject to search in that case. Keep
|
|
225
|
+
# wrappers around actual topic text, including unbalanced ones like "ai (foo".
|
|
226
|
+
if stripped != text and re.fullmatch(r"[\s()\[\]\"']*", stripped):
|
|
227
|
+
return ""
|
|
223
228
|
while True:
|
|
224
229
|
cleaned = _EMPTY_WRAPPER_RE.sub("", stripped)
|
|
225
230
|
if cleaned == stripped:
|
|
@@ -750,8 +755,8 @@ def search_github_person(
|
|
|
750
755
|
_log(f"Person-mode search for @{username} (since {from_date})")
|
|
751
756
|
|
|
752
757
|
# Phase 1: PR velocity via search API
|
|
753
|
-
total_q = urllib.parse.quote(f"author:{username} type:pr created
|
|
754
|
-
merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created
|
|
758
|
+
total_q = urllib.parse.quote(f"author:{username} type:pr created:{from_date}..{to_date}")
|
|
759
|
+
merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:{from_date}..{to_date}")
|
|
755
760
|
|
|
756
761
|
total_url = f"{SEARCH_URL}?q={total_q}&per_page=1"
|
|
757
762
|
merged_url = f"{SEARCH_URL}?q={merged_q}&sort=reactions&order=desc&per_page=100"
|
|
@@ -823,7 +828,7 @@ def search_github_person(
|
|
|
823
828
|
num_repos = len(repo_pr_counts)
|
|
824
829
|
velocity_text = (
|
|
825
830
|
f"GitHub Person Profile: @{username}\n\n"
|
|
826
|
-
f"CONTRIBUTION VELOCITY (last {(to_date
|
|
831
|
+
f"CONTRIBUTION VELOCITY (last {dates.days_ago(from_date, to_date)} days)\n"
|
|
827
832
|
f"- {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)\n"
|
|
828
833
|
f"- {total_prs} total PRs submitted, {open_prs} still open\n"
|
|
829
834
|
)
|
|
@@ -10,6 +10,7 @@ import math
|
|
|
10
10
|
import sys
|
|
11
11
|
import time
|
|
12
12
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
13
|
+
from http.client import HTTPException
|
|
13
14
|
from typing import Any, Dict, List, Optional
|
|
14
15
|
|
|
15
16
|
import re
|
|
@@ -24,6 +25,9 @@ _HN_PREFIXES = re.compile(r"^(Tell HN|Show HN|Ask HN|Launch HN)\s*:\s*", re.IGNO
|
|
|
24
25
|
ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
|
|
25
26
|
ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
|
|
26
27
|
ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
|
|
28
|
+
# Official HN Firebase item API — `kids` is in the order the thread displays
|
|
29
|
+
# top-level comments (Algolia's /items children do not carry useful points).
|
|
30
|
+
FIREBASE_ITEM_URL = "https://hacker-news.firebaseio.com/v0/item"
|
|
27
31
|
|
|
28
32
|
DEPTH_CONFIG = {
|
|
29
33
|
"quick": 15,
|
|
@@ -309,6 +313,68 @@ def _hn_item_url(item_id: Any) -> str:
|
|
|
309
313
|
return f"https://news.ycombinator.com/item?id={item_id}"
|
|
310
314
|
|
|
311
315
|
|
|
316
|
+
# Ordering is optional, so a slow Firebase must not hold up the report.
|
|
317
|
+
FIREBASE_KIDS_TIMEOUT = 5
|
|
318
|
+
FIREBASE_KIDS_RETRIES = 2
|
|
319
|
+
FIREBASE_KIDS_BUDGET = 8.0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _fetch_hn_kids(object_id: str) -> Optional[List[Any]]:
|
|
323
|
+
"""Return HN's display order for a story's top-level comments, or None on failure.
|
|
324
|
+
|
|
325
|
+
Algolia's `/items/<id>` children list is effectively id-order with `points`
|
|
326
|
+
always null, so sorting by points is a no-op (#1285). The Firebase item
|
|
327
|
+
endpoint returns `kids` in the same order the HN thread UI shows.
|
|
328
|
+
"""
|
|
329
|
+
url = f"{FIREBASE_ITEM_URL}/{object_id}.json"
|
|
330
|
+
# Comment order is a refinement over comments Algolia already returned, so a
|
|
331
|
+
# Firebase failure must not reach the pipeline's sink and mark HN degraded.
|
|
332
|
+
try:
|
|
333
|
+
with http.capture_failures():
|
|
334
|
+
data = http.request(
|
|
335
|
+
"GET",
|
|
336
|
+
url,
|
|
337
|
+
timeout=FIREBASE_KIDS_TIMEOUT,
|
|
338
|
+
retries=FIREBASE_KIDS_RETRIES,
|
|
339
|
+
deadline_monotonic=time.monotonic() + FIREBASE_KIDS_BUDGET,
|
|
340
|
+
)
|
|
341
|
+
# http.request wraps HTTP and socket errors, but a truncated or non-UTF-8
|
|
342
|
+
# body escapes as IncompleteRead or UnicodeDecodeError.
|
|
343
|
+
except (http.HTTPError, HTTPException, UnicodeDecodeError) as e:
|
|
344
|
+
_log(f"Failed to fetch HN kids for {object_id}: {e}")
|
|
345
|
+
return None
|
|
346
|
+
if not isinstance(data, dict):
|
|
347
|
+
return None
|
|
348
|
+
kids = data.get("kids")
|
|
349
|
+
if not isinstance(kids, list):
|
|
350
|
+
return None
|
|
351
|
+
return kids
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _order_comments_by_kids(
|
|
355
|
+
comments: List[Dict[str, Any]],
|
|
356
|
+
kids: List[Any],
|
|
357
|
+
) -> List[Dict[str, Any]]:
|
|
358
|
+
"""Reorder Algolia children to match HN `kids` order; unknowns keep relative order."""
|
|
359
|
+
by_id: Dict[Any, Dict[str, Any]] = {}
|
|
360
|
+
for comment in comments:
|
|
361
|
+
cid = comment.get("id")
|
|
362
|
+
if cid is not None and cid not in by_id:
|
|
363
|
+
by_id[cid] = comment
|
|
364
|
+
ordered: List[Dict[str, Any]] = []
|
|
365
|
+
seen = set()
|
|
366
|
+
for kid in kids:
|
|
367
|
+
comment = by_id.get(kid)
|
|
368
|
+
if comment is not None:
|
|
369
|
+
ordered.append(comment)
|
|
370
|
+
seen.add(kid)
|
|
371
|
+
for comment in comments:
|
|
372
|
+
cid = comment.get("id")
|
|
373
|
+
if cid not in seen:
|
|
374
|
+
ordered.append(comment)
|
|
375
|
+
return ordered
|
|
376
|
+
|
|
377
|
+
|
|
312
378
|
def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any]:
|
|
313
379
|
"""Fetch top-level comments for a story from Algolia items endpoint.
|
|
314
380
|
|
|
@@ -329,12 +395,18 @@ def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any
|
|
|
329
395
|
|
|
330
396
|
children = data.get("children", [])
|
|
331
397
|
|
|
332
|
-
#
|
|
398
|
+
# Filter to actual comments. Prefer HN's Firebase `kids` order (#1285);
|
|
399
|
+
# Algolia children have null points, so a points sort is a no-op and leaves
|
|
400
|
+
# earliest-posted replies first. Fall back to points only when kids is unavailable.
|
|
333
401
|
real_comments = [
|
|
334
402
|
c for c in children
|
|
335
403
|
if c.get("text") and c.get("author")
|
|
336
404
|
]
|
|
337
|
-
|
|
405
|
+
kids = _fetch_hn_kids(object_id)
|
|
406
|
+
if kids is not None:
|
|
407
|
+
real_comments = _order_comments_by_kids(real_comments, kids)
|
|
408
|
+
else:
|
|
409
|
+
real_comments.sort(key=lambda c: c.get("points") or 0, reverse=True)
|
|
338
410
|
|
|
339
411
|
comments = []
|
|
340
412
|
insights = []
|
|
@@ -20,6 +20,18 @@ PROSE_LABELS = [
|
|
|
20
20
|
INVITATION_PATTERN = re.compile(r"^---\nI'm now an expert.*?Just ask\.$", re.MULTILINE | re.DOTALL)
|
|
21
21
|
EVIDENCE_BLOCK_PATTERN = re.compile(r"<!-- EVIDENCE FOR SYNTHESIS.*?<!-- END EVIDENCE FOR SYNTHESIS -->", re.DOTALL)
|
|
22
22
|
PASS_THROUGH_FOOTER_PATTERN = re.compile(r"<!-- PASS-THROUGH FOOTER.*?-->\n(.*?)<!-- END PASS-THROUGH FOOTER -->", re.DOTALL)
|
|
23
|
+
# Line-by-line on purpose: no row inside the match may itself open or close a
|
|
24
|
+
# fence, so an earlier ordinary ```text block cannot be stitched to the real
|
|
25
|
+
# footer with the prose between them swallowed into the footer box.
|
|
26
|
+
_FOOTER_ROWS = r"(?:(?!```)[^\n]*\n)*?"
|
|
27
|
+
FENCED_ENGINE_FOOTER_PATTERN = re.compile(
|
|
28
|
+
r"^```text\n(---\n"
|
|
29
|
+
+ _FOOTER_ROWS
|
|
30
|
+
+ r"[^\n]*All agents reported back![^\n]*\n"
|
|
31
|
+
+ _FOOTER_ROWS
|
|
32
|
+
+ r"---)\n```$",
|
|
33
|
+
re.MULTILINE,
|
|
34
|
+
)
|
|
23
35
|
CANONICAL_BOUNDARY_PATTERN = re.compile(r"\n?---\n# END OF last30days CANONICAL OUTPUT.*$", re.DOTALL)
|
|
24
36
|
# render_for_html emits metadata as <!-- META: ... --> so it survives the
|
|
25
37
|
# markdown converter (which escapes raw HTML inside paragraphs). Promoted to
|
|
@@ -713,7 +725,9 @@ def _protect_engine_footers(md: str) -> tuple[str, dict[str, str]]:
|
|
|
713
725
|
footers[token] = match.group(1).strip("\n")
|
|
714
726
|
return f"\n{token}\n"
|
|
715
727
|
|
|
716
|
-
|
|
728
|
+
md = PASS_THROUGH_FOOTER_PATTERN.sub(replace, md)
|
|
729
|
+
md = FENCED_ENGINE_FOOTER_PATTERN.sub(replace, md)
|
|
730
|
+
return md, footers
|
|
717
731
|
|
|
718
732
|
|
|
719
733
|
def _wrap_engine_footer(body: str) -> str:
|
|
@@ -6116,7 +6116,7 @@ def _retrieve_stream_impl(
|
|
|
6116
6116
|
# search_query, so off-topic narrowing does not let weak matches through.
|
|
6117
6117
|
relevance_topic = raw_topic or topic or subquery.search_query
|
|
6118
6118
|
return (
|
|
6119
|
-
arxiv.parse_arxiv_response(result, query=relevance_topic),
|
|
6119
|
+
arxiv.parse_arxiv_response(result, query=relevance_topic, to_date=to_date),
|
|
6120
6120
|
_result_outcome_artifact(source, result),
|
|
6121
6121
|
)
|
|
6122
6122
|
if source == "techmeme":
|
|
@@ -621,6 +621,11 @@ def _trim_subqueries_for_depth(
|
|
|
621
621
|
# Operator-supplied --plan is a contract: keep per-subquery sources.
|
|
622
622
|
if depth != "quick" and not honor_plan_sources:
|
|
623
623
|
expanded_sources = _default_sources_for_intent(intent, available_sources)
|
|
624
|
+
# Explicit --search sources are a user override: intent routing must not drop them.
|
|
625
|
+
expanded_sources += [
|
|
626
|
+
source for source in available_sources
|
|
627
|
+
if source in (requested_sources or []) and source not in expanded_sources
|
|
628
|
+
]
|
|
624
629
|
return [
|
|
625
630
|
schema.SubQuery(
|
|
626
631
|
label=subquery.label,
|
|
@@ -15,7 +15,7 @@ from . import env, http, schema
|
|
|
15
15
|
GEMINI_FLASH_LITE = "gemini-3.1-flash-lite"
|
|
16
16
|
GEMINI_PRO = "gemini-3.1-pro-preview"
|
|
17
17
|
OPENAI_DEFAULT = "gpt-5.4-nano"
|
|
18
|
-
XAI_DEFAULT = "grok-4-
|
|
18
|
+
XAI_DEFAULT = "grok-4.20-0309-non-reasoning"
|
|
19
19
|
|
|
20
20
|
GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
|
|
21
21
|
OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
|
|
@@ -98,7 +98,10 @@ def _svc_url(subreddit: str, post_id: str) -> str:
|
|
|
98
98
|
|
|
99
99
|
|
|
100
100
|
def _attr(tag: str, name: str) -> str:
|
|
101
|
-
|
|
101
|
+
# HTML attribute names are case-insensitive: the server partial emits
|
|
102
|
+
# ``thingId``, a DOM round-trip serializes it as ``thingid``. Match the name
|
|
103
|
+
# either way; the value keeps its case.
|
|
104
|
+
m = re.search(rf'\b{name}="([^"]*)"', tag, re.IGNORECASE)
|
|
102
105
|
return _html.unescape(m.group(1)) if m else ""
|
|
103
106
|
|
|
104
107
|
|