browser-ai-cli 0.1.0__py3-none-any.whl
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.
- browser_ai_cli-0.1.0.data/data/browser_ai_examples/ai_sites.example.json +188 -0
- browser_ai_cli-0.1.0.data/data/browser_ai_examples/search_routes.example.json +111 -0
- browser_ai_cli-0.1.0.dist-info/METADATA +315 -0
- browser_ai_cli-0.1.0.dist-info/RECORD +12 -0
- browser_ai_cli-0.1.0.dist-info/WHEEL +5 -0
- browser_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- browser_ai_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- browser_ai_cli-0.1.0.dist-info/top_level.txt +1 -0
- scripts/__init__.py +8 -0
- scripts/browser_ai.py +793 -0
- scripts/import_firefox_login.py +502 -0
- scripts/pre-commit-check.py +182 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Batch-import Firefox login cookies into Chromium profiles.
|
|
5
|
+
|
|
6
|
+
从 Firefox 批量导入登录 cookies 到 Chromium profiles。
|
|
7
|
+
|
|
8
|
+
SECURITY WARNING / 安全警告:
|
|
9
|
+
- This script reads cookies from your local Firefox profile. The cookies
|
|
10
|
+
are equivalent to active login sessions. NEVER share the output profiles.
|
|
11
|
+
- 本脚本会读取你本地 Firefox 的 cookies。这些 cookies 等同于活动登录会话。
|
|
12
|
+
切勿共享输出的 profiles 目录。
|
|
13
|
+
- The resulting profiles live under config/profiles/<site>/ and are
|
|
14
|
+
gitignored by default. Do not change that.
|
|
15
|
+
- 生成的 profiles 保存在 config/profiles/<site>/,默认已被 .gitignore 排除。
|
|
16
|
+
请勿修改。
|
|
17
|
+
|
|
18
|
+
Workflow / 工作原理:
|
|
19
|
+
1. Read cookies.sqlite from a Firefox profile.
|
|
20
|
+
2. For each enabled Chromium site, extract matching cookies by domain.
|
|
21
|
+
3. Inject them via Playwright into a persistent Chromium context.
|
|
22
|
+
4. Verify the login state by visiting the login URL.
|
|
23
|
+
|
|
24
|
+
Usage / 用法:
|
|
25
|
+
python import_firefox_login.py # Import all chromium sites
|
|
26
|
+
python import_firefox_login.py --list # List available sites and Firefox cookies
|
|
27
|
+
python import_firefox_login.py --dry-run # Preview only, do not modify anything
|
|
28
|
+
python import_firefox_login.py --site yuanbao # Import a single site
|
|
29
|
+
python import_firefox_login.py --profile 1 # Pick a specific Firefox profile
|
|
30
|
+
"""
|
|
31
|
+
import asyncio
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import shutil
|
|
35
|
+
import sqlite3
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any, Optional
|
|
40
|
+
from urllib.parse import urlparse
|
|
41
|
+
|
|
42
|
+
SiteDict = dict[str, Any]
|
|
43
|
+
FirefoxCookie = dict[str, Any]
|
|
44
|
+
FirefoxProfileInfo = dict[str, Any]
|
|
45
|
+
|
|
46
|
+
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
47
|
+
CONFIG_DIR = SCRIPT_DIR.parent / "config"
|
|
48
|
+
SITES_FILE = CONFIG_DIR / "ai_sites.json"
|
|
49
|
+
PROFILES_DIR = CONFIG_DIR / "profiles"
|
|
50
|
+
|
|
51
|
+
FIREFOX_PROFILES_DIR = Path(os.environ["APPDATA"]) / "Mozilla" / "Firefox" / "Profiles"
|
|
52
|
+
FIREFOX_INI = Path(os.environ["APPDATA"]) / "Mozilla" / "Firefox" / "profiles.ini"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_json(path):
|
|
56
|
+
if not path.exists():
|
|
57
|
+
return {}
|
|
58
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
59
|
+
return json.load(f)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_sites():
|
|
63
|
+
return load_json(SITES_FILE).get("sites", [])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_site(name):
|
|
67
|
+
for s in get_sites():
|
|
68
|
+
if s["name"] == name:
|
|
69
|
+
return s
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_profile_path(site_name):
|
|
74
|
+
p = PROFILES_DIR / site_name
|
|
75
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
return str(p)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def find_firefox_profiles():
|
|
80
|
+
if not FIREFOX_PROFILES_DIR.exists():
|
|
81
|
+
print("Error: Firefox profiles directory not found at")
|
|
82
|
+
print(f" {FIREFOX_PROFILES_DIR}")
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
profiles = {}
|
|
86
|
+
for d in sorted(FIREFOX_PROFILES_DIR.iterdir()):
|
|
87
|
+
if d.is_dir():
|
|
88
|
+
cookies_path = d / "cookies.sqlite"
|
|
89
|
+
profiles[d.name] = {
|
|
90
|
+
"name": d.name,
|
|
91
|
+
"path": d,
|
|
92
|
+
"has_cookies": cookies_path.exists(),
|
|
93
|
+
"cookies_size": cookies_path.stat().st_size if cookies_path.exists() else 0,
|
|
94
|
+
"is_default": False,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if FIREFOX_INI.exists():
|
|
98
|
+
with open(FIREFOX_INI, "r", encoding="utf-8") as f:
|
|
99
|
+
content = f.read()
|
|
100
|
+
|
|
101
|
+
import re
|
|
102
|
+
install_match = re.search(r'\[Install[^\]]*\]\s*\n\s*Default=(.+)', content)
|
|
103
|
+
if install_match:
|
|
104
|
+
path_part = install_match.group(1).strip()
|
|
105
|
+
p = Path(os.environ["APPDATA"]) / "Mozilla" / "Firefox" / path_part
|
|
106
|
+
if p.name in profiles:
|
|
107
|
+
profiles[p.name]["is_default"] = True
|
|
108
|
+
return list(profiles.values())
|
|
109
|
+
|
|
110
|
+
profile_blocks = re.findall(
|
|
111
|
+
r'\[Profile(\d+)\](.*?)(?=\n\[|\Z)', content,
|
|
112
|
+
re.DOTALL
|
|
113
|
+
)
|
|
114
|
+
for num, block in profile_blocks:
|
|
115
|
+
if re.search(r'\n\s*Default\s*=\s*1', block):
|
|
116
|
+
path_match = re.search(r'\n\s*Path\s*=\s*(.+)', block)
|
|
117
|
+
if path_match:
|
|
118
|
+
p = Path(os.environ["APPDATA"]) / "Mozilla" / "Firefox" / path_match.group(1).strip()
|
|
119
|
+
if p.name in profiles:
|
|
120
|
+
profiles[p.name]["is_default"] = True
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
return list(profiles.values())
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_firefox_cookies(profile_dir):
|
|
127
|
+
cookies_path = profile_dir / "cookies.sqlite"
|
|
128
|
+
if not cookies_path.exists():
|
|
129
|
+
print(f" Error: {cookies_path} does not exist")
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
tmp = Path(tempfile.gettempdir()) / f"_ff_cookies_{os.getpid()}.sqlite"
|
|
133
|
+
try:
|
|
134
|
+
shutil.copy2(str(cookies_path), str(tmp))
|
|
135
|
+
except Exception as e:
|
|
136
|
+
print(f" Error: cannot copy cookies.sqlite: {e}")
|
|
137
|
+
return {}
|
|
138
|
+
|
|
139
|
+
domain_cookies = {}
|
|
140
|
+
try:
|
|
141
|
+
conn = sqlite3.connect(str(tmp))
|
|
142
|
+
conn.row_factory = sqlite3.Row
|
|
143
|
+
cur = conn.cursor()
|
|
144
|
+
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='moz_cookies'")
|
|
145
|
+
if not cur.fetchone():
|
|
146
|
+
print(" Warning: cookies.sqlite does not contain moz_cookies table; is this an old Firefox?")
|
|
147
|
+
conn.close()
|
|
148
|
+
return {}
|
|
149
|
+
cur.execute("""
|
|
150
|
+
SELECT name, value, host, path, isSecure, isHttpOnly, sameSite, expiry
|
|
151
|
+
FROM moz_cookies
|
|
152
|
+
""")
|
|
153
|
+
rows = cur.fetchall()
|
|
154
|
+
conn.close()
|
|
155
|
+
|
|
156
|
+
if not rows:
|
|
157
|
+
print(" Warning: cookies.sqlite has no cookie data")
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
for row in rows:
|
|
161
|
+
host = row["host"]
|
|
162
|
+
if host not in domain_cookies:
|
|
163
|
+
domain_cookies[host] = []
|
|
164
|
+
domain_cookies[host].append({
|
|
165
|
+
"name": row["name"],
|
|
166
|
+
"value": row["value"],
|
|
167
|
+
"host": host,
|
|
168
|
+
"path": row["path"],
|
|
169
|
+
"secure": bool(row["isSecure"]),
|
|
170
|
+
"http_only": bool(row["isHttpOnly"]),
|
|
171
|
+
"same_site": row["sameSite"],
|
|
172
|
+
"expiry": row["expiry"],
|
|
173
|
+
})
|
|
174
|
+
finally:
|
|
175
|
+
try:
|
|
176
|
+
os.remove(str(tmp))
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
|
|
180
|
+
return domain_cookies
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def convert_samesite(ff_value: int) -> str:
|
|
184
|
+
mapping = {0: "None", 1: "Lax", 2: "Strict"}
|
|
185
|
+
return mapping.get(ff_value, "Lax")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cookie_matches_domain(cookie_host, target_domain):
|
|
189
|
+
if cookie_host == target_domain:
|
|
190
|
+
return True
|
|
191
|
+
if cookie_host.startswith("."):
|
|
192
|
+
return target_domain.endswith(cookie_host) or target_domain == cookie_host.lstrip(".")
|
|
193
|
+
return cookie_host == target_domain
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_domains_for_site(site):
|
|
197
|
+
configured = site.get("cookie_domains", [])
|
|
198
|
+
if configured:
|
|
199
|
+
return configured
|
|
200
|
+
|
|
201
|
+
login_url = site.get("login_url", site.get("url", ""))
|
|
202
|
+
parsed = urlparse(login_url)
|
|
203
|
+
host = parsed.netloc.lower()
|
|
204
|
+
|
|
205
|
+
bare_host = host[4:] if host.startswith("www.") else host
|
|
206
|
+
|
|
207
|
+
parts = bare_host.split(".")
|
|
208
|
+
if len(parts) >= 2:
|
|
209
|
+
root_domain = "." + ".".join(parts[-2:])
|
|
210
|
+
else:
|
|
211
|
+
root_domain = "." + bare_host
|
|
212
|
+
|
|
213
|
+
domains = [host, bare_host, root_domain, "." + bare_host]
|
|
214
|
+
domains = list(dict.fromkeys(domains))
|
|
215
|
+
return domains
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
async def inject_cookies_to_chromium(site, cookies_to_inject, dry_run=False):
|
|
219
|
+
name = site["name"]
|
|
220
|
+
display_name = site["display_name"]
|
|
221
|
+
profile_path = get_profile_path(name)
|
|
222
|
+
|
|
223
|
+
if dry_run:
|
|
224
|
+
return {
|
|
225
|
+
"site": name,
|
|
226
|
+
"display": display_name,
|
|
227
|
+
"cookies_found": len(cookies_to_inject),
|
|
228
|
+
"injected": 0,
|
|
229
|
+
"verified": None,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
from playwright.async_api import async_playwright
|
|
233
|
+
|
|
234
|
+
p = await async_playwright().start()
|
|
235
|
+
try:
|
|
236
|
+
context = await p.chromium.launch_persistent_context(
|
|
237
|
+
user_data_dir=profile_path,
|
|
238
|
+
headless=True,
|
|
239
|
+
args=[
|
|
240
|
+
"--no-sandbox",
|
|
241
|
+
"--disable-blink-features=AutomationControlled",
|
|
242
|
+
"--disable-infobars",
|
|
243
|
+
"--disable-dev-shm-usage",
|
|
244
|
+
"--window-size=1280,800",
|
|
245
|
+
],
|
|
246
|
+
viewport={"width": 1280, "height": 800},
|
|
247
|
+
locale="zh-CN",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
pw_cookies = []
|
|
251
|
+
skipped = 0
|
|
252
|
+
for c in cookies_to_inject:
|
|
253
|
+
try:
|
|
254
|
+
pw_c = {
|
|
255
|
+
"name": c["name"],
|
|
256
|
+
"value": c["value"],
|
|
257
|
+
"domain": c["host"],
|
|
258
|
+
"path": c["path"] or "/",
|
|
259
|
+
"secure": c["secure"],
|
|
260
|
+
"httpOnly": c["http_only"],
|
|
261
|
+
"sameSite": convert_samesite(c["same_site"]),
|
|
262
|
+
"expires": c["expiry"] / 1000 if c["expiry"] > 1000000000000 else c["expiry"],
|
|
263
|
+
}
|
|
264
|
+
pw_cookies.append(pw_c)
|
|
265
|
+
except Exception:
|
|
266
|
+
skipped += 1
|
|
267
|
+
|
|
268
|
+
if pw_cookies:
|
|
269
|
+
try:
|
|
270
|
+
await context.add_cookies(pw_cookies)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f" Injection failed: {e}")
|
|
273
|
+
await context.close()
|
|
274
|
+
await p.stop()
|
|
275
|
+
return {
|
|
276
|
+
"site": name,
|
|
277
|
+
"display": display_name,
|
|
278
|
+
"cookies_found": len(cookies_to_inject),
|
|
279
|
+
"injected": 0,
|
|
280
|
+
"verified": False,
|
|
281
|
+
"error": str(e),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
verified = await verify_login(context, site)
|
|
285
|
+
await context.close()
|
|
286
|
+
await p.stop()
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
"site": name,
|
|
290
|
+
"display": display_name,
|
|
291
|
+
"cookies_found": len(cookies_to_inject),
|
|
292
|
+
"injected": len(pw_cookies),
|
|
293
|
+
"skipped": skipped,
|
|
294
|
+
"verified": verified,
|
|
295
|
+
}
|
|
296
|
+
except Exception as e:
|
|
297
|
+
try:
|
|
298
|
+
await p.stop()
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
return {
|
|
302
|
+
"site": name,
|
|
303
|
+
"display": display_name,
|
|
304
|
+
"cookies_found": len(cookies_to_inject),
|
|
305
|
+
"injected": 0,
|
|
306
|
+
"verified": False,
|
|
307
|
+
"error": str(e),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
async def verify_login(context: Any, site: SiteDict) -> Optional[bool]:
|
|
312
|
+
login_url = site.get("login_url", site.get("url", ""))
|
|
313
|
+
name = site["name"]
|
|
314
|
+
try:
|
|
315
|
+
page = await context.new_page()
|
|
316
|
+
await page.goto(login_url, wait_until="domcontentloaded", timeout=30000)
|
|
317
|
+
await page.wait_for_timeout(3000)
|
|
318
|
+
|
|
319
|
+
body = await page.inner_text("body")
|
|
320
|
+
|
|
321
|
+
not_logged_in = any(
|
|
322
|
+
kw in body for kw in ["登录", "未登录", "sign in", "Sign in", "login", "Login"]
|
|
323
|
+
)
|
|
324
|
+
logged_in = any(
|
|
325
|
+
kw in body for kw in [
|
|
326
|
+
"退出", "注销", "我的", "个人中心", "设置",
|
|
327
|
+
"新建对话", "历史记录", "logout", "Logout",
|
|
328
|
+
]
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if name == "baidu":
|
|
332
|
+
logged_in = "用户名" in body or "个人中心" in body or "百度首页" in await page.title()
|
|
333
|
+
not_logged_in = "登录" in body and "退出" not in body
|
|
334
|
+
|
|
335
|
+
if name in ("yuanbao", "doubao", "kimi", "tongyi"):
|
|
336
|
+
if "新建对话" in body or "历史记录" in body or "我的" in body:
|
|
337
|
+
logged_in = True
|
|
338
|
+
not_logged_in = False
|
|
339
|
+
|
|
340
|
+
await page.close()
|
|
341
|
+
|
|
342
|
+
if logged_in:
|
|
343
|
+
return True
|
|
344
|
+
if not_logged_in:
|
|
345
|
+
return False
|
|
346
|
+
return None
|
|
347
|
+
except Exception:
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def main():
|
|
352
|
+
import argparse
|
|
353
|
+
parser = argparse.ArgumentParser(description="Batch-import Firefox login state into Chromium profiles.")
|
|
354
|
+
parser.add_argument("--list", action="store_true", help="List available cookies in Firefox")
|
|
355
|
+
parser.add_argument("--dry-run", action="store_true", help="Preview mode: do not modify anything")
|
|
356
|
+
parser.add_argument("--site", type=str, default=None, help="Only import the specified site")
|
|
357
|
+
parser.add_argument("--profile", type=str, default=None, help="Firefox profile name or number")
|
|
358
|
+
args = parser.parse_args()
|
|
359
|
+
|
|
360
|
+
profiles = find_firefox_profiles()
|
|
361
|
+
if not profiles:
|
|
362
|
+
print("No Firefox profiles found")
|
|
363
|
+
sys.exit(1)
|
|
364
|
+
|
|
365
|
+
if len(profiles) == 1:
|
|
366
|
+
selected = profiles[0]
|
|
367
|
+
else:
|
|
368
|
+
default = None
|
|
369
|
+
print("\nAvailable Firefox Profiles:")
|
|
370
|
+
for i, prof in enumerate(profiles, 1):
|
|
371
|
+
marker = " [default]" if prof.get("is_default") else ""
|
|
372
|
+
size_mb = prof["cookies_size"] / (1024 * 1024)
|
|
373
|
+
status = f"({size_mb:.1f}MB cookies)" if prof["has_cookies"] else "(no cookies)"
|
|
374
|
+
print(f" {i}. {prof['name']} {status}{marker}")
|
|
375
|
+
if prof.get("is_default"):
|
|
376
|
+
default = i
|
|
377
|
+
|
|
378
|
+
if args.profile:
|
|
379
|
+
try:
|
|
380
|
+
idx = int(args.profile)
|
|
381
|
+
selected = profiles[idx - 1]
|
|
382
|
+
except (ValueError, IndexError):
|
|
383
|
+
print(f"Invalid number: {args.profile}")
|
|
384
|
+
sys.exit(1)
|
|
385
|
+
else:
|
|
386
|
+
if default:
|
|
387
|
+
selected = profiles[default - 1]
|
|
388
|
+
print(f"\nAuto-selected default profile: {selected['name']}")
|
|
389
|
+
else:
|
|
390
|
+
choice = input("\nSelect profile (number): ").strip()
|
|
391
|
+
try:
|
|
392
|
+
selected = profiles[int(choice) - 1]
|
|
393
|
+
except (ValueError, IndexError):
|
|
394
|
+
print("Invalid selection")
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
|
|
397
|
+
print(f"\nUsing Firefox profile: {selected['name']}")
|
|
398
|
+
if not selected["has_cookies"]:
|
|
399
|
+
print("This profile has no cookies.sqlite; cannot import")
|
|
400
|
+
sys.exit(1)
|
|
401
|
+
|
|
402
|
+
print("Reading Firefox cookies...")
|
|
403
|
+
domain_cookies = read_firefox_cookies(selected["path"])
|
|
404
|
+
total = sum(len(v) for v in domain_cookies.values())
|
|
405
|
+
print(f" Total: {total} cookies across {len(domain_cookies)} domains")
|
|
406
|
+
|
|
407
|
+
sites = [s for s in get_sites() if s.get("enabled", True) and s.get("preferred_engine", "chromium") == "chromium"]
|
|
408
|
+
if args.site:
|
|
409
|
+
sites = [s for s in sites if s["name"] == args.site]
|
|
410
|
+
if not sites:
|
|
411
|
+
print(f"No chromium site matched: {args.site}")
|
|
412
|
+
print(f"Available: {', '.join(s['name'] for s in get_sites() if s.get('preferred_engine') == 'chromium')}")
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
|
|
415
|
+
if args.list:
|
|
416
|
+
print(f"\n{'Site':<20} {'Domains':<30} {'Matched cookies':<12}")
|
|
417
|
+
print("-" * 62)
|
|
418
|
+
for site in sites:
|
|
419
|
+
domains = get_domains_for_site(site)
|
|
420
|
+
matched = []
|
|
421
|
+
for d in domains:
|
|
422
|
+
for host, cookies in domain_cookies.items():
|
|
423
|
+
if cookie_matches_domain(host, d):
|
|
424
|
+
matched.extend(cookies)
|
|
425
|
+
unique = list({c["name"] for c in matched})
|
|
426
|
+
print(f"{site['name']:<20} {', '.join(domains):<30} {len(unique)}")
|
|
427
|
+
if unique:
|
|
428
|
+
for n in sorted(unique)[:5]:
|
|
429
|
+
print(f" {'':<20} {n}")
|
|
430
|
+
if len(unique) > 5:
|
|
431
|
+
print(f" {'':<20} ... total {len(unique)}")
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
if args.dry_run:
|
|
435
|
+
print(f"\n{'Site':<20} {'Display':<20} {'Cookies':<10} {'Expected'}")
|
|
436
|
+
print("-" * 70)
|
|
437
|
+
for site in sites:
|
|
438
|
+
domains = get_domains_for_site(site)
|
|
439
|
+
matched = []
|
|
440
|
+
for d in domains:
|
|
441
|
+
for host, cookies in domain_cookies.items():
|
|
442
|
+
if cookie_matches_domain(host, d):
|
|
443
|
+
matched.extend(cookies)
|
|
444
|
+
unique = list({c["name"] for c in matched})
|
|
445
|
+
status = "[OK] can import" if unique else "[--] no match"
|
|
446
|
+
print(f"{site['name']:<20} {site['display_name']:<20} {len(unique):<10} {status}")
|
|
447
|
+
print(f"\nRun without --dry-run to actually import")
|
|
448
|
+
return
|
|
449
|
+
|
|
450
|
+
print(f"\nPreparing to import {len(sites)} sites...")
|
|
451
|
+
results = []
|
|
452
|
+
|
|
453
|
+
for site in sites:
|
|
454
|
+
domains = get_domains_for_site(site)
|
|
455
|
+
matched = []
|
|
456
|
+
for d in domains:
|
|
457
|
+
for host, cookies in domain_cookies.items():
|
|
458
|
+
if cookie_matches_domain(host, d):
|
|
459
|
+
matched.extend(cookies)
|
|
460
|
+
|
|
461
|
+
unique_cookies = list({c["name"]: c for c in matched}.values())
|
|
462
|
+
if not unique_cookies:
|
|
463
|
+
print(f"\n {site['display_name']}: no matching cookies, skipping")
|
|
464
|
+
results.append({
|
|
465
|
+
"site": site["name"],
|
|
466
|
+
"display": site["display_name"],
|
|
467
|
+
"cookies_found": 0,
|
|
468
|
+
"injected": 0,
|
|
469
|
+
"verified": None,
|
|
470
|
+
"skipped": 0,
|
|
471
|
+
})
|
|
472
|
+
continue
|
|
473
|
+
|
|
474
|
+
print(f"\n--- {site['display_name']} ---")
|
|
475
|
+
print(f" Matched {len(unique_cookies)} cookies from: {', '.join(domains)}")
|
|
476
|
+
result = asyncio.run(inject_cookies_to_chromium(site, unique_cookies))
|
|
477
|
+
results.append(result)
|
|
478
|
+
|
|
479
|
+
if result.get("error"):
|
|
480
|
+
print(f" [X] Error: {result['error']}")
|
|
481
|
+
else:
|
|
482
|
+
status = {True: "[OK] logged-in", False: "[X] not logged-in", None: "[?] uncertain"}.get(result["verified"], "[?] uncertain")
|
|
483
|
+
print(f" Injected {result['injected']} cookies {status}")
|
|
484
|
+
|
|
485
|
+
print(f"\n{'='*50}")
|
|
486
|
+
print(f"Import summary")
|
|
487
|
+
print(f"{'='*50}")
|
|
488
|
+
print(f"{'Site':<20} {'cookies':<10} {'injected':<8} {'status'}")
|
|
489
|
+
print("-" * 50)
|
|
490
|
+
success = 0
|
|
491
|
+
for r in results:
|
|
492
|
+
status = {True: "[OK] logged-in", False: "[X] not logged-in", None: "[?] uncertain", "": ""}.get(r.get("verified"), "")
|
|
493
|
+
if r.get("verified") is True:
|
|
494
|
+
success += 1
|
|
495
|
+
print(f"{r['display']:<20} {r['cookies_found']:<10} {r['injected']:<8} {status}")
|
|
496
|
+
print(f"\n{len(results)} sites, {success} verified")
|
|
497
|
+
if success < len(results):
|
|
498
|
+
print("Failed sites may need manual login: python browser_ai.py login <site_name>")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
if __name__ == "__main__":
|
|
502
|
+
main()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
Pre-commit safety check.
|
|
5
|
+
|
|
6
|
+
提交前安全检查。
|
|
7
|
+
|
|
8
|
+
Two modes / 两种模式:
|
|
9
|
+
|
|
10
|
+
1. Manual: python scripts/pre-commit-check.py
|
|
11
|
+
Scans the working tree for sensitive files or content.
|
|
12
|
+
|
|
13
|
+
2. Hook: cp scripts/pre-commit-check.py .git/hooks/pre-commit
|
|
14
|
+
Runs on every `git commit`; exits non-zero to abort the commit
|
|
15
|
+
when something dangerous is staged.
|
|
16
|
+
|
|
17
|
+
It blocks / 它会阻止:
|
|
18
|
+
|
|
19
|
+
- Staging any path under config/profiles/ (browser session data).
|
|
20
|
+
- Staging config/ai_sites.json or config/search_routes.json
|
|
21
|
+
(the runtime configs that may contain your custom sites).
|
|
22
|
+
- Staging any *.log file or *.lock file.
|
|
23
|
+
- Tracking known credential patterns: GitHub tokens, AWS keys,
|
|
24
|
+
OpenAI/Anthropic keys, generic API keys, hard-coded Windows paths.
|
|
25
|
+
|
|
26
|
+
It does NOT modify your files. It only reports and exits.
|
|
27
|
+
"""
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
+
|
|
36
|
+
BLOCKED_PATH_PATTERNS = [
|
|
37
|
+
re.compile(r"^config/profiles/"),
|
|
38
|
+
re.compile(r"^config/ai_sites\.json$"),
|
|
39
|
+
re.compile(r"^config/search_routes\.json$"),
|
|
40
|
+
re.compile(r".*\.log$"),
|
|
41
|
+
re.compile(r".*\.lock$"),
|
|
42
|
+
re.compile(r"^.*/chrome_debug\.log$"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
CREDENTIAL_PATTERNS = [
|
|
46
|
+
(re.compile(r"ghp_[A-Za-z0-9]{20,}"), "GitHub personal access token (ghp_)"),
|
|
47
|
+
(re.compile(r"gho_[A-Za-z0-9]{20,}"), "GitHub OAuth token (gho_)"),
|
|
48
|
+
(re.compile(r"ghs_[A-Za-z0-9]{20,}"), "GitHub server token (ghs_)"),
|
|
49
|
+
(re.compile(r"ghu_[A-Za-z0-9]{20,}"), "GitHub user token (ghu_)"),
|
|
50
|
+
(re.compile(r"github_pat_[A-Za-z0-9_]{20,}"), "GitHub fine-grained PAT"),
|
|
51
|
+
(re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key"),
|
|
52
|
+
(re.compile(r"sk-[A-Za-z0-9]{20,}"), "OpenAI/Anthropic style secret key"),
|
|
53
|
+
(re.compile(r"sk_live_[A-Za-z0-9]{20,}"), "Stripe live secret"),
|
|
54
|
+
(re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "Slack token"),
|
|
55
|
+
(re.compile(r"AIza[0-9A-Za-z_-]{35}"), "Google API key"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
PATH_PATTERNS = [
|
|
59
|
+
re.compile(r"C:\\Users\\[^\\/\s\"']+", re.IGNORECASE),
|
|
60
|
+
re.compile(r"/Users/[a-zA-Z0-9._-]+/", re.IGNORECASE),
|
|
61
|
+
re.compile(r"/home/[a-zA-Z0-9._-]+/", re.IGNORECASE),
|
|
62
|
+
re.compile(r"G:\\Trae的聊天", re.IGNORECASE),
|
|
63
|
+
re.compile(r"H:\\Trae", re.IGNORECASE),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
SCAN_EXTENSIONS = {".py", ".json", ".md", ".txt", ".yml", ".yaml", ".bat", ".sh", ".ini", ".cfg", ".toml", ".env"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def run_git(*args):
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
["git", *args],
|
|
73
|
+
cwd=str(REPO_ROOT),
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
check=False,
|
|
77
|
+
)
|
|
78
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
79
|
+
except FileNotFoundError:
|
|
80
|
+
return ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_staged_files():
|
|
84
|
+
out = run_git("diff", "--cached", "--name-only", "--diff-filter=ACMRTUXB")
|
|
85
|
+
if out:
|
|
86
|
+
return [line.strip() for line in out.splitlines() if line.strip()]
|
|
87
|
+
files = []
|
|
88
|
+
for p in REPO_ROOT.rglob("*"):
|
|
89
|
+
if not p.is_file():
|
|
90
|
+
continue
|
|
91
|
+
if any(part in {"__pycache__", ".git", "node_modules"} for part in p.parts):
|
|
92
|
+
continue
|
|
93
|
+
if p.suffix.lower() not in SCAN_EXTENSIONS:
|
|
94
|
+
continue
|
|
95
|
+
files.append(str(p.relative_to(REPO_ROOT)).replace(os.sep, "/"))
|
|
96
|
+
return files
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def scan_path_safety(staged):
|
|
100
|
+
bad = []
|
|
101
|
+
for rel in staged:
|
|
102
|
+
rel_unix = rel.replace(os.sep, "/")
|
|
103
|
+
for pat in BLOCKED_PATH_PATTERNS:
|
|
104
|
+
if pat.search(rel_unix):
|
|
105
|
+
bad.append((rel, f"path matches {pat.pattern}"))
|
|
106
|
+
return bad
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def scan_content(staged):
|
|
110
|
+
findings = []
|
|
111
|
+
for rel in staged:
|
|
112
|
+
rel_unix = rel.replace(os.sep, "/")
|
|
113
|
+
if rel_unix.startswith("scripts/pre-commit-check.py"):
|
|
114
|
+
continue
|
|
115
|
+
if rel_unix.endswith(".example.json"):
|
|
116
|
+
continue
|
|
117
|
+
full = REPO_ROOT / rel
|
|
118
|
+
if not full.exists() or not full.is_file():
|
|
119
|
+
continue
|
|
120
|
+
if full.suffix.lower() not in SCAN_EXTENSIONS:
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
text = full.read_text(encoding="utf-8", errors="ignore")
|
|
124
|
+
except Exception:
|
|
125
|
+
continue
|
|
126
|
+
for pat, label in CREDENTIAL_PATTERNS:
|
|
127
|
+
for m in pat.finditer(text):
|
|
128
|
+
findings.append((rel, f"credential pattern ({label}): {m.group(0)[:8]}..."))
|
|
129
|
+
break
|
|
130
|
+
for pat in PATH_PATTERNS:
|
|
131
|
+
if pat.search(text):
|
|
132
|
+
findings.append((rel, f"hard-coded local path: {pat.pattern}"))
|
|
133
|
+
break
|
|
134
|
+
return findings
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def main():
|
|
138
|
+
staged = get_staged_files()
|
|
139
|
+
if not staged:
|
|
140
|
+
print("[OK] No files to scan.")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
print(f"Scanning {len(staged)} file(s)...\n")
|
|
144
|
+
|
|
145
|
+
path_issues = scan_path_safety(staged)
|
|
146
|
+
content_issues = scan_content(staged)
|
|
147
|
+
|
|
148
|
+
if not path_issues and not content_issues:
|
|
149
|
+
print("[OK] No sensitive files or content detected.")
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
print("[X] Potential issues found:\n")
|
|
153
|
+
|
|
154
|
+
if path_issues:
|
|
155
|
+
print("Blocked paths / 阻止的路径:")
|
|
156
|
+
for path, reason in path_issues:
|
|
157
|
+
print(f" - {path}")
|
|
158
|
+
print(f" {reason}")
|
|
159
|
+
print()
|
|
160
|
+
|
|
161
|
+
if content_issues:
|
|
162
|
+
print("Content matches / 内容匹配:")
|
|
163
|
+
for path, reason in content_issues:
|
|
164
|
+
print(f" - {path}")
|
|
165
|
+
print(f" {reason}")
|
|
166
|
+
print()
|
|
167
|
+
|
|
168
|
+
print("=" * 60)
|
|
169
|
+
print("Aborting commit. / 已中止提交。")
|
|
170
|
+
print("=" * 60)
|
|
171
|
+
print()
|
|
172
|
+
print("Fix options / 修复方法:")
|
|
173
|
+
print(" - Remove the offending file(s) from staging:")
|
|
174
|
+
print(" git restore --staged <file>")
|
|
175
|
+
print(" - If the file is essential and safe, edit it to remove the secret")
|
|
176
|
+
print(" or replace with an environment variable.")
|
|
177
|
+
print(" - For local config files, ensure they are listed in .gitignore.")
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
sys.exit(main())
|