wimotek-chromium-sync 1.0.6 → 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 +137 -53
- package/chromium_sync/__init__.py +5 -4
- package/chromium_sync/cli.py +142 -18
- package/chromium_sync/core.py +485 -205
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,96 +4,180 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/wimotek-chromium-sync)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
专为 Chromium / Chrome
|
|
7
|
+
专为 Chromium / Chrome 设计的现代化会话与 Cookie 集群同步调度工具。
|
|
8
8
|
|
|
9
|
-
支持在本地容器与远程服务器容器之间双向热同步登录凭证(Cookie + LocalStorage),全端口可参数化自定义,内置智能流向识别与带心跳保活的 SSH 隧道自愈管理。
|
|
10
9
|
---
|
|
11
10
|
|
|
12
11
|
## 🌟 核心特性
|
|
13
12
|
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
#### (3) 导出 Cookie 详情为 JSON (便于程序与定时脚本直接提取)
|
|
127
|
+
```bash
|
|
128
|
+
chromium-sync --config cluster.json --status --json > /tmp/cluster_cookies.json
|
|
129
|
+
```
|
|
63
130
|
|
|
64
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
|
80
|
-
|
|
|
81
|
-
|
|
|
82
|
-
|
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
86
|
-
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
0 3 * * * /usr/local/bin/chromium-sync --
|
|
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
|
-
|
|
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.
|
|
7
|
-
__all__ = ["ChromiumSync"]
|
|
7
|
+
__version__ = "1.1.0"
|
|
8
|
+
__all__ = ["ChromiumSync", "NodeConfig", "merge_cookies"]
|
package/chromium_sync/cli.py
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
package/chromium_sync/core.py
CHANGED
|
@@ -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
|
|
16
|
+
import shutil
|
|
8
17
|
import socket
|
|
9
18
|
import subprocess
|
|
10
|
-
import
|
|
19
|
+
import tempfile
|
|
11
20
|
import time
|
|
12
21
|
import urllib.request
|
|
13
|
-
from
|
|
14
|
-
from
|
|
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
|
-
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class NodeConfig:
|
|
18
33
|
"""
|
|
19
|
-
|
|
34
|
+
统一节点配置
|
|
35
|
+
- 本地节点:只需提供 cdp_port (无需提供 ssh_host,自动识别为本地免密直连)
|
|
36
|
+
- 远程节点:提供 ssh_host, cdp_port,可选 ssh_user, ssh_key_path, ssh_key_data, ssh_password
|
|
20
37
|
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
self.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
self.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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.
|
|
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=
|
|
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
|
|
72
|
-
if self.
|
|
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.
|
|
79
|
-
f"(
|
|
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
|
-
|
|
159
|
+
|
|
160
|
+
ssh_cmd = [
|
|
82
161
|
"ssh", "-N",
|
|
83
|
-
"-p", str(self.
|
|
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"{
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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.
|
|
222
|
+
if self.proc.poll() is not None:
|
|
104
223
|
break
|
|
105
224
|
|
|
106
|
-
self.
|
|
107
|
-
raise RuntimeError("[-] 建立 SSH
|
|
225
|
+
self.stop(force=True)
|
|
226
|
+
raise RuntimeError(f"[-] 建立 SSH 隧道失败 [{self.node.name}],请检查网络或认证凭据!")
|
|
108
227
|
|
|
109
|
-
def
|
|
110
|
-
if self.
|
|
228
|
+
def stop(self, force: bool = False):
|
|
229
|
+
if self.is_reused and not force:
|
|
111
230
|
return
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
self.
|
|
116
|
-
self.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
317
|
+
page_title = ""
|
|
318
|
+
if browser_ctx.pages:
|
|
319
|
+
p = browser_ctx.pages[0]
|
|
160
320
|
page_url = p.url
|
|
161
321
|
try:
|
|
162
|
-
|
|
163
|
-
page_info = f"[{
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
431
|
+
await target_ctx.add_cookies(cookies)
|
|
176
432
|
|
|
177
|
-
if
|
|
178
|
-
p =
|
|
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
|
|
200
|
-
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
201
455
|
try:
|
|
202
456
|
await p.reload()
|
|
203
|
-
logging.info(f" ->
|
|
457
|
+
logging.info(f" -> 节点 [{target_node.name}] 页面热刷新成功!")
|
|
204
458
|
except Exception:
|
|
205
459
|
pass
|
|
206
460
|
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
561
|
+
self._stop_all_tunnels()
|
|
287
562
|
|
|
288
|
-
def run(
|
|
289
|
-
|
|
290
|
-
|
|
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))
|