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
|
@@ -4,8 +4,8 @@ Reddit's public ``.json`` endpoints now return HTTP 403 from most contexts
|
|
|
4
4
|
(shreddit anti-bot), so this is no longer the primary free path. The keyless
|
|
5
5
|
pipeline (see reddit_keyless.py) still calls ``search`` as a cheap one-shot
|
|
6
6
|
Tier 0 attempt — a residential machine may occasionally get a 200 — before
|
|
7
|
-
falling through to
|
|
8
|
-
enrichment (reddit_shreddit.py).
|
|
7
|
+
falling through to site search discovery (reddit_search.py) and shreddit
|
|
8
|
+
comment enrichment (reddit_shreddit.py).
|
|
9
9
|
|
|
10
10
|
``search_reddit_public`` is retained as a compatibility shim that delegates to
|
|
11
11
|
the keyless pipeline, so existing callers (pipeline.py) need no change.
|
|
@@ -27,6 +27,8 @@ import urllib.parse
|
|
|
27
27
|
import urllib.request
|
|
28
28
|
from typing import Any, Dict, List, Optional
|
|
29
29
|
|
|
30
|
+
from lib import http
|
|
31
|
+
|
|
30
32
|
|
|
31
33
|
USER_AGENT = (
|
|
32
34
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
@@ -86,15 +88,12 @@ def _fetch_json(url: str, timeout: int = 15) -> Optional[Dict[str, Any]]:
|
|
|
86
88
|
|
|
87
89
|
except urllib.error.HTTPError as e:
|
|
88
90
|
if e.code == 429:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
delay = float(retry_after)
|
|
96
|
-
except ValueError:
|
|
97
|
-
pass
|
|
91
|
+
# Reddit answers an anonymous 429 with x-ratelimit-reset and no
|
|
92
|
+
# Retry-After; honour either. See http.retry_delay_from_headers.
|
|
93
|
+
delay = http.retry_delay_from_headers(
|
|
94
|
+
getattr(e, "headers", None),
|
|
95
|
+
BASE_BACKOFF * (2 ** attempt),
|
|
96
|
+
)
|
|
98
97
|
_log(f"429 rate limited, retry {attempt + 1}/{MAX_RETRIES} after {delay:.1f}s")
|
|
99
98
|
if attempt < MAX_RETRIES - 1:
|
|
100
99
|
time.sleep(delay)
|
|
@@ -248,8 +247,8 @@ def search_reddit_public(
|
|
|
248
247
|
|
|
249
248
|
Thin compatibility shim over the keyless pipeline: the legacy ``.json``
|
|
250
249
|
search/enrichment endpoints now return HTTP 403, so this delegates to
|
|
251
|
-
``reddit_keyless.search_and_enrich`` (dedicated-sub listings +
|
|
252
|
-
→ shreddit comment enrichment; no ``.json`` search). The name and signature
|
|
250
|
+
``reddit_keyless.search_and_enrich`` (dedicated-sub listings + site search
|
|
251
|
+
discovery → shreddit comment enrichment; no ``.json`` search). The name and signature
|
|
253
252
|
are preserved so ``pipeline.py`` and other callers need no change and the
|
|
254
253
|
ScrapeCreators backup still engages when this returns empty.
|
|
255
254
|
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""Keyless Reddit discovery via Reddit's own site search fragment.
|
|
2
|
+
|
|
3
|
+
``/svc/shreddit/search/?q=...&type=posts`` is the server-rendered HTML the
|
|
4
|
+
reddit.com search page loads. It serves HTTP 200 with no API key, and
|
|
5
|
+
``/svc/shreddit/r/{sub}/search/`` restricts the same search to one subreddit.
|
|
6
|
+
Each post result unit carries:
|
|
7
|
+
|
|
8
|
+
- a ``<search-telemetry-tracker>`` whose JSON tracking context has
|
|
9
|
+
``action_info.type == "post"``, the post id, title, and subreddit,
|
|
10
|
+
- the post-title link (the canonical permalink),
|
|
11
|
+
- a ``<faceplate-timeago ts=...>`` post date,
|
|
12
|
+
- a counter row of ``<faceplate-number>`` votes and comments.
|
|
13
|
+
|
|
14
|
+
So every discovered post arrives dated and scored, with no extra listing fetch.
|
|
15
|
+
The next page is a lazy ``<faceplate-partial>`` whose ``src`` carries a
|
|
16
|
+
``cursor``. Output dicts match ``reddit_listing.parse_cards`` so downstream
|
|
17
|
+
code is unaffected.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import html as _html
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
from urllib.parse import parse_qs, quote, urlencode, urlsplit
|
|
27
|
+
|
|
28
|
+
from . import http, reddit_listing
|
|
29
|
+
from .relevance import token_overlap_relevance
|
|
30
|
+
|
|
31
|
+
BASE = "https://www.reddit.com"
|
|
32
|
+
|
|
33
|
+
# Post caps per run by depth.
|
|
34
|
+
DEPTH_LIMITS = {"quick": 10, "default": 25, "deep": 50}
|
|
35
|
+
# Global search pages per depth (about 7 posts per page).
|
|
36
|
+
GLOBAL_PAGE_CAPS = {"quick": 2, "default": 4, "deep": 8}
|
|
37
|
+
# Targeted subreddits get one page each.
|
|
38
|
+
SUB_PAGE_CAP = 1
|
|
39
|
+
MAX_WORKERS = 4
|
|
40
|
+
SEARCH_TIMEOUT = 15
|
|
41
|
+
|
|
42
|
+
# A recognizable page has post result units or Reddit's explicit no-results
|
|
43
|
+
# unit. Anything else (challenge page, changed markup) is a lane failure.
|
|
44
|
+
RESULTS_MARKER = 'view-events="search/view/post"'
|
|
45
|
+
NO_RESULTS_MARKER = 'view-events="search/view/no_results"'
|
|
46
|
+
|
|
47
|
+
_TRACKER = re.compile(
|
|
48
|
+
r'<search-telemetry-tracker\b[^>]*?\bdata-faceplate-tracking-context="([^"]*)"'
|
|
49
|
+
)
|
|
50
|
+
_TITLE_HREF = re.compile(
|
|
51
|
+
r'<a\b[^>]*?data-testid="post-title"[^>]*?\bhref="([^"]*)"'
|
|
52
|
+
r'|<a\b[^>]*?\bhref="([^"]*)"[^>]*?data-testid="post-title"'
|
|
53
|
+
)
|
|
54
|
+
_TIMEAGO = re.compile(r'<faceplate-timeago\b[^>]*?\bts="([^"]+)"')
|
|
55
|
+
_COUNTER = re.compile(
|
|
56
|
+
r'<faceplate-number\b[^>]*?\bnumber="(\d+)"[^>]*>\s*</faceplate-number>\s*(vote|comment)'
|
|
57
|
+
)
|
|
58
|
+
_NEXT_PAGE = re.compile(
|
|
59
|
+
r'<faceplate-partial\b[^>]*?\bsrc="([^"]*/svc/shreddit/[^"]*search/[^"]*)"'
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _log(msg: str) -> None:
|
|
64
|
+
sys.stderr.write(f"[RedditSearch] {msg}\n")
|
|
65
|
+
sys.stderr.flush()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def unrecognized_body(text: str) -> Optional[str]:
|
|
69
|
+
"""Validator for the fetch hook: None for a search page, else a reason."""
|
|
70
|
+
if NO_RESULTS_MARKER in text:
|
|
71
|
+
return None
|
|
72
|
+
if RESULTS_MARKER in text:
|
|
73
|
+
# Marker kept but tracking context changed shape: a clean zero-result
|
|
74
|
+
# parse would hide the drift, so treat it as a lane failure.
|
|
75
|
+
if _post_units(text):
|
|
76
|
+
return None
|
|
77
|
+
return "Reddit search result units no longer parse"
|
|
78
|
+
return "not a Reddit search results page"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def search_url(
|
|
82
|
+
query: str,
|
|
83
|
+
time_filter: str = "month",
|
|
84
|
+
subreddit: Optional[str] = None,
|
|
85
|
+
cursor: Optional[str] = None,
|
|
86
|
+
) -> str:
|
|
87
|
+
"""Build a deterministic search URL so concurrent streams share the memo."""
|
|
88
|
+
if subreddit:
|
|
89
|
+
sub = subreddit.strip().removeprefix("r/").strip("/")
|
|
90
|
+
path = f"/svc/shreddit/r/{quote(sub, safe='')}/search/"
|
|
91
|
+
else:
|
|
92
|
+
path = "/svc/shreddit/search/"
|
|
93
|
+
params = [("q", query), ("type", "posts"), ("t", time_filter), ("sort", "relevance")]
|
|
94
|
+
if cursor:
|
|
95
|
+
params.append(("cursor", cursor))
|
|
96
|
+
return f"{BASE}{path}?{urlencode(params)}"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _post_units(html_text: str) -> List[Tuple[int, Dict[str, Any]]]:
|
|
100
|
+
"""(offset, tracking context) for each post-title tracker, in page order."""
|
|
101
|
+
units = []
|
|
102
|
+
for m in _TRACKER.finditer(html_text):
|
|
103
|
+
try:
|
|
104
|
+
ctx = json.loads(_html.unescape(m.group(1)))
|
|
105
|
+
except (ValueError, TypeError):
|
|
106
|
+
continue
|
|
107
|
+
if not isinstance(ctx, dict):
|
|
108
|
+
continue
|
|
109
|
+
action = ctx.get("action_info")
|
|
110
|
+
post = ctx.get("post")
|
|
111
|
+
# A field that is no longer an object is drift: skip the unit, and a
|
|
112
|
+
# page left with none is flagged by unrecognized_body.
|
|
113
|
+
if not isinstance(action, dict) or not isinstance(post, dict):
|
|
114
|
+
continue
|
|
115
|
+
if action.get("type") == "post" and post.get("id"):
|
|
116
|
+
units.append((m.start(), ctx))
|
|
117
|
+
return units
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _next_cursor(html_text: str) -> Optional[str]:
|
|
121
|
+
for m in _NEXT_PAGE.finditer(html_text):
|
|
122
|
+
query = urlsplit(_html.unescape(m.group(1))).query
|
|
123
|
+
cursor = (parse_qs(query).get("cursor") or [None])[0]
|
|
124
|
+
if cursor:
|
|
125
|
+
return cursor
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_page(html_text: str, query: str = "") -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
130
|
+
"""Parse one search fragment into (normalized posts, next-page cursor)."""
|
|
131
|
+
html_text = html_text or ""
|
|
132
|
+
units = _post_units(html_text)
|
|
133
|
+
posts: List[Dict[str, Any]] = []
|
|
134
|
+
seen: set = set()
|
|
135
|
+
for i, (start, ctx) in enumerate(units):
|
|
136
|
+
post_id = str(ctx["post"]["id"]).removeprefix("t3_")
|
|
137
|
+
if post_id in seen:
|
|
138
|
+
continue
|
|
139
|
+
seen.add(post_id)
|
|
140
|
+
end = units[i + 1][0] if i + 1 < len(units) else len(html_text)
|
|
141
|
+
chunk = html_text[start:end]
|
|
142
|
+
|
|
143
|
+
sub_ctx = ctx.get("subreddit")
|
|
144
|
+
subreddit = str(sub_ctx.get("name") or "") if isinstance(sub_ctx, dict) else ""
|
|
145
|
+
title = str(ctx["post"].get("title") or "")
|
|
146
|
+
href_match = _TITLE_HREF.search(chunk)
|
|
147
|
+
href = _html.unescape((href_match.group(1) or href_match.group(2))) if href_match else ""
|
|
148
|
+
href = href.split("?", 1)[0]
|
|
149
|
+
if f"/comments/{post_id}/" in href and href.startswith("/r/"):
|
|
150
|
+
url = f"{BASE}{href}"
|
|
151
|
+
else:
|
|
152
|
+
url = f"{BASE}/r/{subreddit}/comments/{post_id}/"
|
|
153
|
+
|
|
154
|
+
ts_match = _TIMEAGO.search(chunk)
|
|
155
|
+
ts = ts_match.group(1) if ts_match else None
|
|
156
|
+
# First counter of each kind wins: this post's own counters precede any
|
|
157
|
+
# that belong to a skipped (malformed) neighbour sharing the chunk.
|
|
158
|
+
counts: Dict[str, int] = {}
|
|
159
|
+
for number, label in _COUNTER.findall(chunk):
|
|
160
|
+
counts.setdefault(label, int(number))
|
|
161
|
+
score, num_comments = counts.get("vote", 0), counts.get("comment", 0)
|
|
162
|
+
|
|
163
|
+
posts.append({
|
|
164
|
+
"id": "",
|
|
165
|
+
"title": title,
|
|
166
|
+
"url": url,
|
|
167
|
+
"score": score,
|
|
168
|
+
"num_comments": num_comments,
|
|
169
|
+
"subreddit": subreddit,
|
|
170
|
+
"created_utc": reddit_listing._to_epoch(ts),
|
|
171
|
+
"author": "",
|
|
172
|
+
"selftext": "",
|
|
173
|
+
"date": reddit_listing._to_date(ts),
|
|
174
|
+
"engagement": {
|
|
175
|
+
"score": score,
|
|
176
|
+
"num_comments": num_comments,
|
|
177
|
+
"upvote_ratio": None,
|
|
178
|
+
},
|
|
179
|
+
"relevance": round(token_overlap_relevance(query, title), 3) if query else 0.0,
|
|
180
|
+
"why_relevant": "Reddit search",
|
|
181
|
+
"metadata": {"post_id": post_id},
|
|
182
|
+
})
|
|
183
|
+
return posts, _next_cursor(html_text)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _fetch_page(url: str) -> Tuple[Optional[str], Optional[str]]:
|
|
187
|
+
"""(body, error). Shared limiter, run memo, one 429 retry, failure sink."""
|
|
188
|
+
try:
|
|
189
|
+
return http.reddit_keyless_get_text_retry_429(
|
|
190
|
+
url,
|
|
191
|
+
timeout=SEARCH_TIMEOUT,
|
|
192
|
+
accept="text/html",
|
|
193
|
+
validate=unrecognized_body,
|
|
194
|
+
)
|
|
195
|
+
except Exception as e: # defensive: one bad page must not sink the run
|
|
196
|
+
_log(f"search fetch failed for {url}: {e}")
|
|
197
|
+
return None, str(e)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _search_stream(
|
|
201
|
+
query: str,
|
|
202
|
+
time_filter: str,
|
|
203
|
+
max_pages: int,
|
|
204
|
+
subreddit: Optional[str] = None,
|
|
205
|
+
) -> List[Dict[str, Any]]:
|
|
206
|
+
"""Page one search sequentially. Keeps earlier pages when a later one fails."""
|
|
207
|
+
label = f"r/{subreddit}" if subreddit else "global"
|
|
208
|
+
posts: List[Dict[str, Any]] = []
|
|
209
|
+
seen_ids: set = set()
|
|
210
|
+
used_cursors: set = set()
|
|
211
|
+
cursor: Optional[str] = None
|
|
212
|
+
for page in range(1, max_pages + 1):
|
|
213
|
+
text, error = _fetch_page(search_url(query, time_filter, subreddit, cursor))
|
|
214
|
+
if text is None:
|
|
215
|
+
_log(f"{label} page {page} failed: {error or 'no response'}")
|
|
216
|
+
break
|
|
217
|
+
page_posts, next_cursor = parse_page(text, query)
|
|
218
|
+
new = [p for p in page_posts if p["metadata"]["post_id"] not in seen_ids]
|
|
219
|
+
for p in new:
|
|
220
|
+
seen_ids.add(p["metadata"]["post_id"])
|
|
221
|
+
posts.extend(new)
|
|
222
|
+
if not new or not next_cursor or next_cursor in used_cursors:
|
|
223
|
+
break
|
|
224
|
+
used_cursors.add(next_cursor)
|
|
225
|
+
cursor = next_cursor
|
|
226
|
+
return posts
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _result_timeout(batch_size: int, pages: int = 1) -> float:
|
|
230
|
+
"""Per-future wait: each sequential page's own timeout plus the bucket's queue."""
|
|
231
|
+
return pages * (SEARCH_TIMEOUT + 5) + http.reddit_keyless_wait_allowance(batch_size)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _time_filter(from_date: Optional[str], to_date: Optional[str]) -> str:
|
|
235
|
+
# Same mapping as the ScrapeCreators path; falls back to "month".
|
|
236
|
+
from .reddit import _window_to_time_filter
|
|
237
|
+
return _window_to_time_filter(from_date or "", to_date or "")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def search(
|
|
241
|
+
query: str,
|
|
242
|
+
depth: str = "default",
|
|
243
|
+
subreddits: Optional[List[str]] = None,
|
|
244
|
+
from_date: Optional[str] = None,
|
|
245
|
+
to_date: Optional[str] = None,
|
|
246
|
+
) -> List[Dict[str, Any]]:
|
|
247
|
+
"""Discover Reddit posts for a query via the site search fragment.
|
|
248
|
+
|
|
249
|
+
Runs a paged global search plus one page of per-subreddit search for each
|
|
250
|
+
targeted subreddit. Returns normalized, dated, scored posts, deduped by URL
|
|
251
|
+
and capped by depth. Streams are interleaved round-robin before the cap so
|
|
252
|
+
neither the targeted subreddits nor the global search crowds out the other. Returns ``[]`` on total failure and never raises; failures
|
|
253
|
+
land in the pipeline's failure sink.
|
|
254
|
+
"""
|
|
255
|
+
try:
|
|
256
|
+
limit = DEPTH_LIMITS.get(depth, DEPTH_LIMITS["default"])
|
|
257
|
+
global_pages = GLOBAL_PAGE_CAPS.get(depth, GLOBAL_PAGE_CAPS["default"])
|
|
258
|
+
time_filter = _time_filter(from_date, to_date)
|
|
259
|
+
subs = []
|
|
260
|
+
for raw in subreddits or []:
|
|
261
|
+
sub = (raw or "").strip().removeprefix("r/").strip("/")
|
|
262
|
+
if sub and sub.lower() not in {s.lower() for s in subs}:
|
|
263
|
+
subs.append(sub)
|
|
264
|
+
|
|
265
|
+
jobs = [(sub, SUB_PAGE_CAP) for sub in subs] + [(None, global_pages)]
|
|
266
|
+
batch = sum(pages for _sub, pages in jobs)
|
|
267
|
+
streams: List[List[Dict[str, Any]]] = []
|
|
268
|
+
with ThreadPoolExecutor(max_workers=min(MAX_WORKERS, len(jobs))) as executor:
|
|
269
|
+
# submit_with_context, not executor.submit: a plain submit starts the
|
|
270
|
+
# worker with an empty context, dropping the pipeline's
|
|
271
|
+
# capture_failures() sink so a 429/403 or a challenge page is
|
|
272
|
+
# silently discarded (issue #899).
|
|
273
|
+
futures = [
|
|
274
|
+
(http.submit_with_context(executor, _search_stream, query, time_filter, pages, sub), sub, pages)
|
|
275
|
+
for sub, pages in jobs
|
|
276
|
+
]
|
|
277
|
+
for future, sub, pages in futures:
|
|
278
|
+
try:
|
|
279
|
+
streams.append(future.result(timeout=_result_timeout(batch, pages)))
|
|
280
|
+
except (Exception, FuturesTimeoutError) as e:
|
|
281
|
+
_log(f"{'r/' + sub if sub else 'global'} search future failed: {e}")
|
|
282
|
+
|
|
283
|
+
# Round-robin across streams, each in its own relevance order, so the
|
|
284
|
+
# depth cap keeps a share of every stream.
|
|
285
|
+
results = [
|
|
286
|
+
stream[i]
|
|
287
|
+
for i in range(max((len(s) for s in streams), default=0))
|
|
288
|
+
for stream in streams
|
|
289
|
+
if i < len(stream)
|
|
290
|
+
]
|
|
291
|
+
seen: set = set()
|
|
292
|
+
unique: List[Dict[str, Any]] = []
|
|
293
|
+
for post in results:
|
|
294
|
+
if post["url"] not in seen:
|
|
295
|
+
seen.add(post["url"])
|
|
296
|
+
unique.append(post)
|
|
297
|
+
unique = unique[:limit]
|
|
298
|
+
for i, post in enumerate(unique):
|
|
299
|
+
post["id"] = f"R{i + 1}"
|
|
300
|
+
_log(f"{len(unique)} posts (t={time_filter}, {len(subs)} targeted subs)")
|
|
301
|
+
return unique
|
|
302
|
+
except Exception as e: # defensive: discovery must never raise into the pipeline
|
|
303
|
+
_log(f"search failed: {e}")
|
|
304
|
+
return []
|
|
@@ -23,18 +23,47 @@ from typing import Any, Dict, List, Optional
|
|
|
23
23
|
from . import http
|
|
24
24
|
from . import reddit_enrich
|
|
25
25
|
|
|
26
|
-
# Up to N posts enriched per
|
|
26
|
+
# Up to N posts enriched per subquery, by depth. Raised from 3/5/8 once the
|
|
27
|
+
# per-command memo (http.reddit_keyless_get_text) collapsed repeat shreddit
|
|
28
|
+
# fetches across subqueries and the 1 req/s bucket stopped the 429s: eight
|
|
29
|
+
# fetches fit inside ENRICH_BUDGET at four workers, and the comments are the
|
|
30
|
+
# lane's headline value.
|
|
27
31
|
ENRICH_LIMITS = {
|
|
28
|
-
"quick":
|
|
29
|
-
"default":
|
|
30
|
-
"deep":
|
|
32
|
+
"quick": 4,
|
|
33
|
+
"default": 8,
|
|
34
|
+
"deep": 12,
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
# Max comments returned per post (independent of how many posts get enriched).
|
|
34
|
-
|
|
38
|
+
# Twelve so a thousand-comment thread feeds more than ten candidates into the
|
|
39
|
+
# cross-platform Top Community Comments block.
|
|
40
|
+
MAX_COMMENTS = 12
|
|
35
41
|
|
|
36
42
|
SVC_TIMEOUT = 12
|
|
37
43
|
|
|
44
|
+
# Known bots whose comments carry no community signal.
|
|
45
|
+
BOT_AUTHORS = frozenset({
|
|
46
|
+
"automoderator",
|
|
47
|
+
"remindmebot",
|
|
48
|
+
"repostsleuthbot",
|
|
49
|
+
"sneakpeekbot",
|
|
50
|
+
"savevideo",
|
|
51
|
+
"videodownloadbot",
|
|
52
|
+
"totesmessenger",
|
|
53
|
+
"b0trank",
|
|
54
|
+
"amputatorbot",
|
|
55
|
+
"stabbot",
|
|
56
|
+
"gifreversingbot",
|
|
57
|
+
"haikubotinaction",
|
|
58
|
+
"imagesofnetwork",
|
|
59
|
+
"botdefense",
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
_BOT_SUFFIXES = ("-bot", "_bot")
|
|
63
|
+
|
|
64
|
+
# CamelCase catches WikiTextBot without swallowing names such as Talbot.
|
|
65
|
+
_CAMEL_BOT = re.compile(r"[a-z0-9]Bot\d*$")
|
|
66
|
+
|
|
38
67
|
# Match the exact <shreddit-comment> element start tag, not <shreddit-comment-tree>
|
|
39
68
|
# or <shreddit-comment-tree-stats> (lookahead requires whitespace or '>').
|
|
40
69
|
_COMMENT_START = re.compile(r"<shreddit-comment(?=[\s>])[^>]*>")
|
|
@@ -106,13 +135,28 @@ def _body_for(html_text: str, thing_id: str) -> str:
|
|
|
106
135
|
return _WS.sub(" ", _html.unescape(text)).strip()
|
|
107
136
|
|
|
108
137
|
|
|
138
|
+
def _is_bot_author(author: str) -> bool:
|
|
139
|
+
"""Whether an author is a bot whose comments carry no community signal."""
|
|
140
|
+
raw = (author or "").strip()
|
|
141
|
+
if not raw:
|
|
142
|
+
return False
|
|
143
|
+
name = raw.lower()
|
|
144
|
+
return (name in BOT_AUTHORS
|
|
145
|
+
or name.endswith(_BOT_SUFFIXES)
|
|
146
|
+
or bool(_CAMEL_BOT.search(raw)))
|
|
147
|
+
|
|
148
|
+
|
|
109
149
|
def parse_comments(html_text: str, limit: int = MAX_COMMENTS) -> List[Dict[str, Any]]:
|
|
110
|
-
"""Parse <shreddit-comment> elements into scored comment dicts (sorted desc).
|
|
150
|
+
"""Parse <shreddit-comment> elements into scored comment dicts (sorted desc).
|
|
151
|
+
|
|
152
|
+
Deleted, removed, and bot authors are dropped: they occupy top-comment slots
|
|
153
|
+
on high-traffic threads without saying anything about the topic.
|
|
154
|
+
"""
|
|
111
155
|
comments: List[Dict[str, Any]] = []
|
|
112
156
|
for m in _COMMENT_START.finditer(html_text or ""):
|
|
113
157
|
tag = m.group(0)
|
|
114
158
|
author = _attr(tag, "author") or "[deleted]"
|
|
115
|
-
if author in ("[deleted]", "[removed]"):
|
|
159
|
+
if author in ("[deleted]", "[removed]") or _is_bot_author(author):
|
|
116
160
|
continue
|
|
117
161
|
thing_id = _attr(tag, "thingId")
|
|
118
162
|
body = _body_for(html_text, thing_id)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Named audience registers for standard research brief synthesis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
SectionName = str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class AudienceRegister:
|
|
15
|
+
"""A bounded renderer/synthesis preset for one intended audience."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
section_order: tuple[SectionName, ...]
|
|
19
|
+
item_budgets: Mapping[SectionName, int]
|
|
20
|
+
emphasis_weights: Mapping[str, float]
|
|
21
|
+
|
|
22
|
+
def budget_for(self, section: SectionName, fallback: int) -> int:
|
|
23
|
+
return self.item_budgets.get(section, fallback)
|
|
24
|
+
|
|
25
|
+
def emphasis_for(self, source: str) -> float:
|
|
26
|
+
return self.emphasis_weights.get(source, 1.0)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_DEFAULT_ORDER = (
|
|
30
|
+
"hiring_signals",
|
|
31
|
+
"clusters",
|
|
32
|
+
"stats",
|
|
33
|
+
"best_takes",
|
|
34
|
+
"top_comments",
|
|
35
|
+
"source_outcomes",
|
|
36
|
+
"source_coverage",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _preset(
|
|
41
|
+
name: str,
|
|
42
|
+
*,
|
|
43
|
+
section_order: tuple[SectionName, ...] = _DEFAULT_ORDER,
|
|
44
|
+
item_budgets: Mapping[SectionName, int] | None = None,
|
|
45
|
+
emphasis_weights: Mapping[str, float] | None = None,
|
|
46
|
+
) -> AudienceRegister:
|
|
47
|
+
return AudienceRegister(
|
|
48
|
+
name=name,
|
|
49
|
+
section_order=section_order,
|
|
50
|
+
item_budgets=MappingProxyType(dict(item_budgets or {})),
|
|
51
|
+
emphasis_weights=MappingProxyType(dict(emphasis_weights or {})),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_REGISTERS = {
|
|
56
|
+
"default": _preset("default"),
|
|
57
|
+
"exec": _preset(
|
|
58
|
+
"exec",
|
|
59
|
+
section_order=(
|
|
60
|
+
"stats",
|
|
61
|
+
"clusters",
|
|
62
|
+
"hiring_signals",
|
|
63
|
+
"source_outcomes",
|
|
64
|
+
"source_coverage",
|
|
65
|
+
"best_takes",
|
|
66
|
+
"top_comments",
|
|
67
|
+
),
|
|
68
|
+
item_budgets={"clusters": 5, "best_takes": 2, "top_comments": 3},
|
|
69
|
+
emphasis_weights={
|
|
70
|
+
"polymarket": 1.50,
|
|
71
|
+
"jobs": 1.30,
|
|
72
|
+
"github": 1.20,
|
|
73
|
+
"grounding": 1.10,
|
|
74
|
+
},
|
|
75
|
+
),
|
|
76
|
+
"dev": _preset(
|
|
77
|
+
"dev",
|
|
78
|
+
section_order=(
|
|
79
|
+
"clusters",
|
|
80
|
+
"source_outcomes",
|
|
81
|
+
"source_coverage",
|
|
82
|
+
"hiring_signals",
|
|
83
|
+
"stats",
|
|
84
|
+
"top_comments",
|
|
85
|
+
"best_takes",
|
|
86
|
+
),
|
|
87
|
+
item_budgets={"clusters": 10, "best_takes": 3, "top_comments": 4},
|
|
88
|
+
emphasis_weights={
|
|
89
|
+
"github": 1.60,
|
|
90
|
+
"hackernews": 1.35,
|
|
91
|
+
"arxiv": 1.30,
|
|
92
|
+
"devto": 1.25,
|
|
93
|
+
"grounding": 1.10,
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
"creator": _preset(
|
|
97
|
+
"creator",
|
|
98
|
+
section_order=(
|
|
99
|
+
"best_takes",
|
|
100
|
+
"top_comments",
|
|
101
|
+
"stats",
|
|
102
|
+
"clusters",
|
|
103
|
+
"hiring_signals",
|
|
104
|
+
"source_outcomes",
|
|
105
|
+
"source_coverage",
|
|
106
|
+
),
|
|
107
|
+
item_budgets={"clusters": 6, "best_takes": 5, "top_comments": 8},
|
|
108
|
+
emphasis_weights={
|
|
109
|
+
"tiktok": 1.60,
|
|
110
|
+
"instagram": 1.50,
|
|
111
|
+
"youtube": 1.40,
|
|
112
|
+
"x": 1.20,
|
|
113
|
+
"reddit": 1.10,
|
|
114
|
+
},
|
|
115
|
+
),
|
|
116
|
+
# ELI5 historically changed only the agent's prose. Keep the renderer
|
|
117
|
+
# descriptor identical to default and express its voice in SKILL.md.
|
|
118
|
+
"eli5": _preset("eli5"),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
REGISTER_NAMES = tuple(_REGISTERS)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_register(name: str | None = None) -> AudienceRegister:
|
|
125
|
+
"""Return a named register, rejecting unsupported/free-form templates."""
|
|
126
|
+
|
|
127
|
+
normalized = (name or "default").strip().lower()
|
|
128
|
+
try:
|
|
129
|
+
return _REGISTERS[normalized]
|
|
130
|
+
except KeyError as exc:
|
|
131
|
+
choices = ", ".join(REGISTER_NAMES)
|
|
132
|
+
raise ValueError(
|
|
133
|
+
f"unknown audience register {name!r}; choose one of: {choices}"
|
|
134
|
+
) from exc
|
|
@@ -59,6 +59,19 @@ SYNONYMS = {
|
|
|
59
59
|
|
|
60
60
|
# Generic query words that should not carry relevance on their own.
|
|
61
61
|
# They still help when paired with stronger entity/topic matches.
|
|
62
|
+
#
|
|
63
|
+
# The second group is scaffolding emitted by planner's ranking-query templates
|
|
64
|
+
# ("What recent evidence from the last 30 days is most relevant to X?" and its
|
|
65
|
+
# siblings). Those words are not the topic, but every one of them was being
|
|
66
|
+
# counted as an informative query token, which capped achievable coverage at the
|
|
67
|
+
# topic's share of the query and demoted on-topic posts. Kept here rather than
|
|
68
|
+
# stripped in the planner so any caller building a similar natural-language
|
|
69
|
+
# ranking query gets the same treatment.
|
|
70
|
+
#
|
|
71
|
+
# Domain nouns from those same templates (production, market, workflows,
|
|
72
|
+
# experience, signals, ...) are deliberately absent: they can legitimately be a
|
|
73
|
+
# user's topic, and demoting them globally would hurt every source.
|
|
74
|
+
# tests/test_ranking_query_scaffolding.py pins that split.
|
|
62
75
|
LOW_SIGNAL_QUERY_TOKENS = frozenset({
|
|
63
76
|
'advice', 'animation', 'animations', 'best', 'chance', 'chances',
|
|
64
77
|
'code', 'compare', 'comparison', 'differences', 'explain', 'guide',
|
|
@@ -67,6 +80,10 @@ LOW_SIGNAL_QUERY_TOKENS = frozenset({
|
|
|
67
80
|
'prompting', 'prompts', 'rate', 'review', 'reviews', 'thoughts',
|
|
68
81
|
'tip', 'tips', 'tutorial', 'tutorials', 'update', 'updates', 'use',
|
|
69
82
|
'using', 'versus', 'vs', 'worth',
|
|
83
|
+
# planner ranking-query scaffolding
|
|
84
|
+
'30', 'current', 'days', 'describing', 'especially', 'evidence', 'exist',
|
|
85
|
+
'follow', 'hands', 'last', 'matter', 'most', 'new', 'people', 'real',
|
|
86
|
+
'recent', 'relevant', 'running', 'up', 'world',
|
|
70
87
|
})
|
|
71
88
|
|
|
72
89
|
|