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,455 @@
|
|
|
1
|
+
"""Live Chrome cookie reader over the DevTools Protocol (CDP).
|
|
2
|
+
|
|
3
|
+
An EXTRA-host cookie lookup for the bird backend: when a Chrome/Chromium
|
|
4
|
+
instance is running with a remote-debugging endpoint and the user is signed
|
|
5
|
+
into x.com in it, that live session holds the ``auth_token`` + ``ct0`` cookies
|
|
6
|
+
bird needs — even on Linux, where the on-disk cookie store cannot be decrypted
|
|
7
|
+
here. This module talks to that endpoint and pulls the pair via
|
|
8
|
+
``Network.getAllCookies``.
|
|
9
|
+
|
|
10
|
+
Deliberate constraints (see docs/plans/2026-08-31 X plan):
|
|
11
|
+
|
|
12
|
+
* **Extras only.** The engine only calls this on extra hosts (Linux, Mac mini,
|
|
13
|
+
Darwin agentcookie sink, or ``AGENTCOOKIE=on``); the gating lives in
|
|
14
|
+
``env.x_extras_enabled``. On a plain MacBook this is never called, so no
|
|
15
|
+
socket is opened (AE8).
|
|
16
|
+
* **No port scan.** Endpoint resolution is: ``BROWSER_CDP_URL`` if set, else
|
|
17
|
+
port ``18800`` if it answers as Chrome, else ``9222`` + the X display number.
|
|
18
|
+
No 9222..9232 sweep. ``18800`` is NOT box-chrome's built-in default (that is
|
|
19
|
+
``9222`` + the display number); it is the last30days extras NUX convention —
|
|
20
|
+
the agent launches the throwaway login Chrome with
|
|
21
|
+
``SAND_CHROME_REMOTE_DEBUG_PORT=18800`` (see SKILL.md), so a leftover daily
|
|
22
|
+
profile on ``9222``+display is not mistaken for the login session. If ``18800``
|
|
23
|
+
answers with no complete pair we fall through; if it answers with a stale or
|
|
24
|
+
wrong pair, pin ``BROWSER_CDP_URL`` after the NUX rather than scanning.
|
|
25
|
+
* **Require a Chrome page target.** ``/json/version`` must report a Chrome /
|
|
26
|
+
Chromium browser (a Node inspector is rejected) and ``/json`` must expose a
|
|
27
|
+
``page`` target.
|
|
28
|
+
* **Explicit consent required.** ``BROWSER_CONSENT=true`` or an explicit
|
|
29
|
+
Chromium/``auto`` selection permits CDP. ``FROM_BROWSER=off`` or a recorded
|
|
30
|
+
refusal blocks it, including when an endpoint is configured.
|
|
31
|
+
* Stdlib only: a tiny RFC 6455 websocket client, no third-party dependency.
|
|
32
|
+
* Cookie **values are never logged** — only counts and endpoints.
|
|
33
|
+
* First complete pair wins: both ``auth_token`` and ``ct0`` must be present.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import base64
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import re
|
|
42
|
+
import socket
|
|
43
|
+
import struct
|
|
44
|
+
import urllib.request
|
|
45
|
+
from typing import Any, Dict, List, Optional
|
|
46
|
+
|
|
47
|
+
from . import log
|
|
48
|
+
|
|
49
|
+
X_COOKIE_NAMES = ("auth_token", "ct0")
|
|
50
|
+
_BASE_DEBUG_PORT = 9222
|
|
51
|
+
# The last30days extras NUX convention port: the agent launches the throwaway
|
|
52
|
+
# login Chrome with SAND_CHROME_REMOTE_DEBUG_PORT=18800 so this lookup finds it.
|
|
53
|
+
# NOT box-chrome's built-in default (which is 9222 + the X display number).
|
|
54
|
+
_BOX_CHROME_PORT = 18800
|
|
55
|
+
|
|
56
|
+
_HTTP_TIMEOUT = 1.5 # /json and /json/version fetches
|
|
57
|
+
_WS_TIMEOUT = 3.0 # websocket exchange
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _log(msg: str) -> None:
|
|
61
|
+
log.source_log("chrome-cdp", msg, tty_only=False)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display_number() -> Optional[int]:
|
|
65
|
+
"""Parse the X display number from ``$DISPLAY`` (e.g. ``:99`` -> 99)."""
|
|
66
|
+
disp = os.environ.get("DISPLAY") or ""
|
|
67
|
+
match = re.search(r":(\d+)", disp)
|
|
68
|
+
if not match:
|
|
69
|
+
return None
|
|
70
|
+
try:
|
|
71
|
+
return int(match.group(1))
|
|
72
|
+
except ValueError:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalize_base(url: str) -> str:
|
|
77
|
+
"""Return an ``http://host:port`` base for a user-supplied endpoint."""
|
|
78
|
+
url = url.strip().rstrip("/")
|
|
79
|
+
if url.startswith(("http://", "https://", "ws://", "wss://")):
|
|
80
|
+
return url
|
|
81
|
+
return f"http://{url}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def candidate_endpoints(config: Optional[Dict[str, Any]] = None) -> List[str]:
|
|
85
|
+
"""Debug endpoints to try, most-specific first (no port scan).
|
|
86
|
+
|
|
87
|
+
Order: an explicit ``BROWSER_CDP_URL`` (used exclusively when set), else the
|
|
88
|
+
last30days extras NUX port ``18800`` (where the agent launches the throwaway
|
|
89
|
+
login Chrome via ``SAND_CHROME_REMOTE_DEBUG_PORT=18800``), then ``9222`` +
|
|
90
|
+
the X display number (box-chrome's own built-in default). ``18800`` is tried
|
|
91
|
+
first but read_x_cookies falls through when it yields no complete pair, so a
|
|
92
|
+
logged-out Chrome there never shadows a logged-in daily profile.
|
|
93
|
+
"""
|
|
94
|
+
explicit = ""
|
|
95
|
+
if config is not None:
|
|
96
|
+
explicit = (config.get("BROWSER_CDP_URL") or "").strip()
|
|
97
|
+
explicit = explicit or (os.environ.get("BROWSER_CDP_URL") or "").strip()
|
|
98
|
+
if explicit:
|
|
99
|
+
return [_normalize_base(explicit)]
|
|
100
|
+
|
|
101
|
+
endpoints = [f"http://127.0.0.1:{_BOX_CHROME_PORT}"]
|
|
102
|
+
display = _display_number()
|
|
103
|
+
endpoints.append(f"http://127.0.0.1:{_BASE_DEBUG_PORT + (display or 0)}")
|
|
104
|
+
return endpoints
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _http_get_json(url: str) -> Optional[Any]:
|
|
108
|
+
"""GET ``url`` and parse JSON, or None (unreachable/non-JSON)."""
|
|
109
|
+
try:
|
|
110
|
+
with urllib.request.urlopen(url, timeout=_HTTP_TIMEOUT) as resp:
|
|
111
|
+
body = resp.read()
|
|
112
|
+
except (OSError, ValueError):
|
|
113
|
+
return None
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(body)
|
|
116
|
+
except (json.JSONDecodeError, TypeError):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_chrome_endpoint(base: str) -> bool:
|
|
121
|
+
"""True when ``base``/json/version reports a Chrome/Chromium browser.
|
|
122
|
+
|
|
123
|
+
Rejects a Node ``--inspect`` endpoint (whose ``Browser`` is ``node.js/...``)
|
|
124
|
+
so we never mistake an inspector for a browser.
|
|
125
|
+
"""
|
|
126
|
+
version = _http_get_json(f"{base}/json/version")
|
|
127
|
+
if not isinstance(version, dict):
|
|
128
|
+
return False
|
|
129
|
+
browser = str(version.get("Browser") or "").lower()
|
|
130
|
+
return "chrome" in browser or "chromium" in browser
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _page_ws_url(base: str) -> Optional[str]:
|
|
134
|
+
"""Find a Chrome PAGE target's webSocketDebuggerUrl on ``base``."""
|
|
135
|
+
targets = _http_get_json(f"{base}/json")
|
|
136
|
+
if not isinstance(targets, list):
|
|
137
|
+
return None
|
|
138
|
+
for target in targets:
|
|
139
|
+
if not isinstance(target, dict):
|
|
140
|
+
continue
|
|
141
|
+
if target.get("type") != "page":
|
|
142
|
+
continue
|
|
143
|
+
ws_url = target.get("webSocketDebuggerUrl")
|
|
144
|
+
if isinstance(ws_url, str) and ws_url.startswith("ws://"):
|
|
145
|
+
return ws_url
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class _WSConn:
|
|
150
|
+
"""Minimal RFC 6455 websocket client (text frames only) over a TCP socket."""
|
|
151
|
+
|
|
152
|
+
def __init__(self, sock: socket.socket) -> None:
|
|
153
|
+
self._sock = sock
|
|
154
|
+
self._buf = b""
|
|
155
|
+
|
|
156
|
+
def _fill(self, n: int) -> Optional[bytes]:
|
|
157
|
+
while len(self._buf) < n:
|
|
158
|
+
try:
|
|
159
|
+
chunk = self._sock.recv(65536)
|
|
160
|
+
except OSError:
|
|
161
|
+
return None
|
|
162
|
+
if not chunk:
|
|
163
|
+
return None
|
|
164
|
+
self._buf += chunk
|
|
165
|
+
out, self._buf = self._buf[:n], self._buf[n:]
|
|
166
|
+
return out
|
|
167
|
+
|
|
168
|
+
@classmethod
|
|
169
|
+
def connect(cls, ws_url: str, timeout: float) -> Optional["_WSConn"]:
|
|
170
|
+
# Plaintext ws:// only. A wss:// URL would need real TLS
|
|
171
|
+
# (ssl.wrap_socket); this client does not, so refuse it rather than
|
|
172
|
+
# open a plaintext socket to a TLS endpoint. Defense in depth alongside
|
|
173
|
+
# the scheme gate in read_x_cookies.
|
|
174
|
+
match = re.match(r"ws://([^:/]+):(\d+)(/.*)$", ws_url)
|
|
175
|
+
if not match:
|
|
176
|
+
return None
|
|
177
|
+
host, port, path = match.group(1), int(match.group(2)), match.group(3)
|
|
178
|
+
try:
|
|
179
|
+
sock = socket.create_connection((host, port), timeout=timeout)
|
|
180
|
+
except OSError:
|
|
181
|
+
return None
|
|
182
|
+
sock.settimeout(timeout)
|
|
183
|
+
key = base64.b64encode(os.urandom(16)).decode("ascii")
|
|
184
|
+
handshake = (
|
|
185
|
+
f"GET {path} HTTP/1.1\r\n"
|
|
186
|
+
f"Host: {host}:{port}\r\n"
|
|
187
|
+
"Upgrade: websocket\r\n"
|
|
188
|
+
"Connection: Upgrade\r\n"
|
|
189
|
+
f"Sec-WebSocket-Key: {key}\r\n"
|
|
190
|
+
"Sec-WebSocket-Version: 13\r\n\r\n"
|
|
191
|
+
)
|
|
192
|
+
try:
|
|
193
|
+
sock.sendall(handshake.encode("ascii"))
|
|
194
|
+
except OSError:
|
|
195
|
+
sock.close()
|
|
196
|
+
return None
|
|
197
|
+
conn = cls(sock)
|
|
198
|
+
header = conn._read_http_headers()
|
|
199
|
+
if header is None or b" 101 " not in header.split(b"\r\n", 1)[0]:
|
|
200
|
+
sock.close()
|
|
201
|
+
return None
|
|
202
|
+
return conn
|
|
203
|
+
|
|
204
|
+
def _read_http_headers(self) -> Optional[bytes]:
|
|
205
|
+
while b"\r\n\r\n" not in self._buf:
|
|
206
|
+
try:
|
|
207
|
+
chunk = self._sock.recv(65536)
|
|
208
|
+
except OSError:
|
|
209
|
+
return None
|
|
210
|
+
if not chunk:
|
|
211
|
+
return None
|
|
212
|
+
self._buf += chunk
|
|
213
|
+
head, _, rest = self._buf.partition(b"\r\n\r\n")
|
|
214
|
+
self._buf = rest # any bytes after the header belong to the frame stream
|
|
215
|
+
return head
|
|
216
|
+
|
|
217
|
+
def send_text(self, payload: bytes) -> bool:
|
|
218
|
+
header = bytearray([0x81]) # FIN + text opcode
|
|
219
|
+
mask = os.urandom(4)
|
|
220
|
+
length = len(payload)
|
|
221
|
+
if length < 126:
|
|
222
|
+
header.append(0x80 | length)
|
|
223
|
+
elif length < 65536:
|
|
224
|
+
header.append(0x80 | 126)
|
|
225
|
+
header += struct.pack(">H", length)
|
|
226
|
+
else:
|
|
227
|
+
header.append(0x80 | 127)
|
|
228
|
+
header += struct.pack(">Q", length)
|
|
229
|
+
header += mask
|
|
230
|
+
masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
231
|
+
try:
|
|
232
|
+
self._sock.sendall(bytes(header) + masked)
|
|
233
|
+
return True
|
|
234
|
+
except OSError:
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
def recv_message(self) -> Optional[bytes]:
|
|
238
|
+
"""Read one (possibly fragmented) data message; skip control frames."""
|
|
239
|
+
message = b""
|
|
240
|
+
while True:
|
|
241
|
+
first = self._fill(2)
|
|
242
|
+
if first is None:
|
|
243
|
+
return None
|
|
244
|
+
fin = first[0] & 0x80
|
|
245
|
+
opcode = first[0] & 0x0F
|
|
246
|
+
length = first[1] & 0x7F
|
|
247
|
+
masked = first[1] & 0x80
|
|
248
|
+
if length == 126:
|
|
249
|
+
ext = self._fill(2)
|
|
250
|
+
if ext is None:
|
|
251
|
+
return None
|
|
252
|
+
length = struct.unpack(">H", ext)[0]
|
|
253
|
+
elif length == 127:
|
|
254
|
+
ext = self._fill(8)
|
|
255
|
+
if ext is None:
|
|
256
|
+
return None
|
|
257
|
+
length = struct.unpack(">Q", ext)[0]
|
|
258
|
+
mask = self._fill(4) if masked else b""
|
|
259
|
+
payload = self._fill(length) if length else b""
|
|
260
|
+
if length and payload is None:
|
|
261
|
+
return None
|
|
262
|
+
if masked and payload:
|
|
263
|
+
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
264
|
+
if opcode == 0x8: # close
|
|
265
|
+
return None
|
|
266
|
+
if opcode in (0x9, 0xA): # ping / pong — ignore
|
|
267
|
+
continue
|
|
268
|
+
message += payload or b""
|
|
269
|
+
if fin:
|
|
270
|
+
return message
|
|
271
|
+
|
|
272
|
+
def close(self) -> None:
|
|
273
|
+
try:
|
|
274
|
+
self._sock.close()
|
|
275
|
+
except OSError:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _get_all_cookies(ws_url: str) -> Optional[List[Dict[str, Any]]]:
|
|
280
|
+
"""Run Network.enable then Network.getAllCookies over one CDP websocket."""
|
|
281
|
+
conn = _WSConn.connect(ws_url, _WS_TIMEOUT)
|
|
282
|
+
if conn is None:
|
|
283
|
+
return None
|
|
284
|
+
try:
|
|
285
|
+
if not conn.send_text(json.dumps({"id": 1, "method": "Network.enable"}).encode("utf-8")):
|
|
286
|
+
return None
|
|
287
|
+
if not conn.send_text(json.dumps({"id": 2, "method": "Network.getAllCookies"}).encode("utf-8")):
|
|
288
|
+
return None
|
|
289
|
+
# Read frames until the id=2 response arrives (skipping enable's ack and
|
|
290
|
+
# any Network.* events the browser pushes after enable).
|
|
291
|
+
for _ in range(200):
|
|
292
|
+
raw = conn.recv_message()
|
|
293
|
+
if raw is None:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
msg = json.loads(raw)
|
|
297
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
298
|
+
continue
|
|
299
|
+
if isinstance(msg, dict) and msg.get("id") == 2:
|
|
300
|
+
result = msg.get("result")
|
|
301
|
+
if isinstance(result, dict) and isinstance(result.get("cookies"), list):
|
|
302
|
+
return result["cookies"]
|
|
303
|
+
return None
|
|
304
|
+
return None
|
|
305
|
+
finally:
|
|
306
|
+
conn.close()
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# Registrable X hosts we accept cookies from, in preference order. Matched
|
|
310
|
+
# EXACTLY after stripping a single leading dot (never endswith), so a lookalike
|
|
311
|
+
# like ``notx.com`` is not treated as x.com and cannot contribute a cookie.
|
|
312
|
+
_ALLOWED_X_HOSTS = ("x.com", "twitter.com")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _canonical_partition(raw: Any) -> Optional[str]:
|
|
316
|
+
"""Canonicalize a CDP ``partitionKey`` to a hashable scope tag.
|
|
317
|
+
|
|
318
|
+
CDP may send ``partitionKey`` as a string, as an object
|
|
319
|
+
(``{"topLevelSite": ..., "hasCrossSiteAncestor": ...}``), or omit it for an
|
|
320
|
+
unpartitioned cookie. Returns None for "unpartitioned" (so all unpartitioned
|
|
321
|
+
cookies share one scope) and a stable string otherwise (so a partitioned
|
|
322
|
+
cookie never shares a scope with an unpartitioned one, nor with a different
|
|
323
|
+
partition).
|
|
324
|
+
"""
|
|
325
|
+
if raw is None:
|
|
326
|
+
return None
|
|
327
|
+
if isinstance(raw, str):
|
|
328
|
+
return raw.strip() or None
|
|
329
|
+
if isinstance(raw, dict):
|
|
330
|
+
try:
|
|
331
|
+
return json.dumps(raw, sort_keys=True, separators=(",", ":"))
|
|
332
|
+
except (TypeError, ValueError):
|
|
333
|
+
return repr(sorted((str(k), str(v)) for k, v in raw.items()))
|
|
334
|
+
return str(raw)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _pair_from_cookies(cookies: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
338
|
+
"""Extract a complete X cookie pair from ONE cookie scope.
|
|
339
|
+
|
|
340
|
+
``auth_token`` and ``ct0`` are only a usable pair when they share the SAME
|
|
341
|
+
cookie scope — same registrable host AND same ``path`` AND same partition.
|
|
342
|
+
Chrome can hold duplicate names across scopes (different ``path`` or
|
|
343
|
+
``partitionKey``), so pairing across the whole host jar could hand Bird a
|
|
344
|
+
token from one session scope and a ct0 from another, and a valid login would
|
|
345
|
+
look unauthorized. We therefore group by the full scope key
|
|
346
|
+
``(host, path, partition)`` and only ever pair WITHIN one scope.
|
|
347
|
+
|
|
348
|
+
Host is matched EXACTLY against ``_ALLOWED_X_HOSTS`` after stripping one
|
|
349
|
+
leading dot (never ``endswith``, so ``notx.com`` never counts). Missing
|
|
350
|
+
``path`` is treated as ``/``; ``partitionKey`` is canonicalized so
|
|
351
|
+
unpartitioned cookies stay together. Preference order for the returned pair:
|
|
352
|
+
host ``x.com`` before ``twitter.com``; unpartitioned before partitioned;
|
|
353
|
+
path ``/`` before other paths. A scope with only one of the two cookies is
|
|
354
|
+
skipped so a later complete scope still wins. When no scope has a complete
|
|
355
|
+
pair, a single scope's partial is returned for the caller's incomplete-pair
|
|
356
|
+
log — never a cross-scope mix.
|
|
357
|
+
"""
|
|
358
|
+
# scopes[host][(path, partition)] -> {name: value}
|
|
359
|
+
scopes: Dict[str, Dict[tuple, Dict[str, str]]] = {host: {} for host in _ALLOWED_X_HOSTS}
|
|
360
|
+
for cookie in cookies:
|
|
361
|
+
if not isinstance(cookie, dict):
|
|
362
|
+
continue
|
|
363
|
+
name = cookie.get("name")
|
|
364
|
+
value = cookie.get("value")
|
|
365
|
+
if name not in X_COOKIE_NAMES or not (isinstance(value, str) and value):
|
|
366
|
+
continue
|
|
367
|
+
host = str(cookie.get("domain") or "").lstrip(".").lower()
|
|
368
|
+
if host not in scopes:
|
|
369
|
+
continue
|
|
370
|
+
path = cookie.get("path")
|
|
371
|
+
if not isinstance(path, str) or not path:
|
|
372
|
+
path = "/"
|
|
373
|
+
scope_key = (path, _canonical_partition(cookie.get("partitionKey")))
|
|
374
|
+
jar = scopes[host].setdefault(scope_key, {})
|
|
375
|
+
# First value WITHIN this scope only — never across scopes.
|
|
376
|
+
jar.setdefault(name, value)
|
|
377
|
+
|
|
378
|
+
def _scope_rank(item: tuple) -> tuple:
|
|
379
|
+
(path, partition), _jar = item
|
|
380
|
+
# unpartitioned (None) before partitioned; path "/" before others.
|
|
381
|
+
return (partition is not None, path != "/", path)
|
|
382
|
+
|
|
383
|
+
for host in _ALLOWED_X_HOSTS:
|
|
384
|
+
for _key, jar in sorted(scopes[host].items(), key=_scope_rank):
|
|
385
|
+
if all(name in jar for name in X_COOKIE_NAMES):
|
|
386
|
+
return {name: jar[name] for name in X_COOKIE_NAMES}
|
|
387
|
+
|
|
388
|
+
for host in _ALLOWED_X_HOSTS:
|
|
389
|
+
for _key, jar in sorted(scopes[host].items(), key=_scope_rank):
|
|
390
|
+
if jar:
|
|
391
|
+
return dict(jar)
|
|
392
|
+
return {}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def cookie_access_allowed(config: Optional[Dict[str, Any]] = None) -> bool:
|
|
396
|
+
config = config or {}
|
|
397
|
+
from_browser = str(config.get("FROM_BROWSER") or "").strip().lower()
|
|
398
|
+
if from_browser == "off":
|
|
399
|
+
return False
|
|
400
|
+
consent = config.get("BROWSER_CONSENT")
|
|
401
|
+
if consent is not None:
|
|
402
|
+
return str(consent).strip().lower() in {"1", "true", "yes", "on"}
|
|
403
|
+
requested = {browser.strip() for browser in from_browser.split(",")}
|
|
404
|
+
return bool(requested & {"auto", "chrome", "brave", "edge", "vivaldi", "opera", "arc", "chromium"})
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def read_x_cookies(config: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, str]]:
|
|
408
|
+
"""Return the complete X cookie pair from a live Chrome session, or None.
|
|
409
|
+
|
|
410
|
+
Resolves the debug endpoint (BROWSER_CDP_URL, else 18800 if Chrome, else
|
|
411
|
+
9222+$DISPLAY), requires a Chrome page target, and calls
|
|
412
|
+
``Network.getAllCookies``. Returns ``{"auth_token", "ct0"}`` only when BOTH
|
|
413
|
+
cookies are found (no half-pair). Without explicit consent, returns None
|
|
414
|
+
before resolving endpoints or opening a socket. Any failure returns None
|
|
415
|
+
so the caller falls through.
|
|
416
|
+
Never raises.
|
|
417
|
+
|
|
418
|
+
Host gating (extras-only) lives in the caller (``env.x_extras_enabled``);
|
|
419
|
+
on a plain MacBook this function is never invoked, so no socket is opened.
|
|
420
|
+
"""
|
|
421
|
+
if not cookie_access_allowed(config):
|
|
422
|
+
return None
|
|
423
|
+
|
|
424
|
+
for base in candidate_endpoints(config):
|
|
425
|
+
# TLS CDP is NOT supported: the websocket client speaks plaintext only,
|
|
426
|
+
# so a wss:// endpoint (or an https:// base, which would yield a wss://
|
|
427
|
+
# page URL) must fail closed rather than be downgraded to a plaintext
|
|
428
|
+
# connect. Local Chrome CDP is ws://http://.
|
|
429
|
+
scheme = base.split("://", 1)[0].lower() if "://" in base else "http"
|
|
430
|
+
if scheme in ("wss", "https"):
|
|
431
|
+
_log(f"refusing TLS CDP endpoint {base!r}: only ws://http:// is supported (no TLS)")
|
|
432
|
+
continue
|
|
433
|
+
# ws:// endpoints (rare, explicit) connect directly; http bases are
|
|
434
|
+
# validated as Chrome and asked for a page target.
|
|
435
|
+
if base.startswith("ws://"):
|
|
436
|
+
ws_url = base
|
|
437
|
+
else:
|
|
438
|
+
if not _is_chrome_endpoint(base):
|
|
439
|
+
continue
|
|
440
|
+
ws_url = _page_ws_url(base)
|
|
441
|
+
if not ws_url:
|
|
442
|
+
continue
|
|
443
|
+
cookies = _get_all_cookies(ws_url)
|
|
444
|
+
if not cookies:
|
|
445
|
+
continue
|
|
446
|
+
found = _pair_from_cookies(cookies)
|
|
447
|
+
if all(name in found for name in X_COOKIE_NAMES):
|
|
448
|
+
_log(f"read a complete X cookie pair from a live Chrome session at {base}")
|
|
449
|
+
return {name: found[name] for name in X_COOKIE_NAMES}
|
|
450
|
+
if found:
|
|
451
|
+
_log(
|
|
452
|
+
f"live Chrome at {base} had an incomplete pair "
|
|
453
|
+
f"({sorted(found)}); ignoring per no-half-pair rule"
|
|
454
|
+
)
|
|
455
|
+
return None
|