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.
@@ -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())