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.
Files changed (274) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. 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