@m13v/s4l 1.6.197-rc.10
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/README.md +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# clean_stale_singleton.sh — Remove stale Chrome singleton symlinks from a
|
|
3
|
+
# browser profile if (and only if) the PID they reference is dead.
|
|
4
|
+
#
|
|
5
|
+
# Background: when Chrome exits ungracefully (SIGKILL, system sleep, force
|
|
6
|
+
# quit, jetsam), it leaves Singleton{Lock,Cookie,Socket} + RunningChromeVersion
|
|
7
|
+
# symlinks behind. On the next launch Chrome sees them, fails to talk to the
|
|
8
|
+
# (now-dead) PID listed in SingletonLock, and pops "Something went wrong when
|
|
9
|
+
# opening your profile. Some features may be unavailable" once per service
|
|
10
|
+
# (cookies, prefs, history, sync, ...) — typically 7 dialogs. Until the user
|
|
11
|
+
# clicks all of them, no pages load and the pipeline hangs.
|
|
12
|
+
#
|
|
13
|
+
# Safe to call before any Chrome launch on the same profile. Idempotent.
|
|
14
|
+
# Refuses to clean if the SingletonLock PID is still alive (so we never
|
|
15
|
+
# yank locks out from under a running Chrome — including a real user
|
|
16
|
+
# session attached to the same profile).
|
|
17
|
+
#
|
|
18
|
+
# Usage: clean_stale_singleton.sh <profile_dir>
|
|
19
|
+
# e.g. clean_stale_singleton.sh ~/.claude/browser-profiles/twitter
|
|
20
|
+
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
profile_dir="${1:-}"
|
|
24
|
+
if [ -z "$profile_dir" ] || [ ! -d "$profile_dir" ]; then
|
|
25
|
+
echo "[clean_stale_singleton] usage: $0 <profile_dir>" >&2
|
|
26
|
+
exit 0 # never block the pipeline on a misuse; just no-op
|
|
27
|
+
fi
|
|
28
|
+
|
|
29
|
+
lock_link="$profile_dir/SingletonLock"
|
|
30
|
+
|
|
31
|
+
# No lock = nothing to clean.
|
|
32
|
+
if [ ! -L "$lock_link" ] && [ ! -e "$lock_link" ]; then
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# SingletonLock target format: <hostname>-<pid>
|
|
37
|
+
target=$(readlink "$lock_link" 2>/dev/null || echo "")
|
|
38
|
+
pid="${target##*-}"
|
|
39
|
+
|
|
40
|
+
if [ -n "$pid" ] && [[ "$pid" =~ ^[0-9]+$ ]]; then
|
|
41
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
42
|
+
# Live Chrome owns this profile. Do NOT touch.
|
|
43
|
+
echo "[clean_stale_singleton] ${profile_dir##*/}: SingletonLock PID $pid alive; leaving locks intact." >&2
|
|
44
|
+
exit 0
|
|
45
|
+
fi
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Stale: PID dead, malformed, or unreadable. Nuke the singletons so Chrome
|
|
49
|
+
# can launch cleanly. Also drop RunningChromeVersion which Chrome cross-checks.
|
|
50
|
+
rm -f "$profile_dir/SingletonLock" \
|
|
51
|
+
"$profile_dir/SingletonCookie" \
|
|
52
|
+
"$profile_dir/SingletonSocket" \
|
|
53
|
+
"$profile_dir/RunningChromeVersion"
|
|
54
|
+
|
|
55
|
+
echo "[clean_stale_singleton] ${profile_dir##*/}: cleared stale singleton locks (was PID ${pid:-unknown})." >&2
|
|
56
|
+
exit 0
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Close every CDP "page" tab in harness Chrome except one.
|
|
3
|
+
|
|
4
|
+
Called from skill/lib/twitter-backend.sh::ensure_twitter_browser_for_backend
|
|
5
|
+
and skill/engage-twitter.sh's inline harness branch as part of pre-flight.
|
|
6
|
+
Safe to call any time: exits 0 silently when harness Chrome is down. Workers
|
|
7
|
+
and iframe targets are left alone; they auto-clean when their parent page
|
|
8
|
+
closes.
|
|
9
|
+
|
|
10
|
+
The standalone-script form (vs an inline heredoc) is required because bash
|
|
11
|
+
3.2 on macOS cannot parse a nested heredoc inside a function body inside a
|
|
12
|
+
sourced file. See git history around 2026-05-14 for the prior inline form
|
|
13
|
+
that broke every launchd-fired twitter script.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.request
|
|
21
|
+
|
|
22
|
+
# Port can be overridden via BH_CLEANUP_PORT so the LinkedIn backend
|
|
23
|
+
# (skill/lib/linkedin-backend.sh) can reuse this same cleanup script against
|
|
24
|
+
# its own harness Chrome on 9556. Default 9555 keeps Twitter callers unchanged.
|
|
25
|
+
CDP_PORT = int(os.environ.get("BH_CLEANUP_PORT", "9555"))
|
|
26
|
+
CDP_URL = f"http://127.0.0.1:{CDP_PORT}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def main() -> int:
|
|
30
|
+
try:
|
|
31
|
+
with urllib.request.urlopen(f"{CDP_URL}/json", timeout=2) as r:
|
|
32
|
+
tabs = json.loads(r.read())
|
|
33
|
+
except Exception:
|
|
34
|
+
return 0
|
|
35
|
+
pages = [t for t in tabs if t.get("type") == "page"]
|
|
36
|
+
if len(pages) <= 1:
|
|
37
|
+
print(f"[cleanup_harness_tabs] {len(pages)} page tab(s), no cleanup needed")
|
|
38
|
+
return 0
|
|
39
|
+
# Keep a REAL (http/https) tab when one exists, not blindly pages[0]. The
|
|
40
|
+
# /json order is roughly most-recently-active first, so a freshly-spawned
|
|
41
|
+
# about:blank can sit at index 0 and the old code would keep the blank and
|
|
42
|
+
# close the live x.com tab the harness daemon is attached to. Closing the
|
|
43
|
+
# daemon's tab forces it to re-attach and re-spawn another about:blank, which
|
|
44
|
+
# is exactly the orphan-tab churn this script is meant to clean up. Falling
|
|
45
|
+
# back to pages[0] preserves the prior behavior when every tab is blank.
|
|
46
|
+
def _is_real(t):
|
|
47
|
+
return (t.get("url") or "").startswith(("http://", "https://"))
|
|
48
|
+
|
|
49
|
+
keep = next((t for t in pages if _is_real(t)), pages[0])
|
|
50
|
+
closed = 0
|
|
51
|
+
for t in pages:
|
|
52
|
+
if t is keep:
|
|
53
|
+
continue
|
|
54
|
+
tid = t.get("id")
|
|
55
|
+
if not tid:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
urllib.request.urlopen(f"{CDP_URL}/json/close/{tid}", timeout=2).read()
|
|
59
|
+
closed += 1
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
kept_kind = "1 real" if _is_real(keep) else "1"
|
|
63
|
+
print(f"[cleanup_harness_tabs] closed {closed}/{len(pages) - 1} extra page tabs (kept {kept_kind})")
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""copy_browser_cookies.py - self-contained Chromium cookie copier.
|
|
3
|
+
|
|
4
|
+
Reads cookies from a local Chromium-family browser profile (Chrome / Arc /
|
|
5
|
+
Brave / Edge), decrypts them with the OS keychain, and injects them into a
|
|
6
|
+
running Chrome via CDP. Used by setup_twitter_auth.py (the MCP `connect_x`
|
|
7
|
+
flow) to import a user's x.com/twitter.com session into the autoposter's
|
|
8
|
+
managed browser WITHOUT a manual login.
|
|
9
|
+
|
|
10
|
+
This is a VENDORED, dependency-light copy of the logic that previously lived in
|
|
11
|
+
the separate ~/ai-browser-profile repo (ai_browser_profile.cookies +
|
|
12
|
+
ai_browser_profile.ingestors.browser_detect). That repo is a private
|
|
13
|
+
personal-memory project that is never installed on a customer machine, so the
|
|
14
|
+
old code path silently failed on every fresh install and fell back to manual
|
|
15
|
+
login. Vendoring it here means the auto-import works out of the box with only
|
|
16
|
+
the deps social-autoposter already ships (cryptography + websocket-client; see
|
|
17
|
+
requirements.txt). Keep the CLI surface (`copy` / `list`) stable: it is the
|
|
18
|
+
contract setup_twitter_auth.py shells out to.
|
|
19
|
+
|
|
20
|
+
macOS only for now (uses the `security` keychain CLI and ~/Library paths). On
|
|
21
|
+
Linux the caller's manual-login fallback still covers the gap.
|
|
22
|
+
|
|
23
|
+
CLI:
|
|
24
|
+
python3 copy_browser_cookies.py copy \\
|
|
25
|
+
--from chrome:Default --to http://127.0.0.1:9555 \\
|
|
26
|
+
--domains x.com,twitter.com
|
|
27
|
+
python3 copy_browser_cookies.py list \\
|
|
28
|
+
--from chrome:Default --domains x.com,twitter.com
|
|
29
|
+
|
|
30
|
+
Cookie VALUES are never printed; `list` reports counts per host only.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import argparse
|
|
36
|
+
import hashlib
|
|
37
|
+
import json
|
|
38
|
+
import logging
|
|
39
|
+
import shutil
|
|
40
|
+
import sqlite3
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import tempfile
|
|
44
|
+
import urllib.request
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
from typing import Iterable, Optional, Set
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger("copy_browser_cookies")
|
|
50
|
+
|
|
51
|
+
APP_SUPPORT = Path.home() / "Library" / "Application Support"
|
|
52
|
+
|
|
53
|
+
KEYCHAIN_SERVICE = {
|
|
54
|
+
"chrome": "Chrome Safe Storage",
|
|
55
|
+
"arc": "Arc Safe Storage",
|
|
56
|
+
"brave": "Brave Safe Storage",
|
|
57
|
+
"edge": "Microsoft Edge Safe Storage",
|
|
58
|
+
"chromium": "Chromium Safe Storage",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# Chromium cookie-encryption constants (v10/v11 AES-CBC on macOS).
|
|
62
|
+
PBKDF2_SALT = b"saltysalt"
|
|
63
|
+
PBKDF2_ITERATIONS = 1003
|
|
64
|
+
AES_KEY_LENGTH = 16
|
|
65
|
+
AES_IV = b" " * 16
|
|
66
|
+
|
|
67
|
+
SAMESITE_MAP = {-1: "Unspecified", 0: "None", 1: "Lax", 2: "Strict"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --- Browser / profile detection (stdlib only) ------------------------------
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class BrowserProfile:
|
|
74
|
+
browser: str # "arc", "chrome", "brave", "edge", "chromium"
|
|
75
|
+
name: str # "Default", "Profile 1", etc.
|
|
76
|
+
path: Path # Full path to the profile directory
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _chromium_profiles(browser: str, base: Path) -> list[BrowserProfile]:
|
|
80
|
+
"""Find Chromium-based browser profiles (Default, Profile 1, etc.)."""
|
|
81
|
+
profiles: list[BrowserProfile] = []
|
|
82
|
+
if not base.exists():
|
|
83
|
+
return profiles
|
|
84
|
+
|
|
85
|
+
for d in sorted(base.iterdir()):
|
|
86
|
+
if d.is_dir() and (d.name == "Default" or d.name.startswith("Profile ")):
|
|
87
|
+
if (d / "History").exists() or (d / "IndexedDB").exists():
|
|
88
|
+
profiles.append(BrowserProfile(browser=browser, name=d.name, path=d))
|
|
89
|
+
|
|
90
|
+
if not profiles:
|
|
91
|
+
default = base / "Default"
|
|
92
|
+
if default.exists():
|
|
93
|
+
profiles.append(BrowserProfile(browser=browser, name="Default", path=default))
|
|
94
|
+
|
|
95
|
+
return profiles
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def detect_browsers(allowed: Optional[Set[str]] = None) -> list[BrowserProfile]:
|
|
99
|
+
"""Return all detected Chromium-family browser profiles, optionally filtered."""
|
|
100
|
+
profiles: list[BrowserProfile] = []
|
|
101
|
+
browsers = {
|
|
102
|
+
"arc": APP_SUPPORT / "Arc" / "User Data",
|
|
103
|
+
"chrome": APP_SUPPORT / "Google" / "Chrome",
|
|
104
|
+
"brave": APP_SUPPORT / "BraveSoftware" / "Brave-Browser",
|
|
105
|
+
"edge": APP_SUPPORT / "Microsoft Edge",
|
|
106
|
+
"chromium": APP_SUPPORT / "Chromium",
|
|
107
|
+
}
|
|
108
|
+
for name, base in browsers.items():
|
|
109
|
+
if allowed and name not in allowed:
|
|
110
|
+
continue
|
|
111
|
+
profiles.extend(_chromium_profiles(name, base))
|
|
112
|
+
log.info("Detected %d browser profiles: %s", len(profiles),
|
|
113
|
+
[(p.browser, p.name) for p in profiles])
|
|
114
|
+
return profiles
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def copy_db(src: Path) -> Optional[Path]:
|
|
118
|
+
"""Copy a SQLite DB (plus -wal/-shm) to a temp dir to avoid browser locks."""
|
|
119
|
+
if not src.exists():
|
|
120
|
+
return None
|
|
121
|
+
try:
|
|
122
|
+
tmp = Path(tempfile.mkdtemp(prefix="saps_cookies_"))
|
|
123
|
+
dst = tmp / src.name
|
|
124
|
+
shutil.copy2(src, dst)
|
|
125
|
+
for suffix in ("-wal", "-shm"):
|
|
126
|
+
wal = src.parent / (src.name + suffix)
|
|
127
|
+
if wal.exists():
|
|
128
|
+
shutil.copy2(wal, tmp / (src.name + suffix))
|
|
129
|
+
return dst
|
|
130
|
+
except PermissionError:
|
|
131
|
+
log.warning("Permission denied reading %s. Grant Full Disk Access or skip.", src)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- Cookie read + decrypt --------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@dataclass
|
|
138
|
+
class Cookie:
|
|
139
|
+
name: str
|
|
140
|
+
value: str
|
|
141
|
+
domain: str
|
|
142
|
+
path: str
|
|
143
|
+
expires: float
|
|
144
|
+
secure: bool
|
|
145
|
+
http_only: bool
|
|
146
|
+
same_site: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _keychain_password(browser: str) -> bytes:
|
|
150
|
+
service = KEYCHAIN_SERVICE.get(browser)
|
|
151
|
+
if not service:
|
|
152
|
+
raise ValueError(f"No keychain service mapped for browser {browser!r}")
|
|
153
|
+
res = subprocess.run(
|
|
154
|
+
["security", "find-generic-password", "-w", "-s", service],
|
|
155
|
+
capture_output=True, text=True, check=False,
|
|
156
|
+
)
|
|
157
|
+
if res.returncode != 0:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
f"Could not read {service!r} from Keychain: "
|
|
160
|
+
f"{res.stderr.strip() or 'access denied'}"
|
|
161
|
+
)
|
|
162
|
+
return res.stdout.strip().encode()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _derive_key(password: bytes) -> bytes:
|
|
166
|
+
return hashlib.pbkdf2_hmac(
|
|
167
|
+
"sha1", password, PBKDF2_SALT, PBKDF2_ITERATIONS, AES_KEY_LENGTH
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _decrypt(encrypted: bytes, key: bytes, host_key: str) -> Optional[str]:
|
|
172
|
+
"""Decrypt a Chromium cookie value. Returns None on failure."""
|
|
173
|
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
174
|
+
|
|
175
|
+
if not encrypted:
|
|
176
|
+
return None
|
|
177
|
+
prefix = encrypted[:3]
|
|
178
|
+
payload = encrypted[3:] if prefix in (b"v10", b"v11") else encrypted
|
|
179
|
+
if len(payload) % 16 != 0:
|
|
180
|
+
return None
|
|
181
|
+
cipher = Cipher(algorithms.AES(key), modes.CBC(AES_IV))
|
|
182
|
+
dec = cipher.decryptor()
|
|
183
|
+
plain = dec.update(payload) + dec.finalize()
|
|
184
|
+
if not plain:
|
|
185
|
+
return None
|
|
186
|
+
pad = plain[-1]
|
|
187
|
+
if 1 <= pad <= 16 and plain.endswith(bytes([pad]) * pad):
|
|
188
|
+
plain = plain[:-pad]
|
|
189
|
+
# Chrome 80+ prepends SHA256(host_key) (32 bytes) to bind cookie to its host.
|
|
190
|
+
expected = hashlib.sha256(host_key.encode()).digest()
|
|
191
|
+
if plain.startswith(expected):
|
|
192
|
+
plain = plain[32:]
|
|
193
|
+
try:
|
|
194
|
+
return plain.decode("utf-8")
|
|
195
|
+
except UnicodeDecodeError:
|
|
196
|
+
return plain.decode("utf-8", errors="replace")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def read_cookies(
|
|
200
|
+
profile: BrowserProfile,
|
|
201
|
+
domains: Optional[Iterable[str]] = None,
|
|
202
|
+
) -> list[Cookie]:
|
|
203
|
+
"""Read and decrypt cookies from a Chromium browser profile.
|
|
204
|
+
|
|
205
|
+
`domains` is an iterable of host suffixes; a cookie is kept if its host_key
|
|
206
|
+
equals or is a subdomain of any of them ('x.com' keeps 'x.com'/'api.x.com'
|
|
207
|
+
but not 'fedex.com'). None keeps all cookies.
|
|
208
|
+
"""
|
|
209
|
+
cookies_path = profile.path / "Cookies"
|
|
210
|
+
if not cookies_path.exists():
|
|
211
|
+
# Newer Chrome nests the cookie DB under Network/.
|
|
212
|
+
nested = profile.path / "Network" / "Cookies"
|
|
213
|
+
if nested.exists():
|
|
214
|
+
cookies_path = nested
|
|
215
|
+
else:
|
|
216
|
+
raise FileNotFoundError(f"No Cookies file at {cookies_path}")
|
|
217
|
+
|
|
218
|
+
tmp = copy_db(cookies_path)
|
|
219
|
+
if tmp is None:
|
|
220
|
+
raise RuntimeError(
|
|
221
|
+
f"Could not copy {cookies_path}. Grant Full Disk Access to your terminal and retry."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
domain_filters = list(domains) if domains else None
|
|
225
|
+
|
|
226
|
+
def _host_matches(host: str) -> bool:
|
|
227
|
+
h = host or ""
|
|
228
|
+
if "://" in h:
|
|
229
|
+
h = h.split("://", 1)[1]
|
|
230
|
+
h = h.split("/", 1)[0].split(":", 1)[0].lstrip(".").lower()
|
|
231
|
+
for f in (domain_filters or []):
|
|
232
|
+
ff = (f or "").strip().lstrip(".").lower()
|
|
233
|
+
if not ff:
|
|
234
|
+
continue
|
|
235
|
+
if h == ff or h.endswith("." + ff):
|
|
236
|
+
return True
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
def _txt(b) -> str:
|
|
240
|
+
if b is None:
|
|
241
|
+
return ""
|
|
242
|
+
if isinstance(b, bytes):
|
|
243
|
+
return b.decode("utf-8", errors="replace")
|
|
244
|
+
return str(b)
|
|
245
|
+
|
|
246
|
+
key = _derive_key(_keychain_password(profile.browser))
|
|
247
|
+
cookies: list[Cookie] = []
|
|
248
|
+
skipped = 0
|
|
249
|
+
try:
|
|
250
|
+
conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
251
|
+
# Arc and some Chrome forks declare encrypted_value as TEXT, not BLOB,
|
|
252
|
+
# which makes sqlite3 try to UTF-8-decode the AES ciphertext and crash
|
|
253
|
+
# mid-iteration. Force everything to bytes and decode TEXT ourselves.
|
|
254
|
+
conn.text_factory = bytes
|
|
255
|
+
conn.row_factory = sqlite3.Row
|
|
256
|
+
rows = conn.execute(
|
|
257
|
+
"SELECT host_key, name, value, encrypted_value, path, expires_utc, "
|
|
258
|
+
"is_secure, is_httponly, samesite FROM cookies"
|
|
259
|
+
)
|
|
260
|
+
for row in rows:
|
|
261
|
+
host = _txt(row["host_key"])
|
|
262
|
+
if domain_filters and not _host_matches(host):
|
|
263
|
+
continue
|
|
264
|
+
value = _txt(row["value"])
|
|
265
|
+
if not value and row["encrypted_value"]:
|
|
266
|
+
value = _decrypt(row["encrypted_value"], key, host) or ""
|
|
267
|
+
if not value:
|
|
268
|
+
skipped += 1
|
|
269
|
+
continue
|
|
270
|
+
expires = 0.0
|
|
271
|
+
if row["expires_utc"]:
|
|
272
|
+
# Chromium epoch is 1601-01-01 in microseconds.
|
|
273
|
+
expires = (row["expires_utc"] / 1_000_000) - 11644473600
|
|
274
|
+
cookies.append(Cookie(
|
|
275
|
+
name=_txt(row["name"]),
|
|
276
|
+
value=value,
|
|
277
|
+
domain=host,
|
|
278
|
+
path=_txt(row["path"]) or "/",
|
|
279
|
+
expires=expires,
|
|
280
|
+
secure=bool(row["is_secure"]),
|
|
281
|
+
http_only=bool(row["is_httponly"]),
|
|
282
|
+
same_site=SAMESITE_MAP.get(row["samesite"], "Unspecified"),
|
|
283
|
+
))
|
|
284
|
+
conn.close()
|
|
285
|
+
finally:
|
|
286
|
+
shutil.rmtree(tmp.parent, ignore_errors=True)
|
|
287
|
+
|
|
288
|
+
log.info("Read %d cookies from %s/%s (skipped %d undecryptable)",
|
|
289
|
+
len(cookies), profile.browser, profile.name, skipped)
|
|
290
|
+
return cookies
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# --- CDP injection ----------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def _ws_from_cdp_url(cdp_url: str) -> str:
|
|
296
|
+
if cdp_url.startswith("ws://") or cdp_url.startswith("wss://"):
|
|
297
|
+
return cdp_url
|
|
298
|
+
if cdp_url.startswith("cdp://"):
|
|
299
|
+
cdp_url = "http://" + cdp_url[len("cdp://"):]
|
|
300
|
+
base = cdp_url.rstrip("/")
|
|
301
|
+
with urllib.request.urlopen(f"{base}/json/version", timeout=5) as r:
|
|
302
|
+
return json.loads(r.read())["webSocketDebuggerUrl"]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def inject_via_cdp(cookies: Iterable[Cookie], cdp_url: str = "http://127.0.0.1:9222") -> int:
|
|
306
|
+
"""Inject cookies into a running Chrome via CDP. Returns the count accepted.
|
|
307
|
+
|
|
308
|
+
Tries Storage.setCookies at the browser root first; if the browser has no
|
|
309
|
+
Page targets that command fails, so we open a stub about:blank tab and use
|
|
310
|
+
Network.setCookies on its session instead.
|
|
311
|
+
"""
|
|
312
|
+
from websocket import create_connection
|
|
313
|
+
|
|
314
|
+
ws_url = _ws_from_cdp_url(cdp_url)
|
|
315
|
+
# Chrome 111+ enforces CDP origin checking; suppressing the Origin header
|
|
316
|
+
# bypasses it (localhost CDP is already privileged).
|
|
317
|
+
ws = create_connection(ws_url, timeout=10, suppress_origin=True)
|
|
318
|
+
msg_id = 0
|
|
319
|
+
|
|
320
|
+
def _send(method, params=None, session_id=None):
|
|
321
|
+
nonlocal msg_id
|
|
322
|
+
msg_id += 1
|
|
323
|
+
msg = {"id": msg_id, "method": method}
|
|
324
|
+
if params:
|
|
325
|
+
msg["params"] = params
|
|
326
|
+
if session_id:
|
|
327
|
+
msg["sessionId"] = session_id
|
|
328
|
+
ws.send(json.dumps(msg))
|
|
329
|
+
while True:
|
|
330
|
+
resp = json.loads(ws.recv())
|
|
331
|
+
if resp.get("id") == msg_id:
|
|
332
|
+
return resp
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
batch = []
|
|
336
|
+
for c in cookies:
|
|
337
|
+
param = {
|
|
338
|
+
"name": c.name,
|
|
339
|
+
"value": c.value,
|
|
340
|
+
"domain": c.domain,
|
|
341
|
+
"path": c.path or "/",
|
|
342
|
+
"secure": c.secure,
|
|
343
|
+
"httpOnly": c.http_only,
|
|
344
|
+
}
|
|
345
|
+
if c.same_site in ("Strict", "Lax", "None"):
|
|
346
|
+
param["sameSite"] = c.same_site
|
|
347
|
+
if c.expires > 0:
|
|
348
|
+
param["expires"] = c.expires
|
|
349
|
+
batch.append(param)
|
|
350
|
+
if not batch:
|
|
351
|
+
return 0
|
|
352
|
+
|
|
353
|
+
resp = _send("Storage.setCookies", {"cookies": batch})
|
|
354
|
+
err = resp.get("error", {})
|
|
355
|
+
if not err:
|
|
356
|
+
log.info("Injected %d cookies via Storage.setCookies", len(batch))
|
|
357
|
+
return len(batch)
|
|
358
|
+
|
|
359
|
+
msg = err.get("message", "")
|
|
360
|
+
if "Browser context management is not supported" not in msg:
|
|
361
|
+
log.warning("Storage.setCookies failed: %s", err)
|
|
362
|
+
return 0
|
|
363
|
+
|
|
364
|
+
log.info("Storage.setCookies unavailable (no tabs); opening stub tab and retrying")
|
|
365
|
+
target_id = None
|
|
366
|
+
try:
|
|
367
|
+
r = _send("Target.createTarget", {"url": "about:blank"})
|
|
368
|
+
target_id = r.get("result", {}).get("targetId")
|
|
369
|
+
if not target_id:
|
|
370
|
+
log.warning("Couldn't create stub tab: %s", r)
|
|
371
|
+
return 0
|
|
372
|
+
r = _send("Target.attachToTarget", {"targetId": target_id, "flatten": True})
|
|
373
|
+
session_id = r.get("result", {}).get("sessionId")
|
|
374
|
+
if not session_id:
|
|
375
|
+
log.warning("Couldn't attach to stub tab: %s", r)
|
|
376
|
+
return 0
|
|
377
|
+
r = _send("Network.setCookies", {"cookies": batch}, session_id=session_id)
|
|
378
|
+
if r.get("error"):
|
|
379
|
+
log.warning("Network.setCookies failed: %s", r["error"])
|
|
380
|
+
return 0
|
|
381
|
+
log.info("Injected %d cookies via Network.setCookies (per-tab fallback)", len(batch))
|
|
382
|
+
return len(batch)
|
|
383
|
+
finally:
|
|
384
|
+
if target_id:
|
|
385
|
+
try:
|
|
386
|
+
_send("Target.closeTarget", {"targetId": target_id})
|
|
387
|
+
except Exception:
|
|
388
|
+
pass
|
|
389
|
+
finally:
|
|
390
|
+
ws.close()
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- CLI --------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
def find_profile(spec: str) -> BrowserProfile:
|
|
396
|
+
"""Resolve a 'browser:profile' spec (e.g. 'chrome:Default') to a BrowserProfile."""
|
|
397
|
+
if ":" in spec:
|
|
398
|
+
browser, name = spec.split(":", 1)
|
|
399
|
+
else:
|
|
400
|
+
browser, name = spec, "Default"
|
|
401
|
+
matches = [p for p in detect_browsers({browser}) if p.name == name]
|
|
402
|
+
if not matches:
|
|
403
|
+
available = [(p.browser, p.name) for p in detect_browsers({browser})]
|
|
404
|
+
raise SystemExit(f"No profile {spec!r}. Available {browser} profiles: {available}")
|
|
405
|
+
return matches[0]
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _cli(argv: Optional[list[str]] = None) -> int:
|
|
409
|
+
parser = argparse.ArgumentParser(prog="copy_browser_cookies.py")
|
|
410
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
411
|
+
|
|
412
|
+
cp = sub.add_parser("copy", help="copy cookies from a local profile into a running browser via CDP")
|
|
413
|
+
cp.add_argument("--from", dest="src", required=True,
|
|
414
|
+
help="source profile, e.g. chrome:Default or arc:'Profile 1'")
|
|
415
|
+
cp.add_argument("--to", dest="dst", required=True,
|
|
416
|
+
help="target CDP endpoint, e.g. http://127.0.0.1:9555 or cdp://127.0.0.1:9555")
|
|
417
|
+
cp.add_argument("--domains", default=None,
|
|
418
|
+
help="comma-separated host suffixes to include (e.g. x.com,twitter.com)")
|
|
419
|
+
cp.add_argument("-v", "--verbose", action="store_true")
|
|
420
|
+
|
|
421
|
+
ls = sub.add_parser("list", help="list cookies in a local profile (counts only, no values)")
|
|
422
|
+
ls.add_argument("--from", dest="src", required=True)
|
|
423
|
+
ls.add_argument("--domains", default=None)
|
|
424
|
+
ls.add_argument("-v", "--verbose", action="store_true")
|
|
425
|
+
|
|
426
|
+
args = parser.parse_args(argv)
|
|
427
|
+
logging.basicConfig(
|
|
428
|
+
level=logging.DEBUG if getattr(args, "verbose", False) else logging.INFO,
|
|
429
|
+
format="%(levelname)s %(message)s",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
profile = find_profile(args.src)
|
|
433
|
+
domain_filters = [d.strip() for d in args.domains.split(",")] if args.domains else None
|
|
434
|
+
cookies = read_cookies(profile, domains=domain_filters)
|
|
435
|
+
|
|
436
|
+
if args.cmd == "list":
|
|
437
|
+
by_host: dict[str, int] = {}
|
|
438
|
+
for c in cookies:
|
|
439
|
+
by_host[c.domain] = by_host.get(c.domain, 0) + 1
|
|
440
|
+
for host, n in sorted(by_host.items(), key=lambda kv: -kv[1]):
|
|
441
|
+
print(f" {n:4} {host}")
|
|
442
|
+
print(f"Total: {len(cookies)} cookies across {len(by_host)} hosts")
|
|
443
|
+
return 0
|
|
444
|
+
|
|
445
|
+
if args.cmd == "copy":
|
|
446
|
+
n = inject_via_cdp(cookies, args.dst)
|
|
447
|
+
print(f"Injected {n}/{len(cookies)} cookies into {args.dst}")
|
|
448
|
+
return 0 if n > 0 else 2
|
|
449
|
+
|
|
450
|
+
return 1
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
if __name__ == "__main__":
|
|
454
|
+
sys.exit(_cli())
|