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
|
@@ -15,12 +15,24 @@ Default-on safety (three gates):
|
|
|
15
15
|
company/brand -- a domain-like token, or a short (<=2-word) capitalized
|
|
16
16
|
proper noun. Generic phrases ("AI coding agents", "agent memory") and
|
|
17
17
|
longer multi-word phrases never call the CLI, so Trustpilot stays quiet --
|
|
18
|
-
and never harvests Chrome -- on non-company topics.
|
|
18
|
+
and never harvests Chrome -- on non-company topics. An explicit resolved
|
|
19
|
+
domain (``--trustpilot-domain`` or an auto-resolve hint) bypasses this
|
|
20
|
+
gate: an explicit domain is proof of brand intent.
|
|
19
21
|
2. Browser opt-out. Automated contexts (cron, CI, the eval harness) can set
|
|
20
22
|
``LAST30DAYS_TRUSTPILOT_NO_BROWSER`` to disable the source entirely, so a
|
|
21
23
|
headless run never spawns the cookie harvest.
|
|
22
24
|
3. Graceful degradation. Any CLI failure (no Chrome, expired cookie that
|
|
23
25
|
cannot re-harvest, timeout) degrades to empty results, never an error.
|
|
26
|
+
|
|
27
|
+
Domain resolution: Trustpilot review pages are keyed by domain
|
|
28
|
+
(``www.thriftbooks.com``), not company name -- ``info ThriftBooks`` 404s.
|
|
29
|
+
Priority chain: user flag (verbatim) > auto-resolve hint (retries via search
|
|
30
|
+
on a miss) > domain token in the topic > CLI ``search`` name->domain lookup
|
|
31
|
+
(cached per topic) > cleaned topic (legacy behavior).
|
|
32
|
+
|
|
33
|
+
Session pre-flight: ``ensure_session_ready`` performs one serialized
|
|
34
|
+
``auth status`` / ``auth login`` before the parallel fan-out so concurrent
|
|
35
|
+
streams and vs-mode sub-runs never race their own Chrome harvests.
|
|
24
36
|
"""
|
|
25
37
|
|
|
26
38
|
from __future__ import annotations
|
|
@@ -29,6 +41,8 @@ import json
|
|
|
29
41
|
import os
|
|
30
42
|
import re
|
|
31
43
|
import shutil
|
|
44
|
+
import threading
|
|
45
|
+
import time
|
|
32
46
|
from typing import Any, Dict, List, Optional
|
|
33
47
|
|
|
34
48
|
from . import dates, log, subproc
|
|
@@ -39,8 +53,16 @@ CLI_BIN = "trustpilot-pp-cli"
|
|
|
39
53
|
|
|
40
54
|
SEARCH_TIMEOUT = 75 # generous: a cold run may harvest a WAF cookie (~10s).
|
|
41
55
|
|
|
56
|
+
AUTH_STATUS_TIMEOUT = 20 # auth status is a local SQLite read; fast.
|
|
57
|
+
|
|
42
58
|
NO_BROWSER_ENV = "LAST30DAYS_TRUSTPILOT_NO_BROWSER"
|
|
43
59
|
|
|
60
|
+
# Among name-matching search hits, the top hit must have this many times the
|
|
61
|
+
# runner-up's review volume to win automatically. Lookalike/squatter pages have
|
|
62
|
+
# tiny volume (ThriftBooks: 2.8M vs 130); comparable volume means genuine
|
|
63
|
+
# ambiguity, where falling back beats silently picking the wrong company.
|
|
64
|
+
DOMAIN_DOMINANCE_FACTOR = 50
|
|
65
|
+
|
|
44
66
|
# Domain-like token, e.g. "chownow.com", "nothing.tech".
|
|
45
67
|
_DOMAIN_RE = re.compile(r"\b[a-z0-9][a-z0-9-]*\.(com|io|co|net|org|app|ai|dev|gg|tech|shop|store)\b")
|
|
46
68
|
|
|
@@ -132,6 +154,192 @@ def _build_info_args(identifier: str) -> List[str]:
|
|
|
132
154
|
return [CLI_BIN, "info", identifier, "--agent"]
|
|
133
155
|
|
|
134
156
|
|
|
157
|
+
def _normalize_name(text: str) -> str:
|
|
158
|
+
"""Case/whitespace/punctuation-insensitive brand-name key."""
|
|
159
|
+
return re.sub(r"[^a-z0-9]", "", (text or "").lower())
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# name->domain results, keyed by normalized topic. Per-topic (NOT a single
|
|
163
|
+
# process-wide slot): vs-mode resolves several entities in one process, and a
|
|
164
|
+
# single slot would serve entity A's domain to entity B.
|
|
165
|
+
_domain_cache: Dict[str, Optional[str]] = {}
|
|
166
|
+
_domain_cache_lock = threading.Lock()
|
|
167
|
+
|
|
168
|
+
_warmup_lock = threading.Lock()
|
|
169
|
+
_warmup_at: Optional[float] = None # time.monotonic() of the last warm-up
|
|
170
|
+
|
|
171
|
+
# Warm-up freshness window, matching the CLI's ~4-minute safe replay bound for
|
|
172
|
+
# WAF tokens. A boolean-forever flag would leave long-lived host processes
|
|
173
|
+
# running stale (and never retrying a failed login); the TTL re-checks cheaply
|
|
174
|
+
# via `auth status` once the window lapses.
|
|
175
|
+
WARMUP_TTL_SECONDS = 240
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _reset_state_for_tests() -> None:
|
|
179
|
+
"""Clear module-level caches/flags (tests only)."""
|
|
180
|
+
global _warmup_at
|
|
181
|
+
with _domain_cache_lock:
|
|
182
|
+
_domain_cache.clear()
|
|
183
|
+
_warmup_at = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _warmup_fresh() -> bool:
|
|
187
|
+
return _warmup_at is not None and (time.monotonic() - _warmup_at) < WARMUP_TTL_SECONDS
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _select_search_hit(topic: str, hits: List[Any]) -> Optional[str]:
|
|
191
|
+
"""Pick the canonical domain from search hits, or None when ambiguous.
|
|
192
|
+
|
|
193
|
+
Name-match is mandatory: review volume must never override a name
|
|
194
|
+
mismatch, or the engine attributes another company's reviews to the topic.
|
|
195
|
+
Among name-matching hits the winner must dominate on review volume
|
|
196
|
+
(DOMAIN_DOMINANCE_FACTOR); comparable volume is genuine ambiguity and
|
|
197
|
+
falls back to legacy behavior.
|
|
198
|
+
"""
|
|
199
|
+
want = _normalize_name(topic)
|
|
200
|
+
if not want:
|
|
201
|
+
return None
|
|
202
|
+
matching: List[tuple[int, str]] = []
|
|
203
|
+
for hit in hits:
|
|
204
|
+
if not isinstance(hit, dict):
|
|
205
|
+
continue
|
|
206
|
+
domain = str(hit.get("domain") or hit.get("identifyingName") or "").strip()
|
|
207
|
+
name = str(hit.get("displayName") or hit.get("name") or "").strip()
|
|
208
|
+
if not domain or _normalize_name(name) != want:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
count = int(hit.get("numberOfReviews") or 0)
|
|
212
|
+
except (TypeError, ValueError):
|
|
213
|
+
count = 0
|
|
214
|
+
matching.append((count, domain))
|
|
215
|
+
if not matching:
|
|
216
|
+
top = next(
|
|
217
|
+
(str(h.get("domain") or "").strip() for h in hits
|
|
218
|
+
if isinstance(h, dict) and h.get("domain")),
|
|
219
|
+
"",
|
|
220
|
+
)
|
|
221
|
+
if top:
|
|
222
|
+
_log(
|
|
223
|
+
f"no name-matching search hit; top candidate was '{top}' - "
|
|
224
|
+
f"pass --trustpilot-domain to target it"
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
matching.sort(reverse=True)
|
|
228
|
+
if len(matching) == 1:
|
|
229
|
+
return matching[0][1]
|
|
230
|
+
top_count, top_domain = matching[0]
|
|
231
|
+
runner_count, runner_domain = matching[1]
|
|
232
|
+
if top_count >= max(1, runner_count) * DOMAIN_DOMINANCE_FACTOR:
|
|
233
|
+
return top_domain
|
|
234
|
+
_log(
|
|
235
|
+
f"ambiguous search hits ('{top_domain}' vs '{runner_domain}'); "
|
|
236
|
+
f"falling back - pass --trustpilot-domain to disambiguate"
|
|
237
|
+
)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _search_domain(topic: str) -> Optional[str]:
|
|
242
|
+
"""Resolve a company name to its Trustpilot domain via the CLI's search.
|
|
243
|
+
|
|
244
|
+
Cached per normalized topic (thread-safe), so repeat lookups cost one
|
|
245
|
+
subprocess while vs-mode entities still resolve independently. Only
|
|
246
|
+
definitive results are cached: a transient CLI failure (timeout, spawn
|
|
247
|
+
error, malformed JSON) returns None WITHOUT caching, so one flaky search
|
|
248
|
+
does not suppress resolution for this topic for the rest of the process.
|
|
249
|
+
"""
|
|
250
|
+
key = _normalize_name(topic)
|
|
251
|
+
if not key:
|
|
252
|
+
return None
|
|
253
|
+
with _domain_cache_lock:
|
|
254
|
+
if key in _domain_cache:
|
|
255
|
+
return _domain_cache[key]
|
|
256
|
+
data = _run_cli(
|
|
257
|
+
[CLI_BIN, "search", topic.strip(), "--limit", "5", "--agent"],
|
|
258
|
+
timeout=SEARCH_TIMEOUT,
|
|
259
|
+
)
|
|
260
|
+
if not isinstance(data, dict) or "error" in data:
|
|
261
|
+
return None # transient failure: retry on the next lookup
|
|
262
|
+
hits = data.get("hits")
|
|
263
|
+
if not isinstance(hits, list):
|
|
264
|
+
# Degenerate payload (e.g. empty stdout parses to {}): not a
|
|
265
|
+
# definitive no-match -- do not cache, retry on the next lookup.
|
|
266
|
+
return None
|
|
267
|
+
domain = _select_search_hit(topic, hits)
|
|
268
|
+
if domain:
|
|
269
|
+
_log(f"resolved '{topic}' -> '{domain}' via search")
|
|
270
|
+
with _domain_cache_lock:
|
|
271
|
+
_domain_cache[key] = domain
|
|
272
|
+
return domain
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_session_fresh(status: Dict[str, Any]) -> bool:
|
|
276
|
+
"""Read the freshness signal from an ``auth status --agent`` payload."""
|
|
277
|
+
if not isinstance(status, dict) or "error" in status:
|
|
278
|
+
return False
|
|
279
|
+
containers: List[Dict[str, Any]] = [status]
|
|
280
|
+
session = status.get("session")
|
|
281
|
+
if isinstance(session, dict):
|
|
282
|
+
containers.append(session)
|
|
283
|
+
for container in containers:
|
|
284
|
+
for key in ("isFresh", "fresh"):
|
|
285
|
+
if key in container:
|
|
286
|
+
return bool(container[key])
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def ensure_session_ready(
|
|
291
|
+
topic: str,
|
|
292
|
+
config: Optional[Dict[str, Any]] = None,
|
|
293
|
+
has_domain: bool = False,
|
|
294
|
+
) -> None:
|
|
295
|
+
"""Warm the CLI's WAF session, serialized, at the first Trustpilot fetch.
|
|
296
|
+
|
|
297
|
+
Called from ``search_trustpilot`` (never from the pipeline's fan-out
|
|
298
|
+
setup), so it only ever delays the one capped Trustpilot stream, never
|
|
299
|
+
the other sources -- and never fires for runs whose plan fetches no
|
|
300
|
+
Trustpilot at all. The module lock serializes concurrent streams (vs-mode
|
|
301
|
+
fans out up to 6 entity sub-runs) so they never race their own Chrome
|
|
302
|
+
harvests. Freshness is a monotonic TTL (WARMUP_TTL_SECONDS, matching the
|
|
303
|
+
CLI's ~4-minute token bound), not a boolean-forever flag: long-lived host
|
|
304
|
+
processes re-check via ``auth status`` after the window lapses, which
|
|
305
|
+
also retries a previously failed login. ``auth login`` fires only when
|
|
306
|
+
``auth status`` reports the session missing or stale -- login always
|
|
307
|
+
harvests (~10s Chrome), it has no freshness no-op. Logs only structured
|
|
308
|
+
status strings; the raw CLI payload carries live WAF-token prefixes and
|
|
309
|
+
must never be logged. Never raises.
|
|
310
|
+
"""
|
|
311
|
+
global _warmup_at
|
|
312
|
+
if _warmup_fresh():
|
|
313
|
+
return
|
|
314
|
+
if not _is_available():
|
|
315
|
+
return
|
|
316
|
+
if not _harvest_allowed(config):
|
|
317
|
+
return
|
|
318
|
+
if not has_domain and not is_brand_shaped(topic):
|
|
319
|
+
return
|
|
320
|
+
with _warmup_lock:
|
|
321
|
+
if _warmup_fresh():
|
|
322
|
+
return
|
|
323
|
+
status = _run_cli(
|
|
324
|
+
[CLI_BIN, "auth", "status", "--agent"], timeout=AUTH_STATUS_TIMEOUT
|
|
325
|
+
)
|
|
326
|
+
if _is_session_fresh(status):
|
|
327
|
+
_log("warm-up: fresh")
|
|
328
|
+
_warmup_at = time.monotonic()
|
|
329
|
+
return
|
|
330
|
+
# Missing session exits non-zero (an error dict here): that is the
|
|
331
|
+
# "login needed" signal, not a warm-up failure.
|
|
332
|
+
login = _run_cli([CLI_BIN, "auth", "login", "--agent"], timeout=SEARCH_TIMEOUT)
|
|
333
|
+
if isinstance(login, dict) and "error" in login:
|
|
334
|
+
_log("warm-up failed: auth login did not complete")
|
|
335
|
+
else:
|
|
336
|
+
_log("warm-up: harvested")
|
|
337
|
+
# Stamp even on failure: a broken Chrome will not fix itself within
|
|
338
|
+
# the TTL, per-call CLI auto-harvest remains the fallback, and the
|
|
339
|
+
# TTL lapse retries the warm-up later.
|
|
340
|
+
_warmup_at = time.monotonic()
|
|
341
|
+
|
|
342
|
+
|
|
135
343
|
def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
|
|
136
344
|
"""Invoke trustpilot-pp-cli and parse the JSON object. Never raises."""
|
|
137
345
|
if not _is_available():
|
|
@@ -171,23 +379,66 @@ def search_trustpilot(
|
|
|
171
379
|
to_date: str,
|
|
172
380
|
depth: str = "default",
|
|
173
381
|
config: Optional[Dict[str, Any]] = None,
|
|
382
|
+
explicit_domain: Optional[str] = None,
|
|
383
|
+
domain_is_hint: bool = False,
|
|
174
384
|
) -> Dict[str, Any]:
|
|
175
385
|
"""Look up a company's Trustpilot sentiment, gated on a brand-shaped topic.
|
|
176
386
|
|
|
387
|
+
``explicit_domain`` is used verbatim as the CLI identifier and bypasses
|
|
388
|
+
the brand-shape gate (an explicit domain is proof of brand intent). A
|
|
389
|
+
user-set domain is verbatim-final; a resolved hint
|
|
390
|
+
(``domain_is_hint=True``) retries via the CLI search when the lookup
|
|
391
|
+
misses, since auto-resolve can guess a plausible-but-wrong domain (the
|
|
392
|
+
official site is not always Trustpilot's canonical identifyingName).
|
|
393
|
+
|
|
394
|
+
Without an explicit domain, a bare company name resolves via the CLI's
|
|
395
|
+
``search`` (cached per topic) before falling back to the cleaned topic.
|
|
396
|
+
|
|
177
397
|
Returns ``{"results": [info_dict]}`` for a resolved company, or
|
|
178
|
-
``{"results": []}`` when the topic is not brand-shaped, the browser
|
|
179
|
-
is set, or the CLI fails.
|
|
398
|
+
``{"results": []}`` when the topic is not brand-shaped, the browser
|
|
399
|
+
opt-out is set, or the CLI fails.
|
|
180
400
|
"""
|
|
181
|
-
|
|
401
|
+
explicit_domain = (explicit_domain or "").strip() or None
|
|
402
|
+
user_domain = bool(explicit_domain) and not domain_is_hint
|
|
403
|
+
# Only a USER-set domain proves brand intent and bypasses the brand-shape
|
|
404
|
+
# gate. An auto-resolved hint must not widen activation beyond
|
|
405
|
+
# brand-shaped topics, or a generic topic that happens to yield a hint
|
|
406
|
+
# would trigger a Chrome harvest -- violating the module's documented
|
|
407
|
+
# "never harvests on non-company topics" contract.
|
|
408
|
+
if not user_domain and not is_brand_shaped(topic):
|
|
182
409
|
return {"results": []}
|
|
183
410
|
if not _is_available():
|
|
184
411
|
return {"results": [], "error": f"{CLI_BIN} not on PATH"}
|
|
185
412
|
if not _harvest_allowed(config):
|
|
186
413
|
_log("skipped: browser opt-out set")
|
|
187
414
|
return {"results": []}
|
|
188
|
-
|
|
415
|
+
# Serialized session check at first source touch (all gates above have
|
|
416
|
+
# passed, so this never fires for topics the source would not fetch).
|
|
417
|
+
ensure_session_ready(topic, config=config, has_domain=bool(explicit_domain))
|
|
418
|
+
# Retry-budget timer starts AFTER the warm-up: a slow Chrome harvest must
|
|
419
|
+
# not consume the hint-retry budget when the info call itself was fast.
|
|
420
|
+
started = time.monotonic()
|
|
421
|
+
if explicit_domain:
|
|
422
|
+
identifier = explicit_domain
|
|
423
|
+
else:
|
|
424
|
+
identifier = _company_identifier(topic)
|
|
425
|
+
if not _DOMAIN_RE.search(topic.lower()):
|
|
426
|
+
# No domain token in the topic: Trustpilot pages are keyed by
|
|
427
|
+
# domain, so resolve name -> domain before the info lookup.
|
|
428
|
+
identifier = _search_domain(topic) or identifier
|
|
189
429
|
_log(f"info '{identifier}'")
|
|
190
430
|
data = _run_cli(_build_info_args(identifier), timeout=SEARCH_TIMEOUT)
|
|
431
|
+
if ("error" in data or not data) and explicit_domain and domain_is_hint:
|
|
432
|
+
# The auto-resolved hint missed. Only user-set flags are
|
|
433
|
+
# verbatim-final; a hint falls through to the search resolution.
|
|
434
|
+
# Skip the retry chain when the first lookup already consumed a full
|
|
435
|
+
# single-call budget (hung CLI) -- one stream must not chain three
|
|
436
|
+
# sequential SEARCH_TIMEOUT-bound subprocesses.
|
|
437
|
+
if time.monotonic() - started < SEARCH_TIMEOUT:
|
|
438
|
+
resolved = _search_domain(topic)
|
|
439
|
+
if resolved and resolved != identifier:
|
|
440
|
+
_log(f"hint '{identifier}' missed; retrying via search as '{resolved}'")
|
|
441
|
+
data = _run_cli(_build_info_args(resolved), timeout=SEARCH_TIMEOUT)
|
|
191
442
|
if "error" in data or not data:
|
|
192
443
|
return {"results": []}
|
|
193
444
|
return {"results": [data]}
|
|
@@ -89,7 +89,15 @@ def search_truthsocial(
|
|
|
89
89
|
try:
|
|
90
90
|
response = http.request(
|
|
91
91
|
"GET", url,
|
|
92
|
-
headers={
|
|
92
|
+
headers={
|
|
93
|
+
"Authorization": f"Bearer {token}",
|
|
94
|
+
# Cloudflare 403s the skill's default User-Agent regardless of token validity (#909).
|
|
95
|
+
# Reuse http.BROWSER_USER_AGENT, as the keyless Reddit path does.
|
|
96
|
+
"User-Agent": http.BROWSER_USER_AGENT,
|
|
97
|
+
"Accept": "application/json, text/plain, */*",
|
|
98
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
99
|
+
"Referer": "https://truthsocial.com/",
|
|
100
|
+
},
|
|
93
101
|
timeout=30,
|
|
94
102
|
)
|
|
95
103
|
except http.HTTPError as e:
|
|
@@ -121,6 +121,7 @@ SOURCE_COMPLETION_ORDER = [
|
|
|
121
121
|
"tiktok",
|
|
122
122
|
"instagram",
|
|
123
123
|
"hackernews",
|
|
124
|
+
"devto",
|
|
124
125
|
"bluesky",
|
|
125
126
|
"truthsocial",
|
|
126
127
|
"polymarket",
|
|
@@ -130,6 +131,8 @@ SOURCE_COMPLETION_ORDER = [
|
|
|
130
131
|
"arxiv",
|
|
131
132
|
"techmeme",
|
|
132
133
|
"trustpilot",
|
|
134
|
+
"amazon",
|
|
135
|
+
"meta_ads",
|
|
133
136
|
]
|
|
134
137
|
|
|
135
138
|
SOURCE_COMPLETION_META = {
|
|
@@ -139,6 +142,7 @@ SOURCE_COMPLETION_META = {
|
|
|
139
142
|
"tiktok": ("TikTok", "video", "videos", Colors.PURPLE),
|
|
140
143
|
"instagram": ("Instagram", "reel", "reels", Colors.PURPLE),
|
|
141
144
|
"hackernews": ("HN", "story", "stories", Colors.YELLOW),
|
|
145
|
+
"devto": ("dev.to", "article", "articles", Colors.GREEN),
|
|
142
146
|
"bluesky": ("Bluesky", "post", "posts", Colors.BLUE),
|
|
143
147
|
"truthsocial": ("Truth Social", "post", "posts", Colors.CYAN),
|
|
144
148
|
"polymarket": ("Polymarket", "market", "markets", Colors.GREEN),
|
|
@@ -148,6 +152,8 @@ SOURCE_COMPLETION_META = {
|
|
|
148
152
|
"arxiv": ("arXiv", "paper", "papers", Colors.RED),
|
|
149
153
|
"techmeme": ("Techmeme", "headline", "headlines", Colors.CYAN),
|
|
150
154
|
"trustpilot": ("Trustpilot", "review", "reviews", Colors.GREEN),
|
|
155
|
+
"amazon": ("Amazon", "product", "products", Colors.YELLOW),
|
|
156
|
+
"meta_ads": ("Meta Ads", "creative", "creatives", Colors.BLUE),
|
|
151
157
|
}
|
|
152
158
|
|
|
153
159
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Provider-reported charges for a scoped watchlist subprocess."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import uuid
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
JOURNAL_ENV = "LAST30DAYS_USAGE_JOURNAL"
|
|
14
|
+
|
|
15
|
+
_PAID_HOSTS = {
|
|
16
|
+
"openrouter.ai": "openrouter",
|
|
17
|
+
"api.perplexity.ai": "perplexity",
|
|
18
|
+
"api.openai.com": "openai",
|
|
19
|
+
"api.x.ai": "xai",
|
|
20
|
+
"generativelanguage.googleapis.com": "gemini",
|
|
21
|
+
"api.scrapecreators.com": "scrapecreators",
|
|
22
|
+
"api.search.brave.com": "brave",
|
|
23
|
+
"api.tavily.com": "tavily",
|
|
24
|
+
"api.exa.ai": "exa",
|
|
25
|
+
"google.serper.dev": "serper",
|
|
26
|
+
"api.parallel.ai": "parallel",
|
|
27
|
+
"api.x.com": "x",
|
|
28
|
+
"api.twitter.com": "x",
|
|
29
|
+
"xquik.com": "xquik",
|
|
30
|
+
"api.groq.com": "groq",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextmanager
|
|
35
|
+
def _connection(path):
|
|
36
|
+
conn = sqlite3.connect(path)
|
|
37
|
+
try:
|
|
38
|
+
with conn:
|
|
39
|
+
yield conn
|
|
40
|
+
finally:
|
|
41
|
+
conn.close()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def create_journal(path: Path) -> None:
|
|
45
|
+
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
46
|
+
os.close(fd)
|
|
47
|
+
with _connection(path) as conn:
|
|
48
|
+
conn.execute(
|
|
49
|
+
"CREATE TABLE attempts (id TEXT PRIMARY KEY, provider TEXT NOT NULL, "
|
|
50
|
+
"cost REAL, unknown INTEGER NOT NULL DEFAULT 1, "
|
|
51
|
+
"prompt_tokens INTEGER DEFAULT 0, completion_tokens INTEGER DEFAULT 0)"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def begin(provider: str) -> tuple[str, str, str] | None:
|
|
56
|
+
path = os.environ.get(JOURNAL_ENV)
|
|
57
|
+
if not path:
|
|
58
|
+
return None
|
|
59
|
+
attempt_id = uuid.uuid4().hex
|
|
60
|
+
with _connection(path) as conn:
|
|
61
|
+
conn.execute("INSERT INTO attempts (id, provider) VALUES (?, ?)", (attempt_id, provider))
|
|
62
|
+
return path, attempt_id, provider
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def begin_http(url: str, method: str) -> tuple[str, str, str] | None:
|
|
66
|
+
if not os.environ.get(JOURNAL_ENV):
|
|
67
|
+
return None
|
|
68
|
+
parts = urlsplit(url)
|
|
69
|
+
provider = _PAID_HOSTS.get(parts.hostname)
|
|
70
|
+
if provider == "perplexity" and method == "GET":
|
|
71
|
+
return None # Retrieving an existing background operation does not start another charge.
|
|
72
|
+
if provider is None:
|
|
73
|
+
for name in ("OPENAI", "XAI", "OPENROUTER"):
|
|
74
|
+
override = os.environ.get(f"{name}_BASE_URL")
|
|
75
|
+
if override and urlsplit(override).netloc == parts.netloc:
|
|
76
|
+
provider = name.lower()
|
|
77
|
+
break
|
|
78
|
+
return begin(provider) if provider else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cancel(attempt: tuple[str, str, str] | None) -> None:
|
|
82
|
+
"""Remove an attempt when the transport provably never started."""
|
|
83
|
+
if attempt is not None:
|
|
84
|
+
path, attempt_id, _ = attempt
|
|
85
|
+
with _connection(path) as conn:
|
|
86
|
+
conn.execute("DELETE FROM attempts WHERE id = ?", (attempt_id,))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def finish(attempt: tuple[str, str, str] | None, payload: object) -> None:
|
|
90
|
+
if attempt is None or not isinstance(payload, dict):
|
|
91
|
+
return
|
|
92
|
+
path, attempt_id, provider = attempt
|
|
93
|
+
usage = payload.get("usage")
|
|
94
|
+
if not isinstance(usage, dict):
|
|
95
|
+
return
|
|
96
|
+
cost = usage.get("cost")
|
|
97
|
+
if provider == "perplexity":
|
|
98
|
+
if not isinstance(cost, dict) or cost.get("currency") != "USD":
|
|
99
|
+
return
|
|
100
|
+
cost = cost.get("total_cost")
|
|
101
|
+
elif provider != "openrouter":
|
|
102
|
+
return
|
|
103
|
+
if isinstance(cost, bool) or not isinstance(cost, (int, float)) or not math.isfinite(cost) or cost < 0:
|
|
104
|
+
return
|
|
105
|
+
# A background response may expose a running subtotal rather than its final bill.
|
|
106
|
+
unknown = int(payload.get("status") in {"queued", "in_progress"})
|
|
107
|
+
prompt = usage.get("prompt_tokens", usage.get("input_tokens", 0))
|
|
108
|
+
completion = usage.get("completion_tokens", usage.get("output_tokens", 0))
|
|
109
|
+
prompt = prompt if type(prompt) is int and prompt >= 0 else 0
|
|
110
|
+
completion = completion if type(completion) is int and completion >= 0 else 0
|
|
111
|
+
with _connection(path) as conn:
|
|
112
|
+
conn.execute(
|
|
113
|
+
"UPDATE attempts SET cost = ?, unknown = ?, prompt_tokens = ?, completion_tokens = ? WHERE id = ?",
|
|
114
|
+
(cost, unknown, prompt, completion, attempt_id),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def read_journal(path: Path) -> dict:
|
|
119
|
+
try:
|
|
120
|
+
with _connection(path) as conn:
|
|
121
|
+
row = conn.execute(
|
|
122
|
+
"SELECT COALESCE(SUM(cost), 0), COALESCE(SUM(unknown), 0), "
|
|
123
|
+
"COALESCE(SUM(prompt_tokens), 0), COALESCE(SUM(completion_tokens), 0) FROM attempts"
|
|
124
|
+
).fetchone()
|
|
125
|
+
except sqlite3.Error:
|
|
126
|
+
return {"token_cost": 0.0, "cost_unknown": 1, "prompt_tokens": 0, "completion_tokens": 0}
|
|
127
|
+
return {
|
|
128
|
+
"token_cost": row[0],
|
|
129
|
+
"cost_unknown": int(row[1] > 0),
|
|
130
|
+
"prompt_tokens": row[2],
|
|
131
|
+
"completion_tokens": row[3],
|
|
132
|
+
}
|
|
@@ -9,10 +9,12 @@ It must never run on a host that has native search (the model does it better
|
|
|
9
9
|
there) or preempt a configured paid backend. The pipeline/grounding layer owns
|
|
10
10
|
that gating; this module just performs the search when asked.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Three vendor-neutral rungs, all stdlib-only via :mod:`http`:
|
|
13
13
|
1. DuckDuckGo HTML endpoint (no key, no instance to maintain).
|
|
14
|
-
2.
|
|
15
|
-
|
|
14
|
+
2. Startpage HTML results, tried when DuckDuckGo yields nothing — notably
|
|
15
|
+
when DuckDuckGo anomaly-blocks a datacenter IP with a 202 challenge page.
|
|
16
|
+
3. A configurable SearXNG instance returning JSON (``LAST30DAYS_SEARXNG_URL``),
|
|
17
|
+
tried when the HTML rungs yield nothing.
|
|
16
18
|
|
|
17
19
|
Never raises. Returns results in the same dict shape as the paid backends in
|
|
18
20
|
:mod:`grounding` so they flow through normalize/score/dedupe unchanged. On total
|
|
@@ -37,6 +39,10 @@ _DDG_HTML_URL = "https://html.duckduckgo.com/html/"
|
|
|
37
39
|
_KEYLESS_RELEVANCE = 0.6
|
|
38
40
|
|
|
39
41
|
_TAG_RE = re.compile(r"<[^>]+>")
|
|
42
|
+
# Strip <style>/<script> blocks *including their contents* before dropping tags,
|
|
43
|
+
# so inline CSS/JS text (e.g. Startpage's emotion styles) never leaks into a
|
|
44
|
+
# title or snippet.
|
|
45
|
+
_STYLE_SCRIPT_RE = re.compile(r"<(style|script)\b[^>]*>.*?</\1>", re.IGNORECASE | re.DOTALL)
|
|
40
46
|
_RESULT_A_RE = re.compile(
|
|
41
47
|
r'class="result__a"[^>]*href="(?P<href>[^"]+)"[^>]*>(?P<title>.*?)</a>',
|
|
42
48
|
re.IGNORECASE | re.DOTALL,
|
|
@@ -46,6 +52,21 @@ _SNIPPET_RE = re.compile(
|
|
|
46
52
|
re.IGNORECASE | re.DOTALL,
|
|
47
53
|
)
|
|
48
54
|
|
|
55
|
+
_STARTPAGE_HTML_URL = "https://www.startpage.com/sp/search"
|
|
56
|
+
# Startpage marks each organic hit with an <a class="result-title result-link …"
|
|
57
|
+
# href="<target>">…<h2 …>title</h2></a>, and the description in a following
|
|
58
|
+
# <p class="…description…">. Class names carry hashed emotion suffixes, so match
|
|
59
|
+
# on the stable "result-title" / "description" substrings.
|
|
60
|
+
_SP_RESULT_RE = re.compile(
|
|
61
|
+
r'<a\b[^>]*class="[^"]*result-title[^"]*"[^>]*href="(?P<href>https?://[^"]+)"[^>]*>(?P<inner>.*?)</a>',
|
|
62
|
+
re.IGNORECASE | re.DOTALL,
|
|
63
|
+
)
|
|
64
|
+
_SP_H2_RE = re.compile(r"<h2\b[^>]*>(?P<title>.*?)</h2>", re.IGNORECASE | re.DOTALL)
|
|
65
|
+
_SP_DESC_RE = re.compile(
|
|
66
|
+
r'<p\b[^>]*class="[^"]*description[^"]*"[^>]*>(?P<snippet>.*?)</p>',
|
|
67
|
+
re.IGNORECASE | re.DOTALL,
|
|
68
|
+
)
|
|
69
|
+
|
|
49
70
|
|
|
50
71
|
def _domain(url: str) -> str:
|
|
51
72
|
# Normalize identically to grounding._domain (strip + lowercase) so keyless
|
|
@@ -57,7 +78,8 @@ def _domain(url: str) -> str:
|
|
|
57
78
|
|
|
58
79
|
|
|
59
80
|
def _strip_html(fragment: str) -> str:
|
|
60
|
-
|
|
81
|
+
without_blocks = _STYLE_SCRIPT_RE.sub("", fragment or "")
|
|
82
|
+
return html.unescape(_TAG_RE.sub("", without_blocks)).strip()
|
|
61
83
|
|
|
62
84
|
|
|
63
85
|
def _unwrap_ddg_redirect(href: str) -> str:
|
|
@@ -81,6 +103,11 @@ def keyless_search(
|
|
|
81
103
|
"""Run keyless web search; returns (items, artifact). Never raises."""
|
|
82
104
|
items = _search_ddg(query, count)
|
|
83
105
|
used = "ddg"
|
|
106
|
+
if not items:
|
|
107
|
+
# DuckDuckGo anomaly-blocks datacenter IPs (202 challenge page); fall
|
|
108
|
+
# back to Startpage, which still serves organic results there.
|
|
109
|
+
items = _search_startpage(query, count)
|
|
110
|
+
used = "startpage"
|
|
84
111
|
if not items:
|
|
85
112
|
searxng_url = (config.get("LAST30DAYS_SEARXNG_URL") or "").strip()
|
|
86
113
|
if searxng_url:
|
|
@@ -123,6 +150,38 @@ def _search_ddg(query: str, count: int) -> list[dict]:
|
|
|
123
150
|
return items
|
|
124
151
|
|
|
125
152
|
|
|
153
|
+
def _search_startpage(query: str, count: int) -> list[dict]:
|
|
154
|
+
"""Keyless rung 2: Startpage's HTML results page. Unlike DuckDuckGo's HTML
|
|
155
|
+
endpoint (which anomaly-blocks datacenter IPs with a 202 challenge page),
|
|
156
|
+
Startpage returns organic results to a plain browser-UA GET, making it the
|
|
157
|
+
working floor on hosts DuckDuckGo refuses. Never raises."""
|
|
158
|
+
url = f"{_STARTPAGE_HTML_URL}?{urlencode({'query': query})}"
|
|
159
|
+
text = http.get_text(url, accept="text/html", retries=2)
|
|
160
|
+
if not text:
|
|
161
|
+
return []
|
|
162
|
+
items: list[dict] = []
|
|
163
|
+
result_matches = list(_SP_RESULT_RE.finditer(text))
|
|
164
|
+
desc_matches = list(_SP_DESC_RE.finditer(text))
|
|
165
|
+
for match in result_matches:
|
|
166
|
+
if len(items) >= count:
|
|
167
|
+
break
|
|
168
|
+
target = html.unescape(match.group("href"))
|
|
169
|
+
if not target.startswith("http"):
|
|
170
|
+
continue
|
|
171
|
+
h2 = _SP_H2_RE.search(match.group("inner"))
|
|
172
|
+
title = _strip_html(h2.group("title") if h2 else match.group("inner"))
|
|
173
|
+
if not title:
|
|
174
|
+
continue
|
|
175
|
+
# First description block that appears after this result's title anchor.
|
|
176
|
+
snippet = ""
|
|
177
|
+
for desc in desc_matches:
|
|
178
|
+
if desc.start() > match.end():
|
|
179
|
+
snippet = _strip_html(desc.group("snippet"))
|
|
180
|
+
break
|
|
181
|
+
items.append(_to_item(len(items), title, target, snippet))
|
|
182
|
+
return items
|
|
183
|
+
|
|
184
|
+
|
|
126
185
|
def _search_searxng(query: str, count: int, instance_url: str) -> list[dict]:
|
|
127
186
|
base = instance_url.rstrip("/")
|
|
128
187
|
url = f"{base}/search?{urlencode({'q': query, 'format': 'json'})}"
|