Files
cmshoppe/app/appconfig.py
T
chengma 8ad1754946 feat: 完成配置与SQLite地基
- 新增 appconfig 配置读写、默认值、AI 参数和敏感字段拦截

- 新增 SQLite batches/accounts/tasks schema 与阶段写库函数

- 校准本地配置、DB、图片和登录态文件的 gitignore 规则

- 更新任务看板、模块合约、当前状态和进度记录
2026-06-27 09:02:05 +08:00

166 lines
5.1 KiB
Python

"""Application-level configuration for cmshopee.
This module owns `config.json`, which stores local app settings and AI role/
generation parameters. AI provider definitions and API keys are intentionally
kept out of this file; T-005 will own `config/ai_models.json`.
"""
import copy
import json
import os
CONFIG_PATH = "config.json"
DEFAULT_CONFIG = {
"chrome_path": r"C:\Program Files\Google\Chrome\Application\chrome.exe",
"user_data_root": "chrome_user_data_dir",
"image_dir": "images",
"db_path": "cmshopee.db",
"default_debug_port": 9222,
"debug_port_range": [9222, 9260],
"cdp_ready_timeout": 60,
"ai": {
"default_text_model": "GPT-5.5 文本",
"default_image_model": "Nano Banana 2",
"title_concurrency": 4,
"image_concurrency": 4,
"retry": 2,
"jpg_quality": 90,
"resolution": "1k",
"resolution_timeouts": {
"512": 180,
"1k": 240,
"2k": 360,
"4k": 600,
},
},
}
SECRET_FIELD_NAMES = {"api_key", "apikey", "key", "token", "password"}
class ConfigError(RuntimeError):
"""Raised when app configuration is missing or malformed."""
def default_config() -> dict:
"""Return a new copy of the default config."""
return copy.deepcopy(DEFAULT_CONFIG)
def _deep_merge(defaults, loaded):
if not isinstance(defaults, dict):
return copy.deepcopy(loaded) if loaded is not None else copy.deepcopy(defaults)
merged = copy.deepcopy(defaults)
if not isinstance(loaded, dict):
return merged
for key, value in loaded.items():
if isinstance(merged.get(key), dict) and isinstance(value, dict):
merged[key] = _deep_merge(merged[key], value)
else:
merged[key] = copy.deepcopy(value)
return merged
def _assert_no_secrets(config):
def visit(value, path):
if isinstance(value, dict):
for key, child in value.items():
lowered = str(key).lower()
if lowered in SECRET_FIELD_NAMES or lowered.endswith("_key"):
raise ConfigError(
f"config.json 不允许保存敏感字段: {'.'.join(path + [str(key)])}"
)
visit(child, path + [str(key)])
elif isinstance(value, list):
for index, child in enumerate(value):
visit(child, path + [str(index)])
visit(config, [])
def save_config(config, path=CONFIG_PATH) -> dict:
"""Persist config to JSON and return the normalized config."""
normalized = _deep_merge(DEFAULT_CONFIG, config)
_assert_no_secrets(normalized)
directory = os.path.dirname(os.path.abspath(path))
if directory:
os.makedirs(directory, exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, ensure_ascii=False, indent=2)
fh.write("\n")
return normalized
def load_config(path=CONFIG_PATH) -> dict:
"""Load config, writing defaults first if the file does not exist."""
if not os.path.exists(path):
return save_config(default_config(), path=path)
with open(path, "r", encoding="utf-8") as fh:
try:
loaded = json.load(fh)
except json.JSONDecodeError as exc:
raise ConfigError(f"配置文件不是有效 JSON: {path}") from exc
normalized = _deep_merge(DEFAULT_CONFIG, loaded)
_assert_no_secrets(normalized)
return normalized
def update_config(updates, path=CONFIG_PATH) -> dict:
"""Merge updates into the persisted config."""
config = load_config(path)
return save_config(_deep_merge(config, updates), path=path)
def _config_or_load(config):
return load_config() if config is None else config
def chrome_path(config=None) -> str:
return _config_or_load(config).get("chrome_path", "")
def user_data_root(config=None) -> str:
return _config_or_load(config).get("user_data_root", "chrome_user_data_dir")
def image_dir(config=None) -> str:
return _config_or_load(config).get("image_dir", "images")
def db_path(config=None) -> str:
return _config_or_load(config).get("db_path", "cmshopee.db")
def default_debug_port(config=None) -> int:
return int(_config_or_load(config).get("default_debug_port", 9222))
def debug_port_range(config=None) -> tuple:
values = _config_or_load(config).get("debug_port_range", [9222, 9260])
if not isinstance(values, list) or len(values) != 2:
raise ConfigError("debug_port_range 必须是 [start, end]")
return int(values[0]), int(values[1])
def cdp_ready_timeout(config=None) -> int:
return int(_config_or_load(config).get("cdp_ready_timeout", 60))
def ai_config(config=None) -> dict:
return copy.deepcopy(_config_or_load(config).get("ai", DEFAULT_CONFIG["ai"]))
def response_timeout(config=None) -> int:
ai = ai_config(config)
resolution = str(ai.get("resolution", DEFAULT_CONFIG["ai"]["resolution"]))
timeouts = ai.get("resolution_timeouts", {})
if resolution not in timeouts:
raise ConfigError(f"未配置分辨率 {resolution} 的返回超时")
return int(timeouts[resolution])