feat: 完成配置与SQLite地基
- 新增 appconfig 配置读写、默认值、AI 参数和敏感字段拦截 - 新增 SQLite batches/accounts/tasks schema 与阶段写库函数 - 校准本地配置、DB、图片和登录态文件的 gitignore 规则 - 更新任务看板、模块合约、当前状态和进度记录
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
"""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])
|
||||
Reference in New Issue
Block a user