flower-trellis 0.4.0-beta.5 → 0.4.1

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.
@@ -1,890 +0,0 @@
1
- #!/usr/bin/env python3
2
- """按模板补全 sub2api 账号导出 JSON,并可直接推送到 sub2api admin 接口。"""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- import copy
8
- import json
9
- import os
10
- import sys
11
- from dataclasses import dataclass
12
- from pathlib import Path
13
- from typing import Any
14
- from urllib import error as urllib_error
15
- from urllib import parse as urllib_parse
16
- from urllib import request as urllib_request
17
-
18
- SKILL_ROOT = Path(__file__).resolve().parent.parent
19
- SKILL_PUSH_ENV_FILE = SKILL_ROOT / "env" / "push.env"
20
-
21
- ENV_PUSH_ENV_FILE_OVERRIDE = "SUB2API_ACCOUNT_JSON_FIX_ENV_FILE"
22
- ENV_PUSH_ADMIN_BASE_URL = "SUB2API_ACCOUNT_JSON_FIX_PUSH_ADMIN_BASE_URL"
23
- ENV_PUSH_TOKEN = "SUB2API_ACCOUNT_JSON_FIX_PUSH_TOKEN"
24
- ENV_PUSH_GROUP_IDS = "SUB2API_ACCOUNT_JSON_FIX_PUSH_GROUP_IDS"
25
- ENV_PUSH_SCHEDULABLE = "SUB2API_ACCOUNT_JSON_FIX_PUSH_SCHEDULABLE"
26
- ENV_PUSH_REFRESH_AFTER_CREATE = "SUB2API_ACCOUNT_JSON_FIX_PUSH_REFRESH_AFTER_CREATE"
27
-
28
- EXAMPLE_PUSH_ADMIN_BASE_URL = "http://127.0.0.1:8080/api/v1/admin"
29
- EXAMPLE_PUSH_TOKEN = "admin-xxxx"
30
-
31
-
32
- @dataclass
33
- class TemplateValues:
34
- """保存从模板账号提取出的补全字段。"""
35
-
36
- model_mapping: dict[str, Any]
37
- allow_overages: bool
38
-
39
-
40
- @dataclass
41
- class FileChangeSummary:
42
- """记录单个文件内的修改统计。"""
43
-
44
- name_fixed: int = 0
45
- model_mapping_fixed: int = 0
46
- allow_overages_fixed: int = 0
47
-
48
- def has_changes(self) -> bool:
49
- """判断当前文件是否发生了任何修改。"""
50
-
51
- return (
52
- self.name_fixed > 0
53
- or self.model_mapping_fixed > 0
54
- or self.allow_overages_fixed > 0
55
- )
56
-
57
-
58
- @dataclass
59
- class PushSummary:
60
- """记录直推 sub2api 的统计结果。"""
61
-
62
- success: int = 0
63
- failed: int = 0
64
-
65
-
66
- def parse_args() -> argparse.Namespace:
67
- """解析命令行参数。"""
68
-
69
- parser = argparse.ArgumentParser(
70
- description="按模板补全 sub2api 账号导出 JSON 的 model_mapping、allow_overages 和 name,并可直接推送到 sub2api。"
71
- )
72
- parser.add_argument(
73
- "--template",
74
- required=True,
75
- help="模板 JSON 文件路径,默认取其第一个账号作为模板来源。",
76
- )
77
- parser.add_argument(
78
- "--template-account-index",
79
- type=int,
80
- default=0,
81
- help="模板文件中作为来源账号的索引,默认 0。",
82
- )
83
- parser.add_argument(
84
- "--glob",
85
- default="sub2api-account-*.json",
86
- help="未显式传入目标文件时使用的匹配模式,默认 sub2api-account-*.json。",
87
- )
88
- mode_group = parser.add_mutually_exclusive_group()
89
- mode_group.add_argument(
90
- "--write",
91
- action="store_true",
92
- help="真正写回文件。未指定时只预览。",
93
- )
94
- mode_group.add_argument(
95
- "--check",
96
- action="store_true",
97
- help="仅检查是否还有待修复项;存在待修复项时退出码为 1。",
98
- )
99
- parser.add_argument(
100
- "--push",
101
- action="store_true",
102
- help="把规范化后的账号直接推送到 sub2api admin 接口。",
103
- )
104
- parser.add_argument(
105
- "--push-admin-base-url",
106
- help="sub2api admin 基础地址,例如 http://127.0.0.1:8080/api/v1/admin。",
107
- )
108
- parser.add_argument(
109
- "--push-token",
110
- help="sub2api admin token。若以 Bearer 开头则走 Authorization,否则走 x-api-key。",
111
- )
112
- parser.add_argument(
113
- "--push-group-id",
114
- action="append",
115
- type=int,
116
- default=[],
117
- help="创建账号时绑定到这些 group_id,可重复传入。",
118
- )
119
- parser.add_argument(
120
- "--push-schedulable",
121
- choices=("true", "false"),
122
- help="创建账号后是否保持可调度。默认 false,也就是创建后立即关闭调度。",
123
- )
124
- parser.add_argument(
125
- "--push-refresh-after-create",
126
- choices=("true", "false"),
127
- help="创建账号后是否立即调用 refresh。默认 true。",
128
- )
129
- parser.add_argument(
130
- "targets",
131
- nargs="*",
132
- help="要处理的目标文件;留空时按 --glob 自动搜索。",
133
- )
134
- return parser.parse_args()
135
-
136
-
137
- def strip_wrapped_quotes(raw_value: str) -> str:
138
- """去掉包裹值的同类引号。"""
139
-
140
- text = str(raw_value or "").strip()
141
- if len(text) >= 2 and text[0] == text[-1] and text[0] in {"'", '"'}:
142
- return text[1:-1]
143
- return text
144
-
145
-
146
- def load_env_file(path: Path) -> dict[str, str]:
147
- """解析简单的 .env 风格文件。"""
148
-
149
- if not path.is_file():
150
- return {}
151
-
152
- result: dict[str, str] = {}
153
- for raw_line in path.read_text(encoding="utf-8").splitlines():
154
- line = raw_line.strip()
155
- if not line or line.startswith("#"):
156
- continue
157
- if line.startswith("export "):
158
- line = line[7:].strip()
159
- key, sep, value = line.partition("=")
160
- if not sep:
161
- continue
162
- normalized_key = key.strip()
163
- if not normalized_key:
164
- continue
165
- result[normalized_key] = strip_wrapped_quotes(value)
166
- return result
167
-
168
-
169
- def parse_group_ids(raw_value: str) -> list[int]:
170
- """把逗号分隔的 group_ids 转成整数列表。"""
171
-
172
- result: list[int] = []
173
- seen: set[int] = set()
174
- for chunk in str(raw_value or "").split(","):
175
- item = chunk.strip()
176
- if not item:
177
- continue
178
- try:
179
- group_id = int(item)
180
- except ValueError as exc:
181
- raise ValueError(f"group_id 不是合法整数: {item}") from exc
182
- if group_id <= 0:
183
- raise ValueError(f"group_id 必须大于 0: {item}")
184
- if group_id not in seen:
185
- seen.add(group_id)
186
- result.append(group_id)
187
- return result
188
-
189
-
190
- def resolve_push_env_file() -> Path:
191
- """解析当前应该读取的 push.env 路径。"""
192
-
193
- override = str(os.getenv(ENV_PUSH_ENV_FILE_OVERRIDE, "")).strip()
194
- if not override:
195
- return SKILL_PUSH_ENV_FILE
196
- return Path(override).expanduser().resolve()
197
-
198
-
199
- def parse_env_bool(raw_value: str) -> bool:
200
- """把环境变量中的布尔字符串转换为布尔值。"""
201
-
202
- normalized = str(raw_value or "").strip().lower()
203
- return normalized in {"1", "true", "yes", "on"}
204
-
205
-
206
- def apply_env_defaults(args: argparse.Namespace) -> argparse.Namespace:
207
- """当命令行未显式传值时,使用技能目录配置和环境变量补全直推配置。"""
208
-
209
- file_env = load_env_file(resolve_push_env_file())
210
- if not args.push_admin_base_url:
211
- args.push_admin_base_url = (
212
- str(file_env.get(ENV_PUSH_ADMIN_BASE_URL, "")).strip()
213
- or str(os.getenv(ENV_PUSH_ADMIN_BASE_URL, "")).strip()
214
- or None
215
- )
216
- if not args.push_token:
217
- args.push_token = (
218
- str(file_env.get(ENV_PUSH_TOKEN, "")).strip()
219
- or str(os.getenv(ENV_PUSH_TOKEN, "")).strip()
220
- or None
221
- )
222
- if not args.push_group_id:
223
- raw_group_ids = (
224
- str(file_env.get(ENV_PUSH_GROUP_IDS, "")).strip()
225
- or str(os.getenv(ENV_PUSH_GROUP_IDS, "")).strip()
226
- )
227
- if raw_group_ids:
228
- args.push_group_id = parse_group_ids(raw_group_ids)
229
- if args.push_schedulable is None:
230
- raw_schedulable = (
231
- str(file_env.get(ENV_PUSH_SCHEDULABLE, "")).strip()
232
- or str(os.getenv(ENV_PUSH_SCHEDULABLE, "")).strip()
233
- )
234
- if raw_schedulable:
235
- args.push_schedulable = "true" if parse_env_bool(raw_schedulable) else "false"
236
- else:
237
- args.push_schedulable = "false"
238
- if args.push_refresh_after_create is None:
239
- raw_refresh_after_create = (
240
- str(file_env.get(ENV_PUSH_REFRESH_AFTER_CREATE, "")).strip()
241
- or str(os.getenv(ENV_PUSH_REFRESH_AFTER_CREATE, "")).strip()
242
- )
243
- if raw_refresh_after_create:
244
- args.push_refresh_after_create = "true" if parse_env_bool(raw_refresh_after_create) else "false"
245
- else:
246
- args.push_refresh_after_create = "true"
247
- return args
248
-
249
-
250
- def is_placeholder_push_config(args: argparse.Namespace) -> bool:
251
- """判断当前直推配置是否仍然是示例占位值。"""
252
-
253
- admin_base_url = str(args.push_admin_base_url or "").strip()
254
- token = str(args.push_token or "").strip()
255
- if token == EXAMPLE_PUSH_TOKEN:
256
- return True
257
- return admin_base_url == EXAMPLE_PUSH_ADMIN_BASE_URL and token == EXAMPLE_PUSH_TOKEN
258
-
259
-
260
- def load_json(path: Path) -> Any:
261
- """读取并解析 JSON 文件。"""
262
-
263
- with path.open("r", encoding="utf-8") as handle:
264
- return json.load(handle)
265
-
266
-
267
- def write_json(path: Path, payload: Any) -> None:
268
- """以统一格式写回 JSON 文件。"""
269
-
270
- text = json.dumps(payload, ensure_ascii=False, indent=2)
271
- path.write_text(text + "\n", encoding="utf-8")
272
-
273
-
274
- def build_target_paths(args: argparse.Namespace, template_path: Path) -> list[Path]:
275
- """构建要处理的目标文件列表,并自动跳过模板文件。"""
276
-
277
- if args.targets:
278
- raw_paths = [Path(item) for item in args.targets]
279
- else:
280
- raw_paths = sorted(Path.cwd().glob(args.glob))
281
-
282
- seen: set[Path] = set()
283
- result: list[Path] = []
284
- template_resolved = template_path.resolve()
285
- for raw_path in raw_paths:
286
- resolved = raw_path.resolve()
287
- if resolved == template_resolved:
288
- continue
289
- if resolved in seen:
290
- continue
291
- seen.add(resolved)
292
- result.append(raw_path)
293
- return result
294
-
295
-
296
- def extract_template_values(template_data: dict[str, Any], account_index: int) -> TemplateValues:
297
- """从模板文件中提取补全时要使用的字段。"""
298
-
299
- accounts = template_data.get("accounts")
300
- if not isinstance(accounts, list) or not accounts:
301
- raise ValueError("模板文件缺少非空的 accounts 数组")
302
- if account_index < 0 or account_index >= len(accounts):
303
- raise ValueError(f"模板账号索引越界: {account_index}")
304
-
305
- template_account = accounts[account_index]
306
- if not isinstance(template_account, dict):
307
- raise ValueError("模板账号结构无效")
308
-
309
- credentials = template_account.get("credentials")
310
- if not isinstance(credentials, dict):
311
- raise ValueError("模板账号缺少 credentials 对象")
312
-
313
- model_mapping = credentials.get("model_mapping")
314
- if not isinstance(model_mapping, dict) or not model_mapping:
315
- raise ValueError("模板账号缺少有效的 credentials.model_mapping")
316
-
317
- extra = template_account.get("extra")
318
- if not isinstance(extra, dict):
319
- raise ValueError("模板账号缺少 extra 对象")
320
-
321
- allow_overages = extra.get("allow_overages")
322
- if not isinstance(allow_overages, bool):
323
- raise ValueError("模板账号缺少布尔类型的 extra.allow_overages")
324
-
325
- return TemplateValues(
326
- model_mapping=copy.deepcopy(model_mapping),
327
- allow_overages=allow_overages,
328
- )
329
-
330
-
331
- def replace_or_insert_key(
332
- payload: dict[str, Any],
333
- key: str,
334
- value: Any,
335
- *,
336
- insert_after: tuple[str, ...] = (),
337
- ) -> tuple[dict[str, Any], bool]:
338
- """替换已有键或按指定相对位置插入新键。"""
339
-
340
- if key in payload:
341
- if payload[key] == value:
342
- return payload, False
343
- updated: dict[str, Any] = {}
344
- for existing_key, existing_value in payload.items():
345
- if existing_key == key:
346
- updated[existing_key] = copy.deepcopy(value)
347
- else:
348
- updated[existing_key] = existing_value
349
- return updated, True
350
-
351
- updated = {}
352
- inserted = False
353
- for existing_key, existing_value in payload.items():
354
- updated[existing_key] = existing_value
355
- if not inserted and existing_key in insert_after:
356
- updated[key] = copy.deepcopy(value)
357
- inserted = True
358
- if not inserted:
359
- updated[key] = copy.deepcopy(value)
360
- return updated, True
361
-
362
-
363
- def normalize_account(
364
- account: dict[str, Any],
365
- template_values: TemplateValues,
366
- ) -> FileChangeSummary:
367
- """补全单个账号对象并返回修改统计。"""
368
-
369
- summary = FileChangeSummary()
370
-
371
- credentials = account.get("credentials")
372
- if not isinstance(credentials, dict):
373
- raise ValueError("账号缺少 credentials 对象")
374
-
375
- updated_credentials, changed = replace_or_insert_key(
376
- credentials,
377
- "model_mapping",
378
- template_values.model_mapping,
379
- insert_after=("expires_at", "email"),
380
- )
381
- if changed:
382
- account["credentials"] = updated_credentials
383
- credentials = updated_credentials
384
- summary.model_mapping_fixed += 1
385
-
386
- email = credentials.get("email")
387
- if isinstance(email, str):
388
- normalized_email = email.strip()
389
- if normalized_email and account.get("name") != normalized_email:
390
- account["name"] = normalized_email
391
- summary.name_fixed += 1
392
-
393
- extra = account.get("extra")
394
- if extra is None:
395
- extra = {}
396
- if not isinstance(extra, dict):
397
- raise ValueError("账号 extra 不是对象")
398
-
399
- updated_extra, changed = replace_or_insert_key(
400
- extra,
401
- "allow_overages",
402
- template_values.allow_overages,
403
- )
404
- if changed or account.get("extra") is None:
405
- account["extra"] = updated_extra
406
- summary.allow_overages_fixed += 1
407
-
408
- return summary
409
-
410
-
411
- def normalize_file(path: Path, template_values: TemplateValues) -> tuple[dict[str, Any], FileChangeSummary]:
412
- """补全单个 JSON 文件中的所有账号。"""
413
-
414
- payload = load_json(path)
415
- if not isinstance(payload, dict):
416
- raise ValueError("JSON 根节点必须是对象")
417
-
418
- accounts = payload.get("accounts")
419
- if not isinstance(accounts, list):
420
- raise ValueError("JSON 缺少 accounts 数组")
421
-
422
- total = FileChangeSummary()
423
- for account in accounts:
424
- if not isinstance(account, dict):
425
- raise ValueError("accounts 中存在非对象元素")
426
- item_summary = normalize_account(account, template_values)
427
- total.name_fixed += item_summary.name_fixed
428
- total.model_mapping_fixed += item_summary.model_mapping_fixed
429
- total.allow_overages_fixed += item_summary.allow_overages_fixed
430
-
431
- return payload, total
432
-
433
-
434
- def print_summary(path: Path, summary: FileChangeSummary, *, action: str) -> None:
435
- """打印单个文件的处理摘要。"""
436
-
437
- print(
438
- f"[{action}] {path}: "
439
- f"name={summary.name_fixed}, "
440
- f"model_mapping={summary.model_mapping_fixed}, "
441
- f"allow_overages={summary.allow_overages_fixed}"
442
- )
443
-
444
-
445
- class Sub2APIClient:
446
- """简易 sub2api admin API 客户端。"""
447
-
448
- def __init__(self, admin_base_url: str, token: str) -> None:
449
- self.admin_base_url = self.normalize_admin_base_url(admin_base_url)
450
- self.token = str(token or "").strip()
451
-
452
- @staticmethod
453
- def normalize_admin_base_url(admin_base_url: str) -> str:
454
- """兼容传入 admin 根路径或具体 accounts/proxies 路径。"""
455
-
456
- url = str(admin_base_url or "").rstrip("/")
457
- for suffix in ("/accounts", "/proxies"):
458
- if url.endswith(suffix):
459
- return url[: -len(suffix)]
460
- return url
461
-
462
- def request(
463
- self,
464
- method: str,
465
- path: str,
466
- *,
467
- payload: Any | None = None,
468
- query: dict[str, Any] | None = None,
469
- ) -> Any:
470
- """发起一次 JSON 请求并返回解包后的 data。"""
471
-
472
- url = self.admin_base_url + path
473
- if query:
474
- normalized_query = {
475
- key: value
476
- for key, value in query.items()
477
- if value is not None and value != ""
478
- }
479
- if normalized_query:
480
- url += "?" + urllib_parse.urlencode(normalized_query)
481
-
482
- headers = {
483
- "Accept": "application/json, text/plain, */*",
484
- }
485
- if payload is not None:
486
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
487
- headers["Content-Type"] = "application/json"
488
- else:
489
- body = None
490
-
491
- if self.token.lower().startswith("bearer "):
492
- headers["Authorization"] = self.token
493
- else:
494
- headers["x-api-key"] = self.token
495
-
496
- request = urllib_request.Request(url=url, data=body, headers=headers, method=method.upper())
497
- try:
498
- with urllib_request.urlopen(request, timeout=30) as response:
499
- raw = response.read().decode("utf-8")
500
- return self._unwrap_response(raw)
501
- except urllib_error.HTTPError as exc:
502
- error_body = exc.read().decode("utf-8", errors="replace")
503
- raise RuntimeError(self._format_error(exc.code, error_body)) from exc
504
- except urllib_error.URLError as exc:
505
- raise RuntimeError(f"请求失败: {exc.reason}") from exc
506
-
507
- @staticmethod
508
- def _unwrap_response(raw_text: str) -> Any:
509
- """解包 sub2api 标准响应格式。"""
510
-
511
- if not raw_text.strip():
512
- return None
513
- payload = json.loads(raw_text)
514
- if isinstance(payload, dict) and "code" in payload:
515
- if payload.get("code") == 0:
516
- return payload.get("data")
517
- raise RuntimeError(str(payload.get("message", "接口返回错误")))
518
- return payload
519
-
520
- @staticmethod
521
- def _format_error(status_code: int, raw_text: str) -> str:
522
- """把 HTTP 错误响应整理成可读文本。"""
523
-
524
- try:
525
- payload = json.loads(raw_text)
526
- except json.JSONDecodeError:
527
- payload = None
528
-
529
- if isinstance(payload, dict):
530
- message = payload.get("message")
531
- reason = payload.get("reason")
532
- if message and reason:
533
- return f"HTTP {status_code}: {message} ({reason})"
534
- if message:
535
- return f"HTTP {status_code}: {message}"
536
- compact = raw_text.strip().replace("\n", " ")
537
- if compact:
538
- return f"HTTP {status_code}: {compact[:300]}"
539
- return f"HTTP {status_code}"
540
-
541
-
542
- def validate_push_args(args: argparse.Namespace) -> None:
543
- """校验直推模式必需参数。"""
544
-
545
- if not args.push:
546
- return
547
- push_env_file = resolve_push_env_file()
548
- if not args.push_admin_base_url:
549
- raise ValueError(
550
- "启用 --push 时必须提供 --push-admin-base-url,或在技能目录配置 "
551
- f"{push_env_file},或设置环境变量 {ENV_PUSH_ADMIN_BASE_URL}"
552
- )
553
- if not args.push_token:
554
- raise ValueError(
555
- "启用 --push 时必须提供 --push-token,或在技能目录配置 "
556
- f"{push_env_file},或设置环境变量 {ENV_PUSH_TOKEN}"
557
- )
558
- if is_placeholder_push_config(args):
559
- raise ValueError(
560
- "检测到直推配置仍是示例占位值,请先让用户提供真实的 sub2api admin 地址和 token,"
561
- f"再更新 {push_env_file} 或命令行参数后重试"
562
- )
563
- for group_id in args.push_group_id:
564
- if group_id <= 0:
565
- raise ValueError(f"group_id 必须大于 0: {group_id}")
566
-
567
-
568
- def build_proxy_key(protocol: str, host: str, port: int, username: str, password: str) -> str:
569
- """按 sub2api 的规则生成 proxy_key。"""
570
-
571
- return "|".join(
572
- [
573
- str(protocol or "").strip(),
574
- str(host or "").strip(),
575
- str(port),
576
- str(username or "").strip(),
577
- str(password or "").strip(),
578
- ]
579
- )
580
-
581
-
582
- def build_proxy_key_from_item(item: dict[str, Any]) -> str:
583
- """从代理对象中提取或构造 proxy_key。"""
584
-
585
- proxy_key = str(item.get("proxy_key", "") or "").strip()
586
- if proxy_key:
587
- return proxy_key
588
- return build_proxy_key(
589
- str(item.get("protocol", "") or ""),
590
- str(item.get("host", "") or ""),
591
- int(item.get("port", 0) or 0),
592
- str(item.get("username", "") or ""),
593
- str(item.get("password", "") or ""),
594
- )
595
-
596
-
597
- def list_remote_proxies(client: Sub2APIClient) -> dict[str, dict[str, Any]]:
598
- """读取远端全部代理,并按 proxy_key 建立索引。"""
599
-
600
- page = 1
601
- page_size = 1000
602
- result: dict[str, dict[str, Any]] = {}
603
- while True:
604
- payload = client.request(
605
- "GET",
606
- "/proxies",
607
- query={"page": page, "page_size": page_size},
608
- )
609
- if not isinstance(payload, dict):
610
- raise RuntimeError("代理列表响应格式不正确")
611
- items = payload.get("items")
612
- if not isinstance(items, list):
613
- raise RuntimeError("代理列表缺少 items")
614
- for item in items:
615
- if not isinstance(item, dict):
616
- continue
617
- key = build_proxy_key(
618
- str(item.get("protocol", "") or ""),
619
- str(item.get("host", "") or ""),
620
- int(item.get("port", 0) or 0),
621
- str(item.get("username", "") or ""),
622
- str(item.get("password", "") or ""),
623
- )
624
- result[key] = item
625
- total = int(payload.get("total", 0) or 0)
626
- if len(items) == 0 or page * page_size >= total:
627
- break
628
- page += 1
629
- return result
630
-
631
-
632
- def ensure_remote_proxy(
633
- client: Sub2APIClient,
634
- remote_proxies: dict[str, dict[str, Any]],
635
- item: dict[str, Any],
636
- ) -> int:
637
- """确保远端存在目标代理,并返回 proxy_id。"""
638
-
639
- key = build_proxy_key_from_item(item)
640
- existing = remote_proxies.get(key)
641
- desired_status = str(item.get("status", "") or "").strip().lower()
642
- if existing is None:
643
- created = client.request(
644
- "POST",
645
- "/proxies",
646
- payload={
647
- "name": str(item.get("name", "") or "imported-proxy"),
648
- "protocol": str(item.get("protocol", "") or "").strip(),
649
- "host": str(item.get("host", "") or "").strip(),
650
- "port": int(item.get("port", 0) or 0),
651
- "username": str(item.get("username", "") or "").strip(),
652
- "password": str(item.get("password", "") or "").strip(),
653
- },
654
- )
655
- if not isinstance(created, dict):
656
- raise RuntimeError("创建代理返回格式不正确")
657
- existing = created
658
- remote_proxies[key] = existing
659
-
660
- proxy_id = int(existing.get("id", 0) or 0)
661
- if proxy_id <= 0:
662
- raise RuntimeError("远端代理缺少有效 id")
663
-
664
- current_status = str(existing.get("status", "") or "").strip().lower()
665
- if desired_status in {"active", "inactive"} and current_status != desired_status:
666
- updated = client.request(
667
- "PUT",
668
- f"/proxies/{proxy_id}",
669
- payload={"status": desired_status},
670
- )
671
- if isinstance(updated, dict):
672
- remote_proxies[key] = updated
673
- return proxy_id
674
-
675
-
676
- def build_create_account_payload(
677
- account: dict[str, Any],
678
- proxy_id: int | None,
679
- group_ids: list[int],
680
- ) -> dict[str, Any]:
681
- """把导出 JSON 中的账号对象转换为创建接口所需 payload。"""
682
-
683
- payload = {
684
- "name": account.get("name"),
685
- "notes": account.get("notes"),
686
- "platform": account.get("platform"),
687
- "type": account.get("type"),
688
- "credentials": account.get("credentials"),
689
- "extra": account.get("extra") or {},
690
- "proxy_id": proxy_id,
691
- "concurrency": int(account.get("concurrency", 0) or 0),
692
- "priority": int(account.get("priority", 0) or 0),
693
- "rate_multiplier": account.get("rate_multiplier"),
694
- "expires_at": account.get("expires_at"),
695
- "auto_pause_on_expired": account.get("auto_pause_on_expired", True),
696
- }
697
- if group_ids:
698
- payload["group_ids"] = group_ids
699
- return payload
700
-
701
-
702
- def set_account_schedulable(client: Sub2APIClient, account_id: int, schedulable: bool) -> None:
703
- """创建账号后显式设置调度开关。"""
704
-
705
- client.request(
706
- "POST",
707
- f"/accounts/{account_id}/schedulable",
708
- payload={"schedulable": schedulable},
709
- )
710
-
711
-
712
- def should_refresh_after_create(account: dict[str, Any], refresh_after_create: bool) -> bool:
713
- """判断当前账号创建后是否应触发 refresh。"""
714
-
715
- if not refresh_after_create:
716
- return False
717
- if str(account.get("type", "") or "").strip() != "oauth":
718
- return False
719
- credentials = account.get("credentials")
720
- if not isinstance(credentials, dict):
721
- return False
722
- refresh_token = str(credentials.get("refresh_token", "") or "").strip()
723
- return refresh_token != ""
724
-
725
-
726
- def refresh_account_after_create(client: Sub2APIClient, account_id: int) -> None:
727
- """创建账号后触发一次远端 refresh。"""
728
-
729
- client.request("POST", f"/accounts/{account_id}/refresh", payload={})
730
-
731
-
732
- def push_files_by_create(
733
- client: Sub2APIClient,
734
- prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]],
735
- args: argparse.Namespace,
736
- ) -> PushSummary:
737
- """通过 `/accounts` 逐个创建账号到 sub2api。"""
738
-
739
- summary = PushSummary()
740
- remote_proxies = list_remote_proxies(client)
741
- target_schedulable = str(args.push_schedulable or "false").lower() == "true"
742
- refresh_after_create = str(args.push_refresh_after_create or "true").lower() == "true"
743
-
744
- for path, payload, _ in prepared_files:
745
- proxies = payload.get("proxies")
746
- accounts = payload.get("accounts")
747
- if not isinstance(proxies, list) or not isinstance(accounts, list):
748
- print(f"[错误] {path}: 不是合法的 sub2api bundle JSON", file=sys.stderr)
749
- summary.failed += 1
750
- continue
751
-
752
- proxy_id_by_key: dict[str, int] = {}
753
- for item in proxies:
754
- if not isinstance(item, dict):
755
- continue
756
- try:
757
- proxy_id_by_key[build_proxy_key_from_item(item)] = ensure_remote_proxy(
758
- client, remote_proxies, item
759
- )
760
- except Exception as exc: # noqa: BLE001
761
- summary.failed += 1
762
- print(
763
- f"[错误] {path}: 代理 {item.get('name') or item.get('host') or '-'} 推送失败: {exc}",
764
- file=sys.stderr,
765
- )
766
-
767
- for account in accounts:
768
- if not isinstance(account, dict):
769
- summary.failed += 1
770
- print(f"[错误] {path}: accounts 中存在非法元素", file=sys.stderr)
771
- continue
772
-
773
- try:
774
- proxy_key = str(account.get("proxy_key", "") or "").strip()
775
- proxy_id = proxy_id_by_key.get(proxy_key) if proxy_key else None
776
- if proxy_key and proxy_id is None:
777
- raise RuntimeError(f"找不到对应代理: {proxy_key}")
778
- created = client.request(
779
- "POST",
780
- "/accounts",
781
- payload=build_create_account_payload(account, proxy_id, args.push_group_id),
782
- )
783
- if not isinstance(created, dict):
784
- raise RuntimeError("创建账号返回格式不正确")
785
- account_id = int(created.get("id", 0) or 0)
786
- if account_id <= 0:
787
- raise RuntimeError("创建账号成功但返回中缺少有效 id")
788
- set_account_schedulable(client, account_id, target_schedulable)
789
- refresh_status = "skipped"
790
- if should_refresh_after_create(account, refresh_after_create):
791
- refresh_account_after_create(client, account_id)
792
- refresh_status = "triggered"
793
- summary.success += 1
794
- group_text = ",".join(str(item) for item in args.push_group_id) if args.push_group_id else "-"
795
- print(
796
- f"[直推/create] {path}: "
797
- f"account_id={account_id}, "
798
- f"name={created.get('name', account.get('name', ''))}, "
799
- f"group_ids={group_text}, "
800
- f"schedulable={str(target_schedulable).lower()}, "
801
- f"refresh={refresh_status}"
802
- )
803
- except Exception as exc: # noqa: BLE001
804
- summary.failed += 1
805
- print(
806
- f"[错误] {path}: 账号 {account.get('name') or '-'} 推送失败: {exc}",
807
- file=sys.stderr,
808
- )
809
-
810
- return summary
811
-
812
-
813
- def push_prepared_files(
814
- args: argparse.Namespace,
815
- prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]],
816
- ) -> PushSummary:
817
- """把已规范化的数据推送到 sub2api。"""
818
-
819
- validate_push_args(args)
820
- client = Sub2APIClient(args.push_admin_base_url, args.push_token)
821
- return push_files_by_create(client, prepared_files, args)
822
-
823
-
824
- def main() -> int:
825
- """执行入口。"""
826
-
827
- args = apply_env_defaults(parse_args())
828
- template_path = Path(args.template)
829
- if not template_path.is_file():
830
- print(f"模板文件不存在: {template_path}", file=sys.stderr)
831
- return 2
832
-
833
- try:
834
- template_data = load_json(template_path)
835
- if not isinstance(template_data, dict):
836
- raise ValueError("模板 JSON 根节点必须是对象")
837
- template_values = extract_template_values(template_data, args.template_account_index)
838
- except Exception as exc: # noqa: BLE001
839
- print(f"读取模板失败: {exc}", file=sys.stderr)
840
- return 2
841
-
842
- target_paths = build_target_paths(args, template_path)
843
- if not target_paths:
844
- print("没有找到需要处理的目标文件", file=sys.stderr)
845
- return 2
846
-
847
- changed_files = 0
848
- had_error = False
849
- action = "写入" if args.write else "预览"
850
- prepared_files: list[tuple[Path, dict[str, Any], FileChangeSummary]] = []
851
-
852
- for path in target_paths:
853
- try:
854
- payload, summary = normalize_file(path, template_values)
855
- except Exception as exc: # noqa: BLE001
856
- had_error = True
857
- print(f"[错误] {path}: {exc}", file=sys.stderr)
858
- continue
859
-
860
- if not summary.has_changes():
861
- print_summary(path, summary, action="跳过")
862
- else:
863
- changed_files += 1
864
- if args.write:
865
- write_json(path, payload)
866
- print_summary(path, summary, action=action)
867
- prepared_files.append((path, payload, summary))
868
-
869
- print(f"总目标文件: {len(target_paths)}")
870
- print(f"发生修改的文件: {changed_files}")
871
-
872
- if had_error:
873
- return 2
874
- if args.push:
875
- try:
876
- push_summary = push_prepared_files(args, prepared_files)
877
- except Exception as exc: # noqa: BLE001
878
- print(f"直推失败: {exc}", file=sys.stderr)
879
- return 2
880
- print(f"直推成功: {push_summary.success}")
881
- print(f"直推失败: {push_summary.failed}")
882
- if push_summary.failed > 0:
883
- return 2
884
- if args.check and changed_files > 0:
885
- return 1
886
- return 0
887
-
888
-
889
- if __name__ == "__main__":
890
- sys.exit(main())