186 lines
5.8 KiB
Python
186 lines
5.8 KiB
Python
"""账号导入工具。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import csv
|
|
import io
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any, Iterable, Mapping
|
|
|
|
import yaml
|
|
from loguru import logger
|
|
|
|
from douyu.config import Account
|
|
from douyu.email_verifier import get_email_config_for_account
|
|
from .login_worker import EMAIL_PATTERN, _is_ascii, parse_accounts_text
|
|
|
|
|
|
FIELD_ALIASES = {
|
|
"username": {"username", "user", "account", "账号", "用户名", "斗鱼账号"},
|
|
"password": {"password", "pass", "pwd", "密码", "登录密码"},
|
|
"email": {"email", "mail", "邮箱", "邮箱地址"},
|
|
"email_password": {
|
|
"email_password",
|
|
"email_pass",
|
|
"email_pwd",
|
|
"mail_password",
|
|
"mail_pass",
|
|
"邮箱密码",
|
|
"邮箱授权码",
|
|
"授权码",
|
|
},
|
|
"email_imap_server": {"email_imap_server", "imap_server", "imap", "imap服务器"},
|
|
"email_imap_port": {"email_imap_port", "imap_port", "imap端口"},
|
|
}
|
|
|
|
NORMALIZED_ALIASES = {
|
|
"".join(alias.lower().replace("-", "_").split()): field
|
|
for field, aliases in FIELD_ALIASES.items()
|
|
for alias in aliases
|
|
}
|
|
|
|
|
|
def load_accounts_from_file(filepath: str | Path) -> list[Account]:
|
|
"""从文件导入账号,支持txt/csv/json/yaml。"""
|
|
path = Path(filepath)
|
|
text = _read_text(path)
|
|
suffix = path.suffix.lower()
|
|
|
|
if suffix == ".json":
|
|
return _load_structured_accounts(text, path.name, "json")
|
|
|
|
if suffix in {".yaml", ".yml"}:
|
|
return _load_structured_accounts(text, path.name, "yaml")
|
|
|
|
if suffix == ".csv":
|
|
accounts = _parse_csv_text(text, path.name)
|
|
return accounts or parse_accounts_text(text)
|
|
|
|
return parse_accounts_text(text)
|
|
|
|
|
|
def _read_text(path: Path) -> str:
|
|
"""按常见编码读取文本文件。"""
|
|
last_error: Exception | None = None
|
|
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
|
try:
|
|
return path.read_text(encoding=encoding)
|
|
except UnicodeDecodeError as exc:
|
|
last_error = exc
|
|
|
|
if last_error:
|
|
raise last_error
|
|
return path.read_text(encoding="utf-8")
|
|
|
|
|
|
def _load_structured_accounts(text: str, source: str, file_type: str) -> list[Account]:
|
|
"""读取JSON/YAML中的账号列表。"""
|
|
try:
|
|
if file_type == "json":
|
|
data = json.loads(text)
|
|
else:
|
|
data = yaml.safe_load(text)
|
|
except Exception as exc:
|
|
logger.warning(f"{source} 结构化解析失败,尝试按文本格式导入: {exc}")
|
|
return parse_accounts_text(text)
|
|
|
|
return accounts_from_payload(data, source)
|
|
|
|
|
|
def accounts_from_payload(data: Any, source: str = "导入数据") -> list[Account]:
|
|
"""从结构化数据中提取账号列表。"""
|
|
if isinstance(data, Mapping):
|
|
rows = data.get("accounts") or data.get("data") or data.get("items") or []
|
|
else:
|
|
rows = data
|
|
|
|
if not isinstance(rows, list):
|
|
logger.warning(f"{source} 中未找到账号列表")
|
|
return []
|
|
|
|
return accounts_from_rows(rows, source)
|
|
|
|
|
|
def accounts_from_rows(rows: Iterable[Any], source: str = "导入数据") -> list[Account]:
|
|
"""从字典行列表转换账号。"""
|
|
accounts: list[Account] = []
|
|
for row_index, row in enumerate(rows, 1):
|
|
if not isinstance(row, Mapping):
|
|
logger.warning(f"{source} 第{row_index}行不是对象,已跳过")
|
|
continue
|
|
|
|
account = _account_from_mapping(row, f"{source} 第{row_index}行")
|
|
if account:
|
|
accounts.append(account)
|
|
|
|
return accounts
|
|
|
|
|
|
def _parse_csv_text(text: str, source: str) -> list[Account]:
|
|
"""解析带表头的CSV文件。"""
|
|
reader = csv.reader(io.StringIO(text))
|
|
try:
|
|
first_row = next(reader)
|
|
except StopIteration:
|
|
return []
|
|
|
|
if not _looks_like_header(first_row):
|
|
return []
|
|
|
|
dict_reader = csv.DictReader(io.StringIO(text))
|
|
return accounts_from_rows(dict_reader, source)
|
|
|
|
|
|
def _looks_like_header(row: list[str]) -> bool:
|
|
"""判断CSV首行是否像账号字段表头。"""
|
|
normalized = {
|
|
"".join(str(value).lower().replace("-", "_").split())
|
|
for value in row
|
|
}
|
|
return len(normalized & set(NORMALIZED_ALIASES.keys())) >= 2
|
|
|
|
|
|
def _account_from_mapping(row: Mapping[str, Any], source: str) -> Account | None:
|
|
"""从字典字段构造账号。"""
|
|
normalized_row: dict[str, str] = {}
|
|
for key, value in row.items():
|
|
normalized_key = "".join(str(key).lower().replace("-", "_").split())
|
|
field = NORMALIZED_ALIASES.get(normalized_key)
|
|
if field:
|
|
normalized_row[field] = "" if value is None else str(value).strip()
|
|
|
|
username = normalized_row.get("username", "")
|
|
password = normalized_row.get("password", "")
|
|
email = normalized_row.get("email", "")
|
|
email_password = normalized_row.get("email_password", "")
|
|
|
|
if not all([username, password, email, email_password]):
|
|
logger.warning(f"{source} 存在空字段,已跳过")
|
|
return None
|
|
|
|
if not EMAIL_PATTERN.match(email) or not _is_ascii(email):
|
|
logger.warning(f"{source} 邮箱格式不正确: {email}")
|
|
return None
|
|
|
|
if not _is_ascii(email_password):
|
|
logger.warning(f"{source} 邮箱密码/授权码包含非ASCII字符,IMAP可能无法登录")
|
|
return None
|
|
|
|
email_config = get_email_config_for_account(email)
|
|
imap_server = normalized_row.get("email_imap_server") or email_config["server"]
|
|
try:
|
|
imap_port = int(normalized_row.get("email_imap_port") or email_config["port"])
|
|
except ValueError:
|
|
logger.warning(f"{source} IMAP端口不正确,已使用默认端口")
|
|
imap_port = email_config["port"]
|
|
|
|
return Account(
|
|
username=username,
|
|
password=password,
|
|
email=email,
|
|
email_password=email_password,
|
|
email_imap_server=imap_server,
|
|
email_imap_port=imap_port,
|
|
)
|