wimotek-chromium-sync 1.0.7 → 1.0.8

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 CHANGED
@@ -4,96 +4,180 @@
4
4
  [![npm version](https://img.shields.io/npm/v/wimotek-chromium-sync.svg?style=flat-square)](https://www.npmjs.com/package/wimotek-chromium-sync)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg?style=flat-square)](LICENSE)
6
6
 
7
- 专为 Chromium / Chrome 设计的通用会话与 Cookie 穿透热同步工具。
7
+ 专为 Chromium / Chrome 设计的现代化会话与 Cookie 集群同步调度工具。
8
8
 
9
- 支持在本地容器与远程服务器容器之间双向热同步登录凭证(Cookie + LocalStorage),全端口可参数化自定义,内置智能流向识别与带心跳保活的 SSH 隧道自愈管理。
10
9
  ---
11
10
 
12
11
  ## 🌟 核心特性
13
12
 
14
- * 全端口自由定义:本地容器端口、远程实际端口、本地隧道映射端口及 SSH 端口均可自由配置或通过环境变量覆盖。
15
- * 智能双向流向探测 (auto):连通后自动探测两端 Cookie 数量与页面状态,支持智能推荐、人工交互确认或全自动执行。
16
- * 内置心跳保活隧道:内置 ServerAliveInterval=15 与端口占用检测,已存在则自动复用,不存在则自动拉起,绝不产生假死僵尸连接。
17
- * 无 TTY 环境自适应:在 Linux Crontab 定时任务、无图形后台或 CI 流程中自动回退,不因无键盘输入而崩溃。
18
- * 双生态支持:同时发布于 PyPI 与 npm,支持 Python 代码导入与命令行跨语言一键执行。
13
+ * **统一节点模型 (1对1 / 1对多集群)**:采用纯粹现代的节点架构,同时支持 1对1 穿透与任意规模的 1对多 节点集群分发。
14
+ * **本地免密直连与全能 SSH 认证**:
15
+ * **本地节点**:指定本地端口即可直接直连 CDP,免登录、免配置、零 SSH 开销。
16
+ * **远程节点**:支持自定义 SSH 用户名 (`ssh_user`)、私钥认证 (`ssh_key_path` / `ssh_key_data`) 与密码认证 (`ssh_password`)。
17
+ * **端口自愈防撞**:自动为各远程节点分配互不冲突的本地隧道映射端口,已存在的活跃隧道自动复用。
18
+ * **两类高效同步策略**:
19
+ * **`merge` (全节点凭证智能融合)**:汇总所有在线节点的 Cookie,若存在同名/同域/同路径冲突,**无条件保留有效期最新(expires 更长)的记录**,同时融合 LocalStorage 状态并向集群节点广播热刷新。
20
+ * **`sync` (单源广播同步)**:将指定源节点 (`source`) 的全部会话凭据镜像推送至一个或多个目标节点。
21
+ * **极简调用,免建数据库表**:
22
+ * 代码层面提供 `get_cookies()`、`inspect()` 与 `get_summary_table()`,**仅需 1 行代码即可获取任何节点或全集群的 Cookie 列表与状态**。
23
+ * 命令行提供 `--status` (ASCII 表格) 与 `--json` (标准 JSON 格式),随取随用。
19
24
 
20
- ## 📦 安装
25
+ ---
21
26
 
22
- Python 环境 (PyPI)
27
+ ## 📦 安装
23
28
 
29
+ ### Python 环境 (PyPI)
24
30
  ```bash
25
31
  pip install wimotek-chromium-sync
32
+ playwright install chromium
26
33
  ```
27
34
 
28
- Node.js 环境 (npm)
29
-
35
+ ### Node.js 环境 (npm)
30
36
  ```bash
31
- npm install wimotek-chromium-sync
37
+ npm install -g wimotek-chromium-sync
32
38
  ```
33
39
 
34
- ## 🚀 极简使用示例
40
+ ---
35
41
 
36
- ### 1. Python 代码调用(仅需 3 行)
42
+ ## 🚀 极简使用指南
37
43
 
44
+ ### 1. Python 代码调用
45
+
46
+ #### (1) 1 行代码获取节点状态与 Cookie 详情 (无需数据库)
38
47
  ```python
39
48
  from chromium_sync import ChromiumSync
40
49
 
41
- 自由定制两端的所有端口
50
+ syncer = ChromiumSync(nodes=[
51
+ {"id": "local", "name": "本机容器", "cdp_port": 9222},
52
+ {"id": "kr2", "name": "韩国节点", "ssh_host": "kr2.wimotek.com", "ssh_user": "root", "ssh_key_path": "~/.ssh/id_rsa", "cdp_port": 9222}
53
+ ])
42
54
 
43
- syncer = ChromiumSync( remote_ssh="root@xx.xx.xx", # 远程 SSH 服务器
44
- remote_ssh_port=22, # SSH 连接端口
45
- local_cdp_port=16002, # 本地容器的 CDP 端口
46
- remote_cdp_port=9222, # 远程容器的实际
47
- CDP 端口 remote_tunnel_port=9222, # 本地用于接收隧道的端口
48
- direction="auto", # 智能判断: 自动往缺少凭证的一端同步
49
- timeout=15 # 倒计时等待秒数 (0 为秒执行) )
55
+ # 打印美观的终端状态表格
56
+ print(syncer.get_summary_table())
50
57
 
51
- syncer.run()
58
+ # 获取指定节点或全集群的 Cookie 明细列表 (无需创建外部数据库表)
59
+ all_cookies = syncer.get_cookies() # {"local": [...], "kr2": [...]}
60
+ local_cookies = syncer.get_cookies("local") # 获取单个节点的 Cookie 列表
61
+ print(f"本地有效 Cookie 数: {len(local_cookies)}")
52
62
  ```
53
63
 
54
- ### 2. 命令行使用 (CLI 模式)
64
+ #### (2) 全节点 Cookie 智能 Merge (合并最新有效期)
65
+ ```python
66
+ from chromium_sync import ChromiumSync
67
+
68
+ syncer = ChromiumSync(
69
+ nodes=[
70
+ {"id": "local", "cdp_port": 9223},
71
+ {"id": "kr2", "ssh_host": "kr2.wimotek.com", "ssh_user": "root", "ssh_key_path": "~/.ssh/id_rsa", "cdp_port": 9222},
72
+ {"id": "sg1", "ssh_host": "sg1.wimotek.com", "ssh_user": "ubuntu", "ssh_password": "YourPassword", "cdp_port": 9222}
73
+ ],
74
+ mode="merge"
75
+ )
76
+ syncer.run()
77
+ ```
78
+
79
+ #### (3) 单源广播同步 (例如将本机状态推送到所有云端节点)
80
+ ```python
81
+ from chromium_sync import ChromiumSync
82
+
83
+ syncer = ChromiumSync(
84
+ nodes=[
85
+ {"id": "local", "cdp_port": 9222},
86
+ {"id": "node_a", "ssh_host": "10.0.0.1", "cdp_port": 9222},
87
+ {"id": "node_b", "ssh_host": "10.0.0.2", "cdp_port": 9222}
88
+ ],
89
+ mode="sync",
90
+ source="local"
91
+ )
92
+ syncer.run()
93
+ ```
94
+
95
+ ---
55
96
 
56
- 无论是通过 pip 安装还是 npm 安装,均可直接在终端中敲命令运行:
97
+ ### 2. 命令行 (CLI) 快速执行
57
98
 
99
+ #### (1) 终端直接传入多个节点 (内联精简语法)
58
100
  ```bash
101
+ # 智能合并多节点最新 Cookie
102
+ chromium-sync \
103
+ --node id=local,port=9223 \
104
+ --node id=kr2,ssh=root@kr2.wimotek.com,port=9222,key=~/.ssh/id_rsa \
105
+ --node id=sg1,ssh=ubuntu@sg1.wimotek.com,port=9222,pass=SecretPassword \
106
+ --mode merge
107
+ ```
59
108
 
60
- 查看所有端口与流向选项
109
+ #### (2) 仅查看状态与 Cookie 统计 (ASCII 报表)
110
+ ```bash
111
+ chromium-sync \
112
+ --node id=local,port=9223 \
113
+ --node id=kr2,ssh=root@kr2.wimotek.com,port=9222,key=~/.ssh/id_rsa \
114
+ --status
115
+ ```
116
+ 输出样例:
117
+ ```text
118
+ ==========================================================================================
119
+ 节点 ID | 节点名称 | 类型 | CDP端口/映射 | Cookie数 | 当前页面状态
120
+ ------------------------------------------------------------------------------------------
121
+ local | 本地容器 | 本地直连 | 9223 | 42 条 | [Google] https://google.com
122
+ kr2 | 韩国节点 | SSH远程 | 19222 | 38 条 | [GitHub] https://github.com
123
+ ==========================================================================================
124
+ ```
61
125
 
62
- chromium-sync --help
126
+ #### (3) 导出 Cookie 详情为 JSON (便于程序与定时脚本直接提取)
127
+ ```bash
128
+ chromium-sync --config cluster.json --status --json > /tmp/cluster_cookies.json
129
+ ```
63
130
 
64
- 自由指定各端口全自动推送 (0 秒等待)
131
+ #### (4) 使用 JSON 配置文件运行集群同步
132
+ 准备配置文件 `cluster.json`:
133
+ ```json
134
+ [
135
+ { "id": "local", "name": "本地开发机", "cdp_port": 9222 },
136
+ { "id": "sg1", "name": "新加坡服", "ssh_host": "sg.host.com", "ssh_user": "ubuntu", "ssh_key_path": "~/.ssh/id_rsa", "cdp_port": 9222 },
137
+ { "id": "kr2", "name": "韩国服", "ssh_host": "kr.host.com", "ssh_user": "root", "ssh_password": "SafePassword123", "cdp_port": 9222 }
138
+ ]
139
+ ```
140
+ 单源广播或合并:
141
+ ```bash
142
+ # 合并所有节点最新有效凭据
143
+ chromium-sync --config cluster.json --mode merge
65
144
 
66
- chromium-sync
67
- --remote-ssh root@xx.xx.xx
68
- --ssh-port 22
69
- --local-port 9222
70
- --tunnel-port 9222
71
- --direction push
72
- --timeout 0
145
+ # 本地向所有云端广播
146
+ chromium-sync --config cluster.json --mode sync --source local
73
147
  ```
74
148
 
75
- ## ⚙️ 端口与参数对应说明
76
-
77
- | 参数名称 | 命令行选项 | 对应环境变量 | 默认值 | 详细说明 |
78
- | :------------------- | :------------- | :-------------------------- | :--------------------- | :------------------- |
79
- | remote\_ssh | \--remote-ssh | CHROMIUM\_REMOTE\_SSH | <root@xx.xx.xx> | 远程 SSH 连接主机 |
80
- | remote\_ssh\_port | \--ssh-port | CHROMIUM\_SSH\_PORT | 22 | 远程服务器的 SSH 端口 |
81
- | local\_cdp\_port | \--local-port | CHROMIUM\_LOCAL\_PORT | 16002 | 运行在本机的容器 CDP 端口 |
82
- | remote\_cdp\_port | \--remote-port | CHROMIUM\_REMOTE\_CDP\_PORT | 9222 | 运行在远端服务器上的容器实际端口 |
83
- | remote\_tunnel\_port | \--tunnel-port | CHROMIUM\_TUNNEL\_PORT | 9222 | SSH 隧道映射到本机的监听端口 |
84
- | direction | \--direction | CHROMIUM\_DIRECTION | push | 默认同步流向 (push 或 pull 或 auto) |
85
- | timeout | \--timeout | CHROMIUM\_TIMEOUT | 15 | 倒计时交互等待秒数,0 为免打扰秒执行 |
86
- | auto\_close\_tunnel | \--auto-close | \- | False | 任务完成后是否关闭创建的 SSH 隧道 |
87
-
88
- ## 🤖 自动化与定时任务配置示例
89
- 配合 Linux Crontab,每天凌晨自动将本地维护好的登录态推送到云端远程容器:
149
+ ---
150
+
151
+ ## ⚙️ 节点配置字段规范 (NodeConfig)
152
+
153
+ | 字段 | 类型 | 默认值 | 详细说明 |
154
+ | :--- | :--- | :--- | :--- |
155
+ | `id` | str | 必填 | 节点唯一标识符 (例如 `local`, `sg1`, `kr2`) |
156
+ | `name` | str | `id` | 节点显示名称 |
157
+ | `is_local` | bool | 自动识别 | 是否为本地节点 (不填 `ssh_host` 或填回环地址自动为 True,免密直连) |
158
+ | `cdp_host` | str | `127.0.0.1` | 节点 CDP 服务监听的主机地址 |
159
+ | `cdp_port` | int | `9222` | 节点暴露的 CDP 端口 |
160
+ | `ssh_host` | str | `None` | 远程服务器 IP 或主机名 (提供该字段时识别为远程 SSH 节点) |
161
+ | `ssh_port` | int | `22` | 远程服务器 SSH 端口 |
162
+ | `ssh_user` | str | `root` | 远程 SSH 用户名 |
163
+ | `ssh_key_path` | str | `None` | SSH 私钥文件路径 (例如 `~/.ssh/id_rsa`) |
164
+ | `ssh_key_data` | str | `None` | SSH 私钥内容字符串 |
165
+ | `ssh_password` | str | `None` | SSH 登录密码 (无外部依赖纯原生环境自动适配) |
166
+ | `tunnel_port` | int | 自动分配 | 本地用于映射隧道的端口 (集群环境下自动递增分配防冲突) |
167
+
168
+ ---
169
+
170
+ ## 🤖 定时任务配置示例 (Crontab)
171
+
172
+ 配合 Linux Crontab,每天凌晨定时执行全节点凭证智能融合:
90
173
  ```bash
91
- 编辑定时任务
92
174
  crontab -e
93
- 添加此行:每天凌晨 3:00 自动执行同步且不弹任何提示
94
- 0 3 * * * /usr/local/bin/chromium-sync --direction push --timeout 0 --auto-close >> /tmp/chromium_sync.log 2>&1
175
+ # 每天凌晨 3:00 自动融合多节点最新有效凭证并释放隧道
176
+ 0 3 * * * /usr/local/bin/chromium-sync --config /etc/chromium/cluster.json --mode merge --auto-close >> /var/log/chromium_sync.log 2>&1
95
177
  ```
96
178
 
179
+ ---
180
+
97
181
  ## 📄 License
98
182
 
99
183
  MIT License
@@ -1,7 +1,8 @@
1
1
  """
2
- Chromium Sync Package
2
+ wimotek-chromium-sync Package
3
+ Universal Chromium Session & Cookie Synchronizer with SSH Tunnel and Cluster Support
3
4
  """
4
- from .core import ChromiumSync
5
+ from .core import ChromiumSync, NodeConfig, merge_cookies
5
6
 
6
- __version__ = "1.0.0"
7
- __all__ = ["ChromiumSync"]
7
+ __version__ = "1.1.0"
8
+ __all__ = ["ChromiumSync", "NodeConfig", "merge_cookies"]
@@ -1,32 +1,156 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
+ """
4
+ Chromium Sync CLI (现代多节点调度版)
5
+ """
3
6
 
4
7
  import argparse
5
- from .core import ChromiumSync
8
+ import json
9
+ import os
10
+ import sys
11
+ try:
12
+ from .core import ChromiumSync, NodeConfig
13
+ except (ImportError, ValueError):
14
+ from core import ChromiumSync, NodeConfig
15
+
16
+
17
+ def parse_node_str(val: str) -> dict:
18
+ """
19
+ 解析内联节点定义参数:
20
+ 例如:
21
+ --node id=local,port=9222
22
+ --node id=kr2,ssh=root@kr2.wimotek.com,port=9222,key=~/.ssh/id_rsa
23
+ --node id=sg1,ssh=ubuntu@sg1.wimotek.com,port=9222,pass=Secret123
24
+ --node '{"id": "local", "cdp_port": 9222}'
25
+ """
26
+ val = val.strip()
27
+ if val.startswith("{") and val.endswith("}"):
28
+ return json.loads(val)
29
+
30
+ res = {}
31
+ for part in val.split(","):
32
+ if "=" in part:
33
+ k, v = part.split("=", 1)
34
+ k = k.strip().lower()
35
+ v = v.strip()
36
+ if k in ("port", "cdp_port"):
37
+ res["cdp_port"] = int(v)
38
+ elif k in ("ssh_port",):
39
+ res["ssh_port"] = int(v)
40
+ elif k in ("tunnel_port",):
41
+ res["tunnel_port"] = int(v)
42
+ elif k in ("local", "is_local"):
43
+ res["is_local"] = v.lower() in ("true", "1", "yes")
44
+ elif k in ("host", "cdp_host"):
45
+ res["cdp_host"] = v
46
+ elif k in ("ssh", "ssh_host"):
47
+ if "@" in v:
48
+ user, host = v.split("@", 1)
49
+ res["ssh_user"] = user
50
+ res["ssh_host"] = host
51
+ else:
52
+ res["ssh_host"] = v
53
+ elif k in ("user", "ssh_user"):
54
+ res["ssh_user"] = v
55
+ elif k in ("key", "ssh_key", "ssh_key_path"):
56
+ res["ssh_key_path"] = v
57
+ elif k in ("pass", "password", "ssh_pass", "ssh_password"):
58
+ res["ssh_password"] = v
59
+ elif k in ("id", "name"):
60
+ res[k] = v
61
+ else:
62
+ res[k] = v
63
+
64
+ if "id" not in res:
65
+ res["id"] = res.get("name") or res.get("ssh_host") or "local"
66
+ return res
6
67
 
7
68
 
8
69
  def main():
9
- parser = argparse.ArgumentParser(description="Chromium 一体化会话热同步工具")
10
- parser.add_argument("--remote-ssh", default="root@kr2.wimotek.com", help="远程 SSH 地址")
11
- parser.add_argument("--ssh-port", type=int, default=22, help="远程 SSH 端口")
12
- parser.add_argument("--local-port", type=int, default=9223, help="本地容器 CDP 端口")
13
- parser.add_argument("--remote-port", type=int, default=9222, help="远程容器暴露的端口")
14
- parser.add_argument("--tunnel-port", type=int, default=9222, help="本地隧道端口")
15
- parser.add_argument("--direction", choices=["push", "pull", "auto"], default=None, help="同步方向")
16
- parser.add_argument("--timeout", type=int, default=5, help="倒计时秒数 (设为 0 即秒执行)")
17
- parser.add_argument("--auto-close", action="store_true", help="任务完成后关闭 SSH 隧道")
70
+ parser = argparse.ArgumentParser(
71
+ description="现代多节点 Chromium 会话同步与 Cookie 集群调度工具",
72
+ formatter_class=argparse.RawDescriptionHelpFormatter,
73
+ epilog="""
74
+ 使用示例:
75
+ # 1. 快速查看节点状态与 Cookie 数量 (ASCII 表格)
76
+ chromium-sync --node id=local,port=9222 --node id=kr2,ssh=root@kr2.wimotek.com,port=9222,key=~/.ssh/id_rsa --status
77
+
78
+ # 2. 导出节点状态与 Cookie 详情为 JSON (便于程序直接提取 Cookie 列表)
79
+ chromium-sync --config cluster.json --status --json
80
+
81
+ # 3. 全节点 Cookie 智能 Merge (合并所有节点并保留最新有效期)
82
+ chromium-sync --config cluster.json --mode merge
83
+
84
+ # 4. 指定单源节点广播同步到所有其他节点
85
+ chromium-sync --config cluster.json --mode sync --source local
86
+
87
+ # 5. 极简命令行多节点直接同步
88
+ chromium-sync \\
89
+ --node id=local,port=9223 \\
90
+ --node id=kr2,ssh=root@kr2.wimotek.com,port=9222,key=~/.ssh/id_rsa \\
91
+ --mode merge
92
+ """
93
+ )
94
+
95
+ # 节点输入方式
96
+ parser.add_argument("--config", help="集群节点配置文件路径 (JSON 格式)")
97
+ parser.add_argument("--node", action="append", default=[], help="定义节点 (可多次传入,如 id=local,port=9222)")
98
+ parser.add_argument("--nodes", help="JSON 数组格式的节点配置字符串")
99
+
100
+ # 同步模式
101
+ parser.add_argument("--mode", choices=["sync", "merge"], default="sync",
102
+ help="同步方式: 'merge'(合并所有节点最新Cookie), 'sync'(单源广播同步,默认)")
103
+ parser.add_argument("--source", default=None, help="sync 模式下的源节点 ID (默认第一个节点)")
104
+ parser.add_argument("--targets", nargs="*", default=None, help="sync 模式下的目标节点 ID 列表 (默认其余所有节点)")
105
+
106
+ # 探测与数据查询
107
+ parser.add_argument("--status", action="store_true", help="仅探测并输出各节点的 Cookie 数与详情表格,不执行写入")
108
+ parser.add_argument("--json", action="store_true", help="以 JSON 格式输出节点完整数据与 Cookie 列表")
109
+
110
+ # 隧道与连接
111
+ parser.add_argument("--auto-close", action="store_true", help="任务完成后关闭拉起的 SSH 隧道")
112
+ parser.add_argument("-v", "--verbose", action="store_true", help="显示详细调试日志")
113
+
18
114
  args = parser.parse_args()
19
115
 
116
+ # 聚合节点配置
117
+ raw_nodes = []
118
+ if args.config:
119
+ if not os.path.exists(args.config):
120
+ sys.exit(f"[-] 配置文件不存在: {args.config}")
121
+ with open(args.config, "r", encoding="utf-8") as f:
122
+ raw_nodes.extend(json.load(f))
123
+
124
+ if args.nodes:
125
+ raw_nodes.extend(json.loads(args.nodes))
126
+
127
+ if args.node:
128
+ for n_str in args.node:
129
+ raw_nodes.append(parse_node_str(n_str))
130
+
131
+ # 默认兜底:若未传入任何节点参数,尝试使用标准默认本地节点
132
+ if not raw_nodes:
133
+ raw_nodes = [
134
+ {"id": "local", "name": "本地容器", "is_local": True, "cdp_port": 9222}
135
+ ]
136
+
20
137
  syncer = ChromiumSync(
21
- remote_ssh=args.remote_ssh,
22
- remote_ssh_port=args.ssh_port,
23
- local_cdp_port=args.local_port,
24
- remote_cdp_port=args.remote_port,
25
- remote_tunnel_port=args.tunnel_port,
26
- direction=args.direction or "auto",
27
- timeout=args.timeout,
28
- auto_close_tunnel=args.auto_close
138
+ nodes=raw_nodes,
139
+ mode=args.mode,
140
+ source=args.source,
141
+ targets=args.targets,
142
+ auto_close_tunnel=args.auto_close,
143
+ verbose=args.verbose
29
144
  )
145
+
146
+ if args.status:
147
+ report = syncer.inspect()
148
+ if args.json:
149
+ print(json.dumps(report, ensure_ascii=False, indent=2))
150
+ else:
151
+ print(syncer.get_summary_table(report))
152
+ return
153
+
30
154
  syncer.run()
31
155
 
32
156
 
@@ -1,192 +1,446 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
+ """
4
+ Chromium Cluster Session & Cookie Synchronizer
5
+ 现代多节点 Chromium 会话同步调度器
6
+ - 支持 1对1 与 1对多集群拓扑
7
+ - 本地节点直接直连免密免隧道,远程节点支持 SSH 密钥与密码登录
8
+ - 同步策略: merge (全节点 Cookie/Storage 智能融合并保留最新有效期) 与 sync (单源节点向多目标广播)
9
+ - 零额外数据库依赖:内置一行代码获取节点在线状态、Cookie 总数与完整详情
10
+ """
3
11
 
4
12
  import asyncio
13
+ import json
5
14
  import logging
6
15
  import os
7
- import select
16
+ import shutil
8
17
  import socket
9
18
  import subprocess
10
- import sys
19
+ import tempfile
11
20
  import time
12
21
  import urllib.request
13
- from typing import Optional
14
- from playwright.async_api import async_playwright, BrowserContext
22
+ from dataclasses import dataclass, field, asdict
23
+ from typing import Dict, List, Optional, Union, Any, TYPE_CHECKING
15
24
 
25
+ if TYPE_CHECKING:
26
+ from playwright.async_api import BrowserContext
27
+ else:
28
+ BrowserContext = Any
16
29
 
17
- class ChromiumSync:
30
+
31
+ @dataclass
32
+ class NodeConfig:
18
33
  """
19
- 通用 Chromium 会话热同步与 SSH 隧道调度器
34
+ 统一节点配置
35
+ - 本地节点:只需提供 cdp_port (无需提供 ssh_host,自动识别为本地免密直连)
36
+ - 远程节点:提供 ssh_host, cdp_port,可选 ssh_user, ssh_key_path, ssh_key_data, ssh_password
20
37
  """
21
- def __init__(
22
- self,
23
- remote_ssh: str = "root@kr2.wimotek.com",
24
- remote_ssh_port: int = 22,
25
- local_cdp_port: int = 9223,
26
- remote_cdp_port: int = 9222,
27
- remote_tunnel_port: int = 9222,
28
- direction: str = "push", # "push", "pull", 或 "auto" (智能判断)
29
- timeout: int = 5,
30
- auto_close_tunnel: bool = False,
31
- verbose: bool = True
32
- ):
33
- self.remote_ssh = os.getenv("CHROMIUM_REMOTE_SSH", remote_ssh)
34
- self.remote_ssh_port = int(os.getenv("CHROMIUM_SSH_PORT", str(remote_ssh_port)))
35
- self.local_cdp_port = int(os.getenv("CHROMIUM_LOCAL_PORT", str(local_cdp_port)))
36
- self.remote_cdp_port = int(os.getenv("CHROMIUM_REMOTE_CDP_PORT", str(remote_cdp_port)))
37
- self.remote_tunnel_port = int(os.getenv("CHROMIUM_TUNNEL_PORT", str(remote_tunnel_port)))
38
-
39
- self.default_direction = os.getenv("CHROMIUM_DIRECTION", direction).lower()
40
- self.timeout = int(os.getenv("CHROMIUM_TIMEOUT", str(timeout)))
41
- self.auto_close_tunnel = auto_close_tunnel
42
-
43
- self.remote_cdp_url = f"http://127.0.0.1:{self.remote_tunnel_port}"
44
- self.local_cdp_url = f"http://127.0.0.1:{self.local_cdp_port}"
45
-
46
- self.tunnel_proc: Optional[subprocess.Popen] = None
47
- self.is_reused_tunnel: bool = False
48
-
49
- log_level = logging.INFO if verbose else logging.WARNING
50
- logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(message)s")
51
-
52
- if self.local_cdp_port == self.remote_tunnel_port:
53
- logging.warning(
54
- f"[警告] 本地容器端口与隧道映射端口重复 ({self.local_cdp_port})!建议区分 (如 9223 与 9222)。"
55
- )
38
+ id: str
39
+ name: str = ""
40
+ is_local: bool = False
41
+ cdp_host: str = "127.0.0.1"
42
+ cdp_port: int = 9222
43
+ ssh_host: Optional[str] = None
44
+ ssh_port: int = 22
45
+ ssh_user: str = "root"
46
+ ssh_key_path: Optional[str] = None
47
+ ssh_key_data: Optional[str] = None
48
+ ssh_password: Optional[str] = None
49
+ tunnel_port: Optional[int] = None
50
+
51
+ def __post_init__(self):
52
+ if not self.name:
53
+ self.name = self.id
54
+
55
+ # 智能判定本地节点:未提供 ssh_host 或指定本地回环
56
+ if not self.ssh_host or self.ssh_host.strip() in ("127.0.0.1", "localhost", "::1", ""):
57
+ self.is_local = True
58
+
59
+ # 本地节点无需额外隧道映射,直接连接 cdp_port
60
+ if self.is_local:
61
+ self.tunnel_port = self.cdp_port
62
+
63
+ @property
64
+ def cdp_url(self) -> str:
65
+ if self.is_local:
66
+ return f"http://{self.cdp_host}:{self.cdp_port}"
67
+ return f"http://127.0.0.1:{self.tunnel_port}"
68
+
69
+
70
+ def merge_cookies(cookies_lists: List[List[dict]]) -> List[dict]:
71
+ """
72
+ 合并各节点 Cookie,针对同一 Cookie 保留有效期最新 (expires 最长) 的记录。
73
+ Cookie 唯一标识: (name, domain.lower(), path)
74
+ """
75
+ merged_map: Dict[tuple, dict] = {}
76
+
77
+ for cookies in cookies_lists:
78
+ for c in cookies:
79
+ name = c.get("name", "")
80
+ domain = c.get("domain", "").lstrip(".").lower()
81
+ path = c.get("path", "/")
82
+ key = (name, domain, path)
83
+
84
+ if key not in merged_map:
85
+ merged_map[key] = dict(c)
86
+ continue
87
+
88
+ existing = merged_map[key]
89
+ curr_exp = float(c.get("expires") or -1)
90
+ exist_exp = float(existing.get("expires") or -1)
91
+
92
+ should_replace = False
93
+ if curr_exp > 0 and exist_exp > 0:
94
+ if curr_exp > exist_exp:
95
+ should_replace = True
96
+ elif curr_exp > 0 and exist_exp <= 0:
97
+ # 持久凭据优先于临时会话凭据
98
+ should_replace = True
99
+ elif curr_exp <= 0 and exist_exp <= 0:
100
+ # 均为会话凭据时,优先保留较完整/非空值
101
+ val_curr = str(c.get("value", ""))
102
+ val_exist = str(existing.get("value", ""))
103
+ if val_curr and not val_exist:
104
+ should_replace = True
105
+ elif len(val_curr) > len(val_exist):
106
+ should_replace = True
107
+
108
+ if should_replace:
109
+ merged_map[key] = dict(c)
110
+
111
+ return list(merged_map.values())
112
+
113
+
114
+ class SshTunnelSession:
115
+ """
116
+ SSH 隧道生命周期管理
117
+ - 针对密钥(路径或数据)与密码自动装配
118
+ - 支持端口复用检测与防僵尸连接自愈
119
+ """
120
+ def __init__(self, node: NodeConfig):
121
+ self.node = node
122
+ self.proc: Optional[subprocess.Popen] = None
123
+ self.is_reused: bool = False
124
+ self.temp_key_file: Optional[str] = None
125
+ self.askpass_script: Optional[str] = None
56
126
 
57
127
  def _is_port_in_use(self, port: int) -> bool:
58
128
  with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
59
- s.settimeout(0.5)
129
+ s.settimeout(0.3)
60
130
  return s.connect_ex(("127.0.0.1", port)) == 0
61
131
 
62
132
  def _is_cdp_ready(self, url: str) -> bool:
63
133
  try:
64
134
  proxy_handler = urllib.request.ProxyHandler({})
65
135
  opener = urllib.request.build_opener(proxy_handler)
66
- resp = opener.open(f"{url}/json/version", timeout=2)
136
+ resp = opener.open(f"{url}/json/version", timeout=1.5)
67
137
  return resp.getcode() == 200
68
138
  except Exception:
69
139
  return False
70
140
 
71
- def _start_tunnel(self):
72
- if self._is_port_in_use(self.remote_tunnel_port):
73
- logging.info(f"[*] 本地 {self.remote_tunnel_port} 端口已被监听,跳过执行 SSH 命令直接复用。")
74
- self.is_reused_tunnel = True
141
+ def start(self, auto_close: bool = False):
142
+ if self.node.is_local:
75
143
  return
76
144
 
145
+ target_local_port = self.node.tunnel_port
146
+ target_url = f"http://127.0.0.1:{target_local_port}"
147
+
148
+ # 检查是否已存在可用的隧道
149
+ if self._is_port_in_use(target_local_port):
150
+ if self._is_cdp_ready(target_url):
151
+ logging.info(f"[*] 节点 [{self.node.name}] 本地端口 {target_local_port} 已通畅,复用现有连接。")
152
+ self.is_reused = True
153
+ return
154
+
77
155
  logging.info(
78
- f"[*] 正在拉起 SSH 隧道: {self.remote_ssh}:{self.remote_ssh_port} "
79
- f"(映射本地 {self.remote_tunnel_port} -> 远程 {self.remote_cdp_port}) ..."
156
+ f"[*] 正在拉起 SSH 隧道: {self.node.name} -> {self.node.ssh_user}@{self.node.ssh_host}:{self.node.ssh_port} "
157
+ f"(本地 127.0.0.1:{target_local_port} -> 远程 127.0.0.1:{self.node.cdp_port}) ..."
80
158
  )
81
- cmd = [
159
+
160
+ ssh_cmd = [
82
161
  "ssh", "-N",
83
- "-p", str(self.remote_ssh_port),
162
+ "-p", str(self.node.ssh_port),
84
163
  "-o", "ServerAliveInterval=15",
85
164
  "-o", "ServerAliveCountMax=3",
86
165
  "-o", "TCPKeepAlive=yes",
166
+ "-o", "StrictHostKeyChecking=accept-new",
87
167
  "-o", "ExitOnForwardFailure=yes",
88
- "-L", f"{self.remote_tunnel_port}:127.0.0.1:{self.remote_cdp_port}",
89
- self.remote_ssh
168
+ "-L", f"{target_local_port}:127.0.0.1:{self.node.cdp_port}"
90
169
  ]
91
- self.tunnel_proc = subprocess.Popen(
92
- cmd,
93
- stdout=subprocess.DEVNULL,
94
- stderr=subprocess.DEVNULL,
95
- start_new_session=(not self.auto_close_tunnel)
96
- )
97
170
 
98
- for _ in range(20):
99
- time.sleep(0.5)
100
- if self._is_port_in_use(self.remote_tunnel_port) and self._is_cdp_ready(self.remote_cdp_url):
101
- logging.info(f"[+] SSH 隧道建立成功!端口 {self.remote_tunnel_port} 已就绪。")
171
+ env = os.environ.copy()
172
+
173
+ # 私钥认证
174
+ if self.node.ssh_key_data:
175
+ fd, tmp_path = tempfile.mkstemp(prefix="ssh_key_")
176
+ with open(tmp_path, "w") as f:
177
+ f.write(self.node.ssh_key_data.strip() + "\n")
178
+ os.close(fd)
179
+ os.chmod(tmp_path, 0o600)
180
+ self.temp_key_file = tmp_path
181
+ ssh_cmd.extend(["-i", tmp_path])
182
+ elif self.node.ssh_key_path:
183
+ expanded_key = os.path.expanduser(self.node.ssh_key_path)
184
+ if os.path.exists(expanded_key):
185
+ ssh_cmd.extend(["-i", expanded_key])
186
+
187
+ # 密码认证
188
+ if self.node.ssh_password:
189
+ if shutil.which("sshpass"):
190
+ ssh_cmd = ["sshpass", "-p", self.node.ssh_password] + ssh_cmd
191
+ else:
192
+ fd, askpass_path = tempfile.mkstemp(prefix="askpass_", suffix=".sh")
193
+ escaped_pw = self.node.ssh_password.replace("'", "'\\''")
194
+ with open(askpass_path, "w") as f:
195
+ f.write(f"#!/bin/sh\necho '{escaped_pw}'\n")
196
+ os.close(fd)
197
+ os.chmod(askpass_path, 0o700)
198
+ self.askpass_script = askpass_path
199
+ env["SSH_ASKPASS"] = askpass_path
200
+ env["SSH_ASKPASS_REQUIRE"] = "force"
201
+ env["DISPLAY"] = ":0"
202
+ ssh_cmd.extend(["-o", "BatchMode=no"])
203
+
204
+ ssh_cmd.append(f"{self.node.ssh_user}@{self.node.ssh_host}")
205
+
206
+ try:
207
+ self.proc = subprocess.Popen(
208
+ ssh_cmd,
209
+ env=env,
210
+ stdout=subprocess.DEVNULL,
211
+ stderr=subprocess.DEVNULL,
212
+ start_new_session=(not auto_close)
213
+ )
214
+ except FileNotFoundError:
215
+ raise RuntimeError(f"[-] 系统未找到 ssh 可执行文件,无法为远程节点 [{self.node.name}] 建立隧道!")
216
+
217
+ for _ in range(25):
218
+ time.sleep(0.4)
219
+ if self._is_port_in_use(target_local_port) and self._is_cdp_ready(target_url):
220
+ logging.info(f"[+] 节点 [{self.node.name}] SSH 隧道建立成功 (端口: {target_local_port})。")
102
221
  return
103
- if self.tunnel_proc.poll() is not None:
222
+ if self.proc.poll() is not None:
104
223
  break
105
224
 
106
- self._stop_tunnel(force=True)
107
- raise RuntimeError("[-] 建立 SSH 隧道失败,请检查网络或 SSH 免密连接配置!")
225
+ self.stop(force=True)
226
+ raise RuntimeError(f"[-] 建立 SSH 隧道失败 [{self.node.name}],请检查网络或认证凭据!")
108
227
 
109
- def _stop_tunnel(self, force: bool = False):
110
- if self.is_reused_tunnel and not force:
228
+ def stop(self, force: bool = False):
229
+ if self.is_reused and not force:
111
230
  return
112
- if self.tunnel_proc and self.tunnel_proc.poll() is None:
113
- if self.auto_close_tunnel or force:
114
- logging.info(f"[*] 正在关闭 SSH 隧道 (PID: {self.tunnel_proc.pid})...")
115
- self.tunnel_proc.terminate()
116
- self.tunnel_proc.wait()
117
- logging.info("[*] 隧道已释放。")
118
- else:
119
- logging.info("[*] 提示:SSH 隧道已保留在后台持续运行。")
120
-
121
- def _timed_input(self, prompt: str, timeout: int, default_val: str) -> str:
122
- # 非交互式环境 (例如 cron / 后台运行),直接返回默认值
123
- if not sys.stdin or not sys.stdin.isatty() or timeout <= 0:
124
- sys.stdout.write(f"\n[*] 非交互环境或超时设为 0,自动采用默认值: {default_val}\n")
125
- return default_val
126
-
127
- sys.stdout.write(prompt)
128
- sys.stdout.flush()
129
-
130
- if sys.platform != "win32":
131
- ready, _, _ = select.select([sys.stdin], [], [], timeout)
132
- if ready:
133
- line = sys.stdin.readline().strip()
134
- return line if line else default_val
135
- sys.stdout.write(f"\n[*] 超过 {timeout} 秒无操作,自动执行默认流向: {default_val}\n")
136
- return default_val
137
- else:
138
- start_time = time.time()
139
- import msvcrt
140
- input_chars = []
141
- while True:
142
- if msvcrt.kbhit():
143
- char = msvcrt.getwche()
144
- if char in ("\r", "\n"):
145
- print()
146
- line = "".join(input_chars).strip()
147
- return line if line else default_val
148
- input_chars.append(char)
149
- if time.time() - start_time > timeout:
150
- print(f"\n[*] 超过 {timeout} 秒无操作,自动执行默认流向: {default_val}")
151
- return default_val
152
- time.sleep(0.05)
153
-
154
- async def _get_info(self, context: BrowserContext, name: str) -> dict:
155
- cookies = await context.cookies()
231
+
232
+ if self.proc and self.proc.poll() is None:
233
+ try:
234
+ self.proc.terminate()
235
+ self.proc.wait(timeout=2)
236
+ except Exception:
237
+ self.proc.kill()
238
+ logging.info(f"[*] 节点 [{self.node.name}] 隧道已释放。")
239
+
240
+ if self.temp_key_file and os.path.exists(self.temp_key_file):
241
+ try:
242
+ os.remove(self.temp_key_file)
243
+ except Exception:
244
+ pass
245
+ if self.askpass_script and os.path.exists(self.askpass_script):
246
+ try:
247
+ os.remove(self.askpass_script)
248
+ except Exception:
249
+ pass
250
+
251
+
252
+ class ChromiumSync:
253
+ """
254
+ 现代多节点 Chromium 会话同步管理器
255
+ """
256
+ def __init__(
257
+ self,
258
+ nodes: List[Union[NodeConfig, dict]],
259
+ mode: str = "sync", # "sync" (单源广播) 或 "merge" (全节点合并最新)
260
+ source: Optional[str] = None, # sync 模式下的源节点 ID (默认第一个节点)
261
+ targets: Optional[List[str]] = None, # sync 模式下的目标节点 ID 列表 (默认其余全部在线节点)
262
+ auto_close_tunnel: bool = False,
263
+ verbose: bool = True
264
+ ):
265
+ if not nodes:
266
+ raise ValueError("[-] 必须至少提供一个节点配置!")
267
+
268
+ self.mode = mode.lower()
269
+ if self.mode not in ("sync", "merge"):
270
+ raise ValueError(f"[-] 未知的同步模式 '{self.mode}',仅支持 'sync' 或 'merge'")
271
+
272
+ self.source = source
273
+ self.targets = targets or []
274
+ self.auto_close_tunnel = auto_close_tunnel
275
+
276
+ log_level = logging.INFO if verbose else logging.WARNING
277
+ logging.basicConfig(level=log_level, format="%(asctime)s [%(levelname)s] %(message)s")
278
+
279
+ # 初始化节点与隧道端口分配
280
+ self.nodes: List[NodeConfig] = []
281
+ used_tunnel_ports = set()
282
+ auto_port = 19222
283
+
284
+ for item in nodes:
285
+ cfg = item if isinstance(item, NodeConfig) else NodeConfig(**item)
286
+ if not cfg.is_local:
287
+ if not cfg.tunnel_port:
288
+ while auto_port in used_tunnel_ports:
289
+ auto_port += 1
290
+ cfg.tunnel_port = auto_port
291
+ used_tunnel_ports.add(auto_port)
292
+ auto_port += 1
293
+ else:
294
+ used_tunnel_ports.add(cfg.tunnel_port)
295
+ self.nodes.append(cfg)
296
+
297
+ self.tunnels: Dict[str, SshTunnelSession] = {}
298
+
299
+ def _start_all_tunnels(self):
300
+ for node in self.nodes:
301
+ if not node.is_local:
302
+ tunnel = SshTunnelSession(node)
303
+ tunnel.start(auto_close=self.auto_close_tunnel)
304
+ self.tunnels[node.id] = tunnel
305
+
306
+ def _stop_all_tunnels(self, force: bool = False):
307
+ for tunnel in self.tunnels.values():
308
+ tunnel.stop(force=force or self.auto_close_tunnel)
309
+ self.tunnels.clear()
310
+
311
+ async def _fetch_node_data(self, browser_ctx: BrowserContext, node: NodeConfig) -> dict:
312
+ cookies = await browser_ctx.cookies()
313
+ storage_state = await browser_ctx.storage_state()
314
+
156
315
  page_info = "无打开页面"
157
316
  page_url = ""
158
- if context.pages:
159
- p = context.pages[0]
317
+ page_title = ""
318
+ if browser_ctx.pages:
319
+ p = browser_ctx.pages[0]
160
320
  page_url = p.url
161
321
  try:
162
- title = await p.title()
163
- page_info = f"[{title}] {p.url}"
322
+ page_title = await p.title()
323
+ page_info = f"[{page_title}] {p.url}"
164
324
  except Exception:
165
325
  page_info = p.url
166
- return {"name": name, "cookie_count": len(cookies), "page_info": page_info, "page_url": page_url}
167
326
 
168
- async def _transfer(self, source: BrowserContext, target: BrowserContext, from_name: str, to_name: str):
169
- logging.info(f"[*] 开始同步: 【{from_name}】 ---> 【{to_name}】")
170
- cookies = await source.cookies()
171
- storage_state = await source.storage_state()
172
- logging.info(f" -> 提取到 {len(cookies)} 个 Cookie")
327
+ storage_origins = len(storage_state.get("origins", []))
328
+
329
+ return {
330
+ "id": node.id,
331
+ "name": node.name,
332
+ "is_local": node.is_local,
333
+ "cdp_url": node.cdp_url,
334
+ "online": True,
335
+ "cookie_count": len(cookies),
336
+ "cookies": cookies,
337
+ "page_info": page_info,
338
+ "page_title": page_title,
339
+ "page_url": page_url,
340
+ "storage_origins": storage_origins,
341
+ "storage_state": storage_state
342
+ }
343
+
344
+ async def inspect_async(self) -> Dict[str, dict]:
345
+ """异步获取所有节点的完整状态与 Cookie 数据"""
346
+ self._start_all_tunnels()
347
+ results: Dict[str, dict] = {}
173
348
 
349
+ try:
350
+ from playwright.async_api import async_playwright
351
+ except ImportError:
352
+ raise RuntimeError("[-] 未安装 playwright 模块,请先运行: pip install playwright && playwright install chromium")
353
+
354
+ try:
355
+ async with async_playwright() as p:
356
+ for node in self.nodes:
357
+ cdp_url = node.cdp_url
358
+ try:
359
+ browser = await p.chromium.connect_over_cdp(cdp_url)
360
+ ctx = browser.contexts[0] if browser.contexts else await browser.new_context()
361
+ results[node.id] = await self._fetch_node_data(ctx, node)
362
+ await browser.close()
363
+ except Exception as e:
364
+ logging.warning(f"无法连通节点 [{node.name}] ({cdp_url}): {e}")
365
+ results[node.id] = {
366
+ "id": node.id,
367
+ "name": node.name,
368
+ "is_local": node.is_local,
369
+ "cdp_url": cdp_url,
370
+ "online": False,
371
+ "error": str(e),
372
+ "cookie_count": 0,
373
+ "cookies": [],
374
+ "page_info": "离线",
375
+ "page_title": "",
376
+ "page_url": "",
377
+ "storage_origins": 0,
378
+ "storage_state": {}
379
+ }
380
+ finally:
381
+ self._stop_all_tunnels()
382
+
383
+ return results
384
+
385
+ def inspect(self) -> Dict[str, dict]:
386
+ """同步获取所有节点完整状态与 Cookie 数据 (供代码直接调用)"""
387
+ return asyncio.run(self.inspect_async())
388
+
389
+ def get_cookies(self, node_id: Optional[str] = None) -> Union[List[dict], Dict[str, List[dict]]]:
390
+ """
391
+ 极简 Cookie 查询接口:
392
+ - 传入 node_id: 返回指定节点的 List[dict] Cookie 列表
393
+ - 不传 node_id: 返回全节点字典 { node_id: List[dict] }
394
+ """
395
+ report = self.inspect()
396
+ if node_id:
397
+ return report.get(node_id, {}).get("cookies", [])
398
+ return {nid: data.get("cookies", []) for nid, data in report.items()}
399
+
400
+ def get_summary_table(self, report: Optional[Dict[str, dict]] = None) -> str:
401
+ """生成格式化节点状态 ASCII 摘要表"""
402
+ if report is None:
403
+ report = self.inspect()
404
+
405
+ lines = [
406
+ "\n" + "=" * 90,
407
+ f"{'节点 ID':<10} | {'节点名称':<14} | {'类型':<10} | {'CDP端口/映射':<12} | {'Cookie数':<10} | {'当前页面状态'}",
408
+ "-" * 90
409
+ ]
410
+
411
+ for nid, data in report.items():
412
+ ntype = "本地直连" if data.get("is_local") else "SSH远程"
413
+ port_str = data.get("cdp_url", "").split(":")[-1]
414
+ status_cookie = f"{data.get('cookie_count', 0)} 条" if data.get("online") else "离线"
415
+ page_text = data.get("page_info", "未知")
416
+ if len(page_text) > 32:
417
+ page_text = page_text[:29] + "..."
418
+ lines.append(f"{nid:<10} | {data.get('name', nid):<14} | {ntype:<10} | {port_str:<12} | {status_cookie:<10} | {page_text}")
419
+
420
+ lines.append("=" * 90 + "\n")
421
+ return "\n".join(lines)
422
+
423
+ async def _apply_cookies_and_storage(
424
+ self,
425
+ target_ctx: BrowserContext,
426
+ target_node: NodeConfig,
427
+ cookies: List[dict],
428
+ storage_state: Optional[dict] = None
429
+ ):
174
430
  if cookies:
175
- await target.add_cookies(cookies)
431
+ await target_ctx.add_cookies(cookies)
176
432
 
177
- if target.pages:
178
- p = target.pages[0]
433
+ if target_ctx.pages and storage_state:
434
+ p = target_ctx.pages[0]
179
435
  for origin in storage_state.get("origins", []):
180
436
  origin_url = origin.get("origin", "")
181
437
  items = origin.get("localStorage", [])
182
438
  if items:
183
- # 如果当前是空白页,尝试导航至目标源以允许写入 LocalStorage
184
439
  if p.url.startswith("about:") and origin_url:
185
440
  try:
186
441
  await p.goto(origin_url)
187
442
  except Exception:
188
443
  pass
189
-
190
444
  try:
191
445
  await p.evaluate(
192
446
  """(items) => {
@@ -196,95 +450,121 @@ class ChromiumSync:
196
450
  }""",
197
451
  items
198
452
  )
199
- except Exception as e:
200
- logging.debug(f"LocalStorage 写入跳过: {e}")
453
+ except Exception:
454
+ pass
201
455
  try:
202
456
  await p.reload()
203
- logging.info(f" -> 【{to_name}】页面已自动刷新生效!")
457
+ logging.info(f" -> 节点 [{target_node.name}] 页面热刷新成功!")
204
458
  except Exception:
205
459
  pass
206
460
 
207
- logging.info(f"[+] 同步完成!【{to_name}】已成功获得登录状态。\n")
461
+ async def _run_async(
462
+ self,
463
+ mode: Optional[str] = None,
464
+ source: Optional[str] = None,
465
+ targets: Optional[List[str]] = None
466
+ ):
467
+ active_mode = (mode or self.mode).lower()
468
+ active_source = source or self.source
469
+ active_targets = targets or self.targets
208
470
 
209
- async def _run_async(self, direction_override: Optional[str] = None, timeout_override: Optional[int] = None):
210
- target_dir = direction_override.lower() if direction_override else None
211
- target_timeout = timeout_override if timeout_override is not None else self.timeout
471
+ self._start_all_tunnels()
472
+ try:
473
+ from playwright.async_api import async_playwright
474
+ except ImportError:
475
+ raise RuntimeError("[-] 未安装 playwright 模块,请先运行: pip install playwright && playwright install chromium")
212
476
 
213
- self._start_tunnel()
214
477
  try:
215
478
  async with async_playwright() as p:
216
- logging.info("正在连通【本地】与【远程】容器 CDP 接口...")
217
- try:
218
- browser_remote = await p.chromium.connect_over_cdp(self.remote_cdp_url)
219
- ctx_remote = browser_remote.contexts[0] if browser_remote.contexts else await browser_remote.new_context()
220
- except Exception as e:
221
- logging.error(f"连接【远程容器】失败 ({self.remote_cdp_url}): {e}")
222
- return
479
+ browsers: Dict[str, Any] = {}
480
+ contexts: Dict[str, BrowserContext] = {}
481
+ nodes_data: Dict[str, dict] = {}
223
482
 
224
- try:
225
- browser_local = await p.chromium.connect_over_cdp(self.local_cdp_url)
226
- ctx_local = browser_local.contexts[0] if browser_local.contexts else await browser_local.new_context()
227
- except Exception as e:
228
- logging.error(f"连接【本地容器】失败 ({self.local_cdp_url}): {e}")
229
- await browser_remote.close()
483
+ for node in self.nodes:
484
+ cdp_url = node.cdp_url
485
+ try:
486
+ b = await p.chromium.connect_over_cdp(cdp_url)
487
+ ctx = b.contexts[0] if b.contexts else await b.new_context()
488
+ browsers[node.id] = b
489
+ contexts[node.id] = ctx
490
+ nodes_data[node.id] = await self._fetch_node_data(ctx, node)
491
+ except Exception as e:
492
+ logging.error(f"[-] 连接节点 [{node.name}] 失败 ({cdp_url}): {e}")
493
+
494
+ if not contexts:
495
+ logging.error("[-] 无可用在线节点,退出同步。")
230
496
  return
231
497
 
232
- info_r = await self._get_info(ctx_remote, "远程容器")
233
- info_l = await self._get_info(ctx_local, "本地容器")
234
-
235
- print("\n" + "=" * 65)
236
- print(" 两端容器状态探测就绪")
237
- print("=" * 65)
238
- print(f" [本地] URL/标题 : {info_l['page_info']}")
239
- print(f" Cookie数 : {info_l['cookie_count']} 条 (端口: {self.local_cdp_port})")
240
- print("-" * 65)
241
- print(f" [远程] URL/标题 : {info_r['page_info']}")
242
- print(f" Cookie数 : {info_r['cookie_count']} 条 (端口: {self.remote_tunnel_port})")
243
- print("=" * 65 + "\n")
244
-
245
- # 智能识别推荐
246
- smart_recommend = self.default_direction
247
- if self.default_direction == "auto":
248
- if info_r['cookie_count'] > info_l['cookie_count']:
249
- smart_recommend = "pull"
250
- else:
251
- smart_recommend = "push"
252
-
253
- if not target_dir:
254
- def_key = "1" if smart_recommend == "push" else "2"
255
- def_text = "Push (本地->远程)" if smart_recommend == "push" else "Pull (远程->本地)"
256
-
257
- print(f"请选择同步流向(等待 {target_timeout} 秒自动执行默认操作):")
258
- print(" 1. 本地 -> 远程 (Push: 推送本地凭证至远程) " + ("[默认]" if smart_recommend == "push" else ""))
259
- print(" 2. 远程 -> 本地 (Pull: 拉取远程凭证到本地) " + ("[默认]" if smart_recommend == "pull" else ""))
260
- print(" 0. 退出 (仅查看状态,不执行同步)")
261
-
262
- prompt = f"\n请输入选项 [1/2/0] (默认 {def_key} [{def_text}]): "
263
- choice = await asyncio.to_thread(self._timed_input, prompt, target_timeout, def_key)
264
- choice = choice.strip()
265
-
266
- if choice == "1":
267
- target_dir = "push"
268
- elif choice == "2":
269
- target_dir = "pull"
270
- elif choice == "0":
271
- logging.info("[*] 取消同步,安全退出。")
272
- await browser_remote.close()
273
- await browser_local.close()
274
- return
275
- else:
276
- target_dir = smart_recommend
277
-
278
- if target_dir == "push":
279
- await self._transfer(ctx_local, ctx_remote, "本地容器", "远程容器")
280
- elif target_dir == "pull":
281
- await self._transfer(ctx_remote, ctx_local, "远程容器", "本地容器")
282
-
283
- await browser_remote.close()
284
- await browser_local.close()
498
+ print(self.get_summary_table(nodes_data))
499
+
500
+ if active_mode == "merge":
501
+ logging.info("[*] 正在执行全节点 Cookie 智能 Merge (合并最新有效凭据)...")
502
+ all_cookies = [data["cookies"] for data in nodes_data.values() if data.get("cookies")]
503
+ merged = merge_cookies(all_cookies)
504
+ logging.info(f" -> 汇总融合完成: 共生成 {len(merged)} 条最新有效 Cookie")
505
+
506
+ combined_storage = {"origins": []}
507
+ origin_map = {}
508
+ for data in nodes_data.values():
509
+ for orig in data.get("storage_state", {}).get("origins", []):
510
+ ourl = orig.get("origin")
511
+ if ourl not in origin_map:
512
+ origin_map[ourl] = []
513
+ origin_map[ourl].extend(orig.get("localStorage", []))
514
+ for ourl, items in origin_map.items():
515
+ combined_storage["origins"].append({"origin": ourl, "localStorage": items})
516
+
517
+ dest_nodes = [n for n in self.nodes if n.id in contexts]
518
+ if active_targets:
519
+ dest_nodes = [n for n in dest_nodes if n.id in active_targets]
520
+
521
+ for dest in dest_nodes:
522
+ logging.info(f"[*] 注入合并凭据至节点: [{dest.name}]")
523
+ await self._apply_cookies_and_storage(
524
+ contexts[dest.id],
525
+ dest,
526
+ merged,
527
+ combined_storage
528
+ )
529
+
530
+ elif active_mode == "sync":
531
+ if not active_source:
532
+ active_source = self.nodes[0].id
533
+ if active_source not in contexts:
534
+ raise RuntimeError(f"[-] 指定的源节点 [{active_source}] 未在线!")
535
+
536
+ src_data = nodes_data[active_source]
537
+ src_cookies = src_data["cookies"]
538
+ src_storage = src_data["storage_state"]
539
+
540
+ dest_nodes = [n for n in self.nodes if n.id != active_source and n.id in contexts]
541
+ if active_targets:
542
+ dest_nodes = [n for n in dest_nodes if n.id in active_targets]
543
+
544
+ logging.info(f"[*] 正在执行单源广播同步: [{src_data['name']}] -> {[n.name for n in dest_nodes]}")
545
+ logging.info(f" -> 源凭证量: {len(src_cookies)} 条 Cookie")
546
+
547
+ for dest in dest_nodes:
548
+ logging.info(f"[*] 正在推送凭证至目标节点: [{dest.name}]")
549
+ await self._apply_cookies_and_storage(
550
+ contexts[dest.id],
551
+ dest,
552
+ src_cookies,
553
+ src_storage
554
+ )
555
+
556
+ logging.info("[+] 同步任务已成功完成!")
557
+
558
+ for b in browsers.values():
559
+ await b.close()
285
560
  finally:
286
- self._stop_tunnel()
561
+ self._stop_all_tunnels()
287
562
 
288
- def run(self, direction: Optional[str] = None, timeout: Optional[int] = None):
289
- """同步运行入口 (开箱即用)"""
290
- asyncio.run(self._run_async(direction_override=direction, timeout_override=timeout))
563
+ def run(
564
+ self,
565
+ mode: Optional[str] = None,
566
+ source: Optional[str] = None,
567
+ targets: Optional[List[str]] = None
568
+ ):
569
+ """同步运行入口"""
570
+ asyncio.run(self._run_async(mode=mode, source=source, targets=targets))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wimotek-chromium-sync",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Universal Chromium Session & Cookie Synchronizer with SSH Tunnel",
5
5
  "main": "chromium_sync/__init__.py",
6
6
  "bin": {