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
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""dev.to (Forem) source for last30days.
|
|
2
|
+
|
|
3
|
+
Uses the public Forem API at dev.to (no key):
|
|
4
|
+
|
|
5
|
+
- ``/api/articles?tag=<tag>&top=<days>`` returns the most-engaged articles for
|
|
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 by
|
|
8
|
+
recency. Single distinctive terms match well; multi-word queries match
|
|
9
|
+
loosely, so every search hit must pass the engine's token-overlap relevance
|
|
10
|
+
check before it is kept.
|
|
11
|
+
- ``/api/comments?a_id=<id>`` returns the comment tree for an article.
|
|
12
|
+
|
|
13
|
+
The source is off by default. Host-inferred tags (``--devto-tags``) turn it
|
|
14
|
+
on for a run; ``INCLUDE_SOURCES=devto`` or ``--search devto`` turn it on
|
|
15
|
+
without tags, in which case only the keyword search runs. An unknown tag
|
|
16
|
+
returns ``[]`` with HTTP 200, so a wrong tag stays quiet instead of failing.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import html
|
|
23
|
+
import re
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
import urllib.parse
|
|
27
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
28
|
+
from concurrent.futures import TimeoutError as FuturesTimeout
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from . import http, log
|
|
32
|
+
from .relevance import token_overlap_relevance
|
|
33
|
+
|
|
34
|
+
ARTICLES_URL = "https://dev.to/api/articles"
|
|
35
|
+
SEARCH_URL = "https://dev.to/api/articles/search"
|
|
36
|
+
COMMENTS_URL = "https://dev.to/api/comments"
|
|
37
|
+
_SEARCH_HEADERS = {"Accept": "application/vnd.forem.api-v1+json"}
|
|
38
|
+
|
|
39
|
+
TAG_LIMITS = {"quick": 1, "default": 2, "deep": 3}
|
|
40
|
+
SEARCH_PER_PAGE = {"quick": 10, "default": 20, "deep": 30}
|
|
41
|
+
TAG_PER_PAGE = 30
|
|
42
|
+
ENRICH_LIMIT = 3
|
|
43
|
+
MAX_COMMENTS = 5
|
|
44
|
+
# Search hits below this token-overlap score are dropped. Same bar as the
|
|
45
|
+
# keyless Reddit lane: it keeps "kubernetes" hits and drops the newest-posts
|
|
46
|
+
# noise a loose multi-word query ("claude code") returns.
|
|
47
|
+
SEARCH_RELEVANCE_FLOOR = 0.25
|
|
48
|
+
# Total wall-clock cap per request. The socket timeout alone bounds each
|
|
49
|
+
# read, so a response trickling in small chunks could otherwise run on.
|
|
50
|
+
REQUEST_DEADLINE_SECONDS = 20.0
|
|
51
|
+
ENRICH_WAIT_SECONDS = 25.0
|
|
52
|
+
|
|
53
|
+
_TAG_RE = re.compile(r"[^a-z0-9]")
|
|
54
|
+
# Bot comments on dev.to ("Official Platform Update ... tr.ee/dev-to") hide
|
|
55
|
+
# phishing behind link shorteners; real discussion rarely needs one.
|
|
56
|
+
_SHORTENER_RE = re.compile(
|
|
57
|
+
r"\b(?:bit\.ly|tinyurl\.com|tr\.ee|shorturl\.at|t\.co|rb\.gy|cutt\.ly|is\.gd|ow\.ly|tiny\.cc)/",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _log(msg: str) -> None:
|
|
63
|
+
log.source_log("dev.to", msg, tty_only=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_tag(value: str) -> str:
|
|
67
|
+
return _TAG_RE.sub("", (value or "").lower().lstrip("#"))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_tags(tags: list[str] | None, depth: str = "default") -> list[str]:
|
|
71
|
+
"""Normalize and dedupe host-supplied tags, capped by depth."""
|
|
72
|
+
resolved: list[str] = []
|
|
73
|
+
for tag in (normalize_tag(t) for t in (tags or [])):
|
|
74
|
+
if tag and tag not in resolved:
|
|
75
|
+
resolved.append(tag)
|
|
76
|
+
return resolved[: TAG_LIMITS.get(depth, TAG_LIMITS["default"])]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _window_days(from_date: str | None, to_date: str | None) -> int:
|
|
80
|
+
try:
|
|
81
|
+
start = datetime.date.fromisoformat(from_date or "")
|
|
82
|
+
end = datetime.date.fromisoformat(to_date or "") if to_date else datetime.date.today()
|
|
83
|
+
return max(1, (end - start).days)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return 30
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# The pipeline calls a source once per subquery, and the tag feed does not
|
|
89
|
+
# depend on the subquery, so identical URLs are fetched once per process.
|
|
90
|
+
_RESPONSE_CACHE: dict[str, list[dict[str, Any]]] = {}
|
|
91
|
+
_URL_LOCKS: dict[str, threading.Lock] = {}
|
|
92
|
+
_LOCKS_GUARD = threading.Lock()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clear_cache() -> None:
|
|
96
|
+
with _LOCKS_GUARD:
|
|
97
|
+
_RESPONSE_CACHE.clear()
|
|
98
|
+
_URL_LOCKS.clear()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
|
102
|
+
# Concurrent subquery streams can request the same URL; the per-URL lock
|
|
103
|
+
# makes the second caller wait for the first fetch instead of repeating it.
|
|
104
|
+
with _LOCKS_GUARD:
|
|
105
|
+
lock = _URL_LOCKS.setdefault(url, threading.Lock())
|
|
106
|
+
with lock:
|
|
107
|
+
if url not in _RESPONSE_CACHE:
|
|
108
|
+
data = http.request(
|
|
109
|
+
"GET", url, headers=headers, timeout=15, retries=2,
|
|
110
|
+
deadline_monotonic=time.monotonic() + REQUEST_DEADLINE_SECONDS,
|
|
111
|
+
owned_get=True,
|
|
112
|
+
)
|
|
113
|
+
if not isinstance(data, list):
|
|
114
|
+
# Forem list endpoints always return a JSON array; anything
|
|
115
|
+
# else is a changed or error response, not "no matches".
|
|
116
|
+
raise ValueError(f"unexpected dev.to response shape: {type(data).__name__}")
|
|
117
|
+
_RESPONSE_CACHE[url] = [row for row in data if isinstance(row, dict)]
|
|
118
|
+
return [dict(row) for row in _RESPONSE_CACHE[url]]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _in_window(article: dict[str, Any], from_date: str | None, to_date: str | None) -> bool:
|
|
122
|
+
published = str(article.get("published_at") or "")[:10]
|
|
123
|
+
if not published:
|
|
124
|
+
return True
|
|
125
|
+
if from_date and published < from_date:
|
|
126
|
+
return False
|
|
127
|
+
if to_date and published > to_date:
|
|
128
|
+
return False
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _article_text(article: dict[str, Any]) -> str:
|
|
133
|
+
tags = article.get("tag_list") or []
|
|
134
|
+
if isinstance(tags, str):
|
|
135
|
+
tags = [t.strip() for t in tags.split(",")]
|
|
136
|
+
return " ".join([str(article.get("title") or ""), str(article.get("description") or ""), " ".join(tags)])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def search_devto(
|
|
140
|
+
topic: str,
|
|
141
|
+
from_date: str | None = None,
|
|
142
|
+
to_date: str | None = None,
|
|
143
|
+
*,
|
|
144
|
+
depth: str = "default",
|
|
145
|
+
tags: list[str] | None = None,
|
|
146
|
+
search_query: str | None = None,
|
|
147
|
+
) -> dict[str, Any]:
|
|
148
|
+
"""Fetch tag-feed and keyword-search articles for the topic.
|
|
149
|
+
|
|
150
|
+
Returns ``{"articles": [...], "tags": [...], "errors": [...]}``. A failed
|
|
151
|
+
endpoint is recorded in ``errors`` and the other endpoint still counts.
|
|
152
|
+
"""
|
|
153
|
+
days = _window_days(from_date, to_date)
|
|
154
|
+
resolved_tags = resolve_tags(tags, depth)
|
|
155
|
+
articles: dict[int, dict[str, Any]] = {}
|
|
156
|
+
errors: list[str] = []
|
|
157
|
+
|
|
158
|
+
for tag in resolved_tags:
|
|
159
|
+
url = f"{ARTICLES_URL}?{urllib.parse.urlencode({'tag': tag, 'top': days, 'per_page': TAG_PER_PAGE})}"
|
|
160
|
+
try:
|
|
161
|
+
rows = _get_list(url)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
errors.append(f"tag {tag}: {exc}")
|
|
164
|
+
_log(f"tag feed failed for '{tag}': {exc}")
|
|
165
|
+
continue
|
|
166
|
+
kept = 0
|
|
167
|
+
for row in rows:
|
|
168
|
+
if row.get("id") is not None and _in_window(row, from_date, to_date):
|
|
169
|
+
row.setdefault("_devto_match", f"tag:{tag}")
|
|
170
|
+
articles.setdefault(row["id"], row)
|
|
171
|
+
kept += 1
|
|
172
|
+
_log(f"tag '{tag}': {kept} articles in window")
|
|
173
|
+
|
|
174
|
+
# Planner queries quote phrases for engines with phrase syntax; Forem
|
|
175
|
+
# search treats the quotes as literal characters.
|
|
176
|
+
query = (search_query or topic or "").replace('"', " ").strip()
|
|
177
|
+
query = " ".join(query.split())
|
|
178
|
+
if query:
|
|
179
|
+
params = {"q": query, "top": days, "per_page": SEARCH_PER_PAGE.get(depth, SEARCH_PER_PAGE["default"])}
|
|
180
|
+
url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
|
|
181
|
+
try:
|
|
182
|
+
rows = _get_list(url, headers=_SEARCH_HEADERS)
|
|
183
|
+
except Exception as exc:
|
|
184
|
+
errors.append(f"search: {exc}")
|
|
185
|
+
_log(f"search failed for '{query}': {exc}")
|
|
186
|
+
rows = []
|
|
187
|
+
kept = dropped = 0
|
|
188
|
+
for row in rows:
|
|
189
|
+
if row.get("id") is None or not _in_window(row, from_date, to_date):
|
|
190
|
+
continue
|
|
191
|
+
if token_overlap_relevance(topic or query, _article_text(row)) < SEARCH_RELEVANCE_FLOOR:
|
|
192
|
+
dropped += 1
|
|
193
|
+
continue
|
|
194
|
+
row.setdefault("_devto_match", "search")
|
|
195
|
+
articles.setdefault(row["id"], row)
|
|
196
|
+
kept += 1
|
|
197
|
+
_log(f"search '{query}': kept {kept}, dropped {dropped} off-topic")
|
|
198
|
+
|
|
199
|
+
result: dict[str, Any] = {"articles": list(articles.values()), "tags": resolved_tags, "errors": errors}
|
|
200
|
+
attempted = len(resolved_tags) + (1 if query else 0)
|
|
201
|
+
if attempted and len(errors) == attempted:
|
|
202
|
+
# Every endpoint failed: surface it so the run reports a source
|
|
203
|
+
# failure instead of a quiet empty result.
|
|
204
|
+
result["error"] = "; ".join(errors)
|
|
205
|
+
return result
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_devto_response(response: dict[str, Any], query: str = "") -> list[dict[str, Any]]:
|
|
209
|
+
"""Map Forem articles to engine item dicts (the HN item shape)."""
|
|
210
|
+
items: list[dict[str, Any]] = []
|
|
211
|
+
for article in response.get("articles") or []:
|
|
212
|
+
reactions = int(article.get("public_reactions_count") or article.get("positive_reactions_count") or 0)
|
|
213
|
+
comments = int(article.get("comments_count") or 0)
|
|
214
|
+
user = article.get("user") or {}
|
|
215
|
+
content_score = token_overlap_relevance(query, _article_text(article)) if query else 0.5
|
|
216
|
+
relevance = min(1.0, 0.7 * content_score + min(0.3, (reactions + comments) / 100))
|
|
217
|
+
tags = article.get("tag_list") or []
|
|
218
|
+
if isinstance(tags, str):
|
|
219
|
+
tags = [t.strip() for t in tags.split(",") if t.strip()]
|
|
220
|
+
items.append({
|
|
221
|
+
"id": str(article.get("id")),
|
|
222
|
+
"title": str(article.get("title") or "").strip(),
|
|
223
|
+
"url": str(article.get("url") or ""),
|
|
224
|
+
"author": str(user.get("username") or user.get("name") or ""),
|
|
225
|
+
"date": str(article.get("published_at") or "")[:10] or None,
|
|
226
|
+
"text": str(article.get("description") or "").strip(),
|
|
227
|
+
"engagement": {"reactions": reactions, "comments": comments},
|
|
228
|
+
"relevance": round(relevance, 2),
|
|
229
|
+
"why_relevant": f"dev.to article ({article.get('_devto_match', 'match')})",
|
|
230
|
+
"metadata": {
|
|
231
|
+
"tags": tags,
|
|
232
|
+
"reading_time_minutes": article.get("reading_time_minutes"),
|
|
233
|
+
"ai_disclosure_level": article.get("ai_disclosure_level"),
|
|
234
|
+
"match": article.get("_devto_match"),
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
return items
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _strip_html(text: str) -> str:
|
|
241
|
+
text = re.sub(r"<(br|/p)\s*/?>", "\n", text or "", flags=re.IGNORECASE)
|
|
242
|
+
text = re.sub(r"<[^>]+>", "", text)
|
|
243
|
+
return html.unescape(text).strip()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _fetch_comments(article_id: str) -> list[dict[str, Any]]:
|
|
247
|
+
url = f"{COMMENTS_URL}?{urllib.parse.urlencode({'a_id': article_id})}"
|
|
248
|
+
try:
|
|
249
|
+
rows = _get_list(url)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
_log(f"comments failed for article {article_id}: {exc}")
|
|
252
|
+
return []
|
|
253
|
+
comments = []
|
|
254
|
+
for row in rows:
|
|
255
|
+
body_html = str(row.get("body_html") or "")
|
|
256
|
+
text = _strip_html(body_html)
|
|
257
|
+
# Check the raw HTML too: a shortener can hide in an href.
|
|
258
|
+
if not text or _SHORTENER_RE.search(html.unescape(body_html)):
|
|
259
|
+
continue
|
|
260
|
+
user = row.get("user") or {}
|
|
261
|
+
comments.append({
|
|
262
|
+
"author": str(user.get("username") or user.get("name") or ""),
|
|
263
|
+
"text": text[:300] + "..." if len(text) > 300 else text,
|
|
264
|
+
# The comments API exposes no like count; direct replies are the
|
|
265
|
+
# only engagement signal it returns.
|
|
266
|
+
"replies": len(row.get("children") or []),
|
|
267
|
+
"ai_disclosure_level": row.get("ai_disclosure_level"),
|
|
268
|
+
})
|
|
269
|
+
comments.sort(key=lambda c: c["replies"], reverse=True)
|
|
270
|
+
return comments[:MAX_COMMENTS]
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def enrich_source_items(items: list[Any], top_k: int = ENRICH_LIMIT) -> list[Any]:
|
|
274
|
+
"""Attach top comments to the first ``top_k`` ranked SourceItems with comments.
|
|
275
|
+
|
|
276
|
+
Runs from ``_finalize_items_by_source`` so the comment calls land on the
|
|
277
|
+
articles that survived relevance pruning and dedupe, not on high-engagement
|
|
278
|
+
tag-feed articles the ranking later drops.
|
|
279
|
+
"""
|
|
280
|
+
from .normalize import _remap_comments
|
|
281
|
+
|
|
282
|
+
targets = []
|
|
283
|
+
for item in items:
|
|
284
|
+
if len(targets) >= top_k:
|
|
285
|
+
break
|
|
286
|
+
if getattr(item, "source", None) != "devto":
|
|
287
|
+
continue
|
|
288
|
+
if (item.metadata or {}).get("top_comments"):
|
|
289
|
+
continue
|
|
290
|
+
if not (item.engagement or {}).get("comments"):
|
|
291
|
+
continue
|
|
292
|
+
targets.append(item)
|
|
293
|
+
if not targets:
|
|
294
|
+
return items
|
|
295
|
+
executor = ThreadPoolExecutor(max_workers=len(targets))
|
|
296
|
+
futures = {http.submit_with_context(executor, _fetch_comments, item.item_id): item for item in targets}
|
|
297
|
+
try:
|
|
298
|
+
for future in as_completed(futures, timeout=ENRICH_WAIT_SECONDS):
|
|
299
|
+
item = futures[future]
|
|
300
|
+
item.metadata["top_comments"] = _remap_comments(
|
|
301
|
+
future.result(),
|
|
302
|
+
score_keys=("replies",),
|
|
303
|
+
excerpt_keys=("text", "excerpt"),
|
|
304
|
+
preserve_absent_score=True,
|
|
305
|
+
)
|
|
306
|
+
except FuturesTimeout:
|
|
307
|
+
_log("comment enrichment hit its time limit; keeping the comments that arrived")
|
|
308
|
+
finally:
|
|
309
|
+
# Never block report finalization on a straggling comment fetch.
|
|
310
|
+
executor.shutdown(wait=False, cancel_futures=True)
|
|
311
|
+
attached = sum(1 for item in targets if item.metadata.get("top_comments"))
|
|
312
|
+
_log(f"attached comments to {attached} of {len(targets)} top articles")
|
|
313
|
+
return items
|