rednote-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.
- rednote_cli/__init__.py +5 -0
- rednote_cli/_runtime/__init__.py +0 -0
- rednote_cli/_runtime/common/__init__.py +0 -0
- rednote_cli/_runtime/common/app_utils.py +77 -0
- rednote_cli/_runtime/common/config.py +83 -0
- rednote_cli/_runtime/common/enums.py +17 -0
- rednote_cli/_runtime/common/errors.py +22 -0
- rednote_cli/_runtime/core/__init__.py +0 -0
- rednote_cli/_runtime/core/account_manager.py +349 -0
- rednote_cli/_runtime/core/browser/__init__.py +0 -0
- rednote_cli/_runtime/core/browser/manager.py +247 -0
- rednote_cli/_runtime/core/database/__init__.py +0 -0
- rednote_cli/_runtime/core/database/manager.py +334 -0
- rednote_cli/_runtime/platforms/__init__.py +0 -0
- rednote_cli/_runtime/platforms/base.py +62 -0
- rednote_cli/_runtime/platforms/factory.py +55 -0
- rednote_cli/_runtime/platforms/publishing/__init__.py +12 -0
- rednote_cli/_runtime/platforms/publishing/media.py +275 -0
- rednote_cli/_runtime/platforms/publishing/models.py +59 -0
- rednote_cli/_runtime/platforms/publishing/validator.py +124 -0
- rednote_cli/_runtime/services/__init__.py +1 -0
- rednote_cli/_runtime/services/scraper_service.py +235 -0
- rednote_cli/adapters/__init__.py +1 -0
- rednote_cli/adapters/output/__init__.py +1 -0
- rednote_cli/adapters/output/event_stream.py +29 -0
- rednote_cli/adapters/output/formatter_json.py +23 -0
- rednote_cli/adapters/output/formatter_table.py +39 -0
- rednote_cli/adapters/output/writer.py +17 -0
- rednote_cli/adapters/persistence/__init__.py +1 -0
- rednote_cli/adapters/persistence/file_account_repo.py +51 -0
- rednote_cli/adapters/platform/__init__.py +1 -0
- rednote_cli/adapters/platform/rednote/__init__.py +1 -0
- rednote_cli/adapters/platform/rednote/extractor.py +65 -0
- rednote_cli/adapters/platform/rednote/publisher.py +26 -0
- rednote_cli/adapters/platform/rednote/runtime_extractor.py +818 -0
- rednote_cli/adapters/platform/rednote/runtime_publisher.py +373 -0
- rednote_cli/adapters/platform/rednote/runtime_registration.py +20 -0
- rednote_cli/application/__init__.py +1 -0
- rednote_cli/application/dto/__init__.py +1 -0
- rednote_cli/application/dto/input_models.py +121 -0
- rednote_cli/application/dto/output_models.py +78 -0
- rednote_cli/application/use_cases/__init__.py +1 -0
- rednote_cli/application/use_cases/account_list.py +9 -0
- rednote_cli/application/use_cases/account_mutation.py +22 -0
- rednote_cli/application/use_cases/auth_login.py +64 -0
- rednote_cli/application/use_cases/auth_status.py +96 -0
- rednote_cli/application/use_cases/doctor.py +49 -0
- rednote_cli/application/use_cases/init_runtime.py +20 -0
- rednote_cli/application/use_cases/note_get.py +22 -0
- rednote_cli/application/use_cases/note_search.py +26 -0
- rednote_cli/application/use_cases/publish_note.py +25 -0
- rednote_cli/application/use_cases/user_get.py +18 -0
- rednote_cli/application/use_cases/user_search.py +8 -0
- rednote_cli/application/use_cases/user_self.py +8 -0
- rednote_cli/cli/__init__.py +1 -0
- rednote_cli/cli/__main__.py +5 -0
- rednote_cli/cli/commands/__init__.py +1 -0
- rednote_cli/cli/commands/account.py +204 -0
- rednote_cli/cli/commands/doctor.py +20 -0
- rednote_cli/cli/commands/init.py +20 -0
- rednote_cli/cli/commands/note.py +101 -0
- rednote_cli/cli/commands/publish.py +147 -0
- rednote_cli/cli/commands/search.py +185 -0
- rednote_cli/cli/commands/user.py +113 -0
- rednote_cli/cli/main.py +163 -0
- rednote_cli/cli/options.py +13 -0
- rednote_cli/cli/runtime.py +142 -0
- rednote_cli/cli/utils.py +74 -0
- rednote_cli/domain/__init__.py +1 -0
- rednote_cli/domain/errors.py +50 -0
- rednote_cli/domain/note_search_filters.py +155 -0
- rednote_cli/infra/__init__.py +1 -0
- rednote_cli/infra/exit_codes.py +30 -0
- rednote_cli/infra/logger.py +11 -0
- rednote_cli/infra/paths.py +31 -0
- rednote_cli/infra/platforms.py +4 -0
- rednote_cli-0.1.0.dist-info/METADATA +81 -0
- rednote_cli-0.1.0.dist-info/RECORD +81 -0
- rednote_cli-0.1.0.dist-info/WHEEL +5 -0
- rednote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rednote_cli-0.1.0.dist-info/top_level.txt +1 -0
rednote_cli/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import sys
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_app_path(relative_path):
|
|
8
|
+
"""
|
|
9
|
+
获取资源绝对路径(兼容 Dev 和 Frozen 环境)
|
|
10
|
+
|
|
11
|
+
在 PyInstaller --onedir 模式下,macOS .app 的结构通常是:
|
|
12
|
+
OperatorRuntime.app/
|
|
13
|
+
Contents/
|
|
14
|
+
MacOS/
|
|
15
|
+
OperatorRuntime (可执行文件)
|
|
16
|
+
assets/ (如果 spec 中配置在同级)
|
|
17
|
+
Resources/ (标准 Mac 资源目录)
|
|
18
|
+
|
|
19
|
+
我们现在的 spec 配置是将 assets 放在可执行文件同级目录。
|
|
20
|
+
"""
|
|
21
|
+
if getattr(sys, 'frozen', False):
|
|
22
|
+
# 打包后的环境
|
|
23
|
+
bundle_dir = os.path.dirname(sys.executable)
|
|
24
|
+
return os.path.join(bundle_dir, relative_path)
|
|
25
|
+
else:
|
|
26
|
+
# 开发环境
|
|
27
|
+
return os.path.join(os.path.abspath("."), relative_path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_system_chrome_path():
|
|
31
|
+
"""
|
|
32
|
+
获取系统已安装的 Chrome/Edge 路径,避免打包几百兆的浏览器。
|
|
33
|
+
优先查找 Chrome,其次 Edge。
|
|
34
|
+
"""
|
|
35
|
+
system = platform.system()
|
|
36
|
+
paths = []
|
|
37
|
+
|
|
38
|
+
if system == "Darwin": # macOS
|
|
39
|
+
paths = [
|
|
40
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
41
|
+
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
|
42
|
+
# 用户可能安装在用户目录下
|
|
43
|
+
os.path.expanduser("~/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"),
|
|
44
|
+
]
|
|
45
|
+
elif system == "Windows":
|
|
46
|
+
paths = [
|
|
47
|
+
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
|
|
48
|
+
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
|
|
49
|
+
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
50
|
+
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
for p in paths:
|
|
54
|
+
if os.path.exists(p):
|
|
55
|
+
return p
|
|
56
|
+
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def timestamp_to_str(ts, fmt='%Y-%m-%d %H:%M:%S', tz=None):
|
|
61
|
+
"""
|
|
62
|
+
自动识别10位或13位时间戳并转为日期字符串
|
|
63
|
+
"""
|
|
64
|
+
if not ts: return ts
|
|
65
|
+
|
|
66
|
+
# 如果是字符串类型的输入,先转为数字
|
|
67
|
+
try:
|
|
68
|
+
ts = int(ts)
|
|
69
|
+
except Exception:
|
|
70
|
+
return ts
|
|
71
|
+
|
|
72
|
+
# 核心逻辑:如果数值大于 1e11 (即 100,000,000,000),通常被认为是毫秒级
|
|
73
|
+
# 10位时间戳最大到 2286年 (9999999999)
|
|
74
|
+
if ts > 10000000000:
|
|
75
|
+
ts = ts / 1000
|
|
76
|
+
|
|
77
|
+
return datetime.fromtimestamp(ts, tz=tz).strftime(fmt)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _read_bool_env(name: str, default: bool) -> bool:
|
|
7
|
+
raw = os.getenv(name)
|
|
8
|
+
if raw is None:
|
|
9
|
+
return default
|
|
10
|
+
text = str(raw).strip().lower()
|
|
11
|
+
if text in {"1", "true", "yes", "on"}:
|
|
12
|
+
return True
|
|
13
|
+
if text in {"0", "false", "no", "off"}:
|
|
14
|
+
return False
|
|
15
|
+
return default
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_app_data_dir(app_name="OperatorRuntime"):
|
|
19
|
+
"""
|
|
20
|
+
Get the platform-specific user data directory.
|
|
21
|
+
"""
|
|
22
|
+
system = platform.system()
|
|
23
|
+
if system == "Windows":
|
|
24
|
+
return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) / app_name
|
|
25
|
+
elif system == "Darwin":
|
|
26
|
+
return Path.home() / "Library" / "Application Support" / app_name
|
|
27
|
+
else:
|
|
28
|
+
# Linux / Unix
|
|
29
|
+
return Path.home() / ".local" / "share" / app_name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Initialize paths
|
|
33
|
+
APP_DATA_DIR = get_app_data_dir()
|
|
34
|
+
try:
|
|
35
|
+
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
except PermissionError:
|
|
37
|
+
# Sandbox-friendly fallback for restricted environments (tests/CI).
|
|
38
|
+
APP_DATA_DIR = Path.cwd() / ".operator_runtime_data"
|
|
39
|
+
APP_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
|
|
41
|
+
# Database path
|
|
42
|
+
DB_FILE = APP_DATA_DIR / "operator_runtime.db"
|
|
43
|
+
|
|
44
|
+
# Log path
|
|
45
|
+
LOG_DIR = APP_DATA_DIR / "logs"
|
|
46
|
+
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
LOG_FILE = LOG_DIR / "app.log"
|
|
48
|
+
|
|
49
|
+
# --- 调试配置 ---
|
|
50
|
+
# 默认行为:无头(生产)
|
|
51
|
+
# 开发调试可通过环境变量强制有头:
|
|
52
|
+
# - REDNOTE_CLI_HEADFUL=1
|
|
53
|
+
# - BROWSER_HEADFUL=1
|
|
54
|
+
BROWSER_HEADLESS = _read_bool_env("BROWSER_HEADLESS", True)
|
|
55
|
+
_BROWSER_HEADFUL_FORCED = _read_bool_env("REDNOTE_CLI_HEADFUL", False) or _read_bool_env("BROWSER_HEADFUL", False)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_browser_headful_forced() -> bool:
|
|
59
|
+
return _BROWSER_HEADFUL_FORCED
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_browser_headless(requested: bool | None = None) -> bool:
|
|
63
|
+
if _BROWSER_HEADFUL_FORCED:
|
|
64
|
+
return False
|
|
65
|
+
if requested is None:
|
|
66
|
+
return BROWSER_HEADLESS
|
|
67
|
+
return bool(requested)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_login_mode_headless(mode: str) -> bool:
|
|
71
|
+
normalized_mode = str(mode or "").strip().lower()
|
|
72
|
+
if normalized_mode == "local":
|
|
73
|
+
# Local login is interactive by design.
|
|
74
|
+
return resolve_browser_headless(False)
|
|
75
|
+
if normalized_mode == "remote":
|
|
76
|
+
# Remote login defaults to headless, but follows BROWSER_HEADLESS env.
|
|
77
|
+
return resolve_browser_headless(None)
|
|
78
|
+
return resolve_browser_headless()
|
|
79
|
+
|
|
80
|
+
# --- 浏览器环境配置 ---
|
|
81
|
+
# 建议与账号真实访问地区保持一致,减少环境指纹漂移。
|
|
82
|
+
BROWSER_LOCALE = os.getenv("BROWSER_LOCALE", "zh-CN")
|
|
83
|
+
BROWSER_TIMEZONE = os.getenv("BROWSER_TIMEZONE", "Asia/Shanghai")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Platform(Enum):
|
|
5
|
+
REDNOTE = "Rednote"
|
|
6
|
+
DOUYIN = "Douyin"
|
|
7
|
+
TIKTOK = "TikTok"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class XsecSource(Enum):
|
|
11
|
+
PC_FEED = "pc_feed" # 发现页 → 笔记;发现页 → 达人
|
|
12
|
+
PC_SEARCH = "pc_search" # 搜索页 → 笔记;搜索页 → 达人
|
|
13
|
+
PC_COLLECT = "pc_collect" # 达人-收藏页 → 笔记;达人-收藏页 → 达人
|
|
14
|
+
PC_LIKE = "pc_like" # 达人-点赞页 → 笔记;达人-点赞页 → 达人
|
|
15
|
+
PC_USER = "pc_user" # 达人-笔记页 → 笔记
|
|
16
|
+
PC_NOTE = "pc_note" # 笔记页 → 达人
|
|
17
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class PublishNoteException(Exception):
|
|
2
|
+
"""Base exception for publish-note workflow."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InvalidPublishParameterError(PublishNoteException, ValueError):
|
|
6
|
+
"""Raised when publish-note inputs are invalid."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnsupportedPublishTargetError(PublishNoteException):
|
|
10
|
+
"""Raised when publish target is unsupported."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PublishWorkflowNotReadyError(PublishNoteException):
|
|
14
|
+
"""Raised when target exists but workflow is not implemented yet."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PublishMediaPreparationError(PublishNoteException):
|
|
18
|
+
"""Raised when media validation/download fails."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PublishExecutionError(PublishNoteException):
|
|
22
|
+
"""Raised when publish execution fails unexpectedly."""
|
|
File without changes
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import random
|
|
3
|
+
import time
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable, Optional, Set
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
import rednote_cli._runtime.common.config as runtime_config
|
|
11
|
+
from rednote_cli._runtime.common.enums import Platform
|
|
12
|
+
from rednote_cli._runtime.core.browser.manager import stealth_async
|
|
13
|
+
from rednote_cli._runtime.core.database.manager import DatabaseManager
|
|
14
|
+
from rednote_cli._runtime.platforms.factory import PlatformFactory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AccountLeaseManager:
|
|
18
|
+
"""Manages account locking and leasing for multi-tenant isolation."""
|
|
19
|
+
_locked_accounts: Set[str] = set() # format: "platform:user_no"
|
|
20
|
+
_lock = asyncio.Lock()
|
|
21
|
+
|
|
22
|
+
@classmethod
|
|
23
|
+
def build_account_key(cls, platform: Platform, user_no: str) -> str:
|
|
24
|
+
return f"{platform.value}:{user_no}"
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def _match_account_identifier(cls, account: dict, identifier: str) -> bool:
|
|
28
|
+
target = str(identifier or "").strip()
|
|
29
|
+
if not target:
|
|
30
|
+
return False
|
|
31
|
+
user_no = str(account.get("user_no") or "").strip()
|
|
32
|
+
return target == user_no
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
async def try_acquire_account_lock(cls, platform: Platform, user_no: str) -> bool:
|
|
36
|
+
if not user_no:
|
|
37
|
+
return False
|
|
38
|
+
accounts = DatabaseManager.get_all_accounts(only_active=True)
|
|
39
|
+
valid_accounts = [acc for acc in accounts if acc['platform'] == platform.value]
|
|
40
|
+
target = next((acc for acc in valid_accounts if cls._match_account_identifier(acc, user_no)), None)
|
|
41
|
+
if not target:
|
|
42
|
+
return False
|
|
43
|
+
key = cls.build_account_key(platform, target["user_no"])
|
|
44
|
+
async with cls._lock:
|
|
45
|
+
if key in cls._locked_accounts:
|
|
46
|
+
return False
|
|
47
|
+
cls._locked_accounts.add(key)
|
|
48
|
+
logger.info(f"Leased account: {key}")
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
async def release_account_lock(cls, platform: Platform, user_no: str) -> None:
|
|
53
|
+
if not user_no:
|
|
54
|
+
return
|
|
55
|
+
accounts = DatabaseManager.get_all_accounts(only_active=False)
|
|
56
|
+
valid_accounts = [acc for acc in accounts if acc['platform'] == platform.value]
|
|
57
|
+
target = next((acc for acc in valid_accounts if cls._match_account_identifier(acc, user_no)), None)
|
|
58
|
+
if not target:
|
|
59
|
+
return
|
|
60
|
+
key = cls.build_account_key(platform, target["user_no"])
|
|
61
|
+
async with cls._lock:
|
|
62
|
+
if key in cls._locked_accounts:
|
|
63
|
+
cls._locked_accounts.remove(key)
|
|
64
|
+
logger.info(f"Released account: {key}")
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
@asynccontextmanager
|
|
68
|
+
async def lease_account(cls, platform: Platform, user_no: Optional[str] = None):
|
|
69
|
+
"""Lease an account for a request. If user_no is None, pick a random active one."""
|
|
70
|
+
account = None
|
|
71
|
+
account_key = None
|
|
72
|
+
|
|
73
|
+
async with cls._lock:
|
|
74
|
+
accounts = DatabaseManager.get_all_accounts(only_active=True)
|
|
75
|
+
valid_accounts = [acc for acc in accounts if acc['platform'] == platform.value]
|
|
76
|
+
|
|
77
|
+
if user_no:
|
|
78
|
+
target = next((acc for acc in valid_accounts if cls._match_account_identifier(acc, user_no)), None)
|
|
79
|
+
if target:
|
|
80
|
+
key = cls.build_account_key(platform, target['user_no'])
|
|
81
|
+
if key not in cls._locked_accounts:
|
|
82
|
+
account = target
|
|
83
|
+
account_key = key
|
|
84
|
+
else:
|
|
85
|
+
# Pick a random one that is not locked
|
|
86
|
+
available = [
|
|
87
|
+
acc for acc in valid_accounts
|
|
88
|
+
if cls.build_account_key(platform, acc['user_no']) not in cls._locked_accounts
|
|
89
|
+
]
|
|
90
|
+
if available:
|
|
91
|
+
account = random.choice(available)
|
|
92
|
+
account_key = cls.build_account_key(platform, account['user_no'])
|
|
93
|
+
|
|
94
|
+
if not account:
|
|
95
|
+
raise Exception(f"No available {platform.value} accounts to lease.")
|
|
96
|
+
|
|
97
|
+
cls._locked_accounts.add(account_key)
|
|
98
|
+
logger.info(f"Leased account: {account_key}")
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
yield account
|
|
102
|
+
finally:
|
|
103
|
+
async with cls._lock:
|
|
104
|
+
if account_key in cls._locked_accounts:
|
|
105
|
+
cls._locked_accounts.remove(account_key)
|
|
106
|
+
logger.info(f"Released account: {account_key}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AccountManager:
|
|
110
|
+
"""Business logic for managing accounts across platforms."""
|
|
111
|
+
DEFAULT_REMOTE_SCREENSHOT_NAME = "login_viewport.png"
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
def list_accounts(only_active=False):
|
|
115
|
+
accounts = DatabaseManager.get_all_accounts(only_active=only_active)
|
|
116
|
+
for acc in accounts:
|
|
117
|
+
acc["uid"] = acc["user_no"]
|
|
118
|
+
acc["status"] = "Active" if acc["is_logged_in"] else "Inactive"
|
|
119
|
+
if acc.get("check_time"):
|
|
120
|
+
acc["last_active"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(acc["check_time"]))
|
|
121
|
+
else:
|
|
122
|
+
acc["last_active"] = "N/A"
|
|
123
|
+
return accounts
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def add_or_update_account(account_data: dict):
|
|
127
|
+
DatabaseManager.upsert_account(account_data)
|
|
128
|
+
logger.info(f"Account {account_data.get('nickname')} ({account_data.get('platform')}) updated.")
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def delete_account(platform, user_no):
|
|
132
|
+
DatabaseManager.delete_account(platform, user_no)
|
|
133
|
+
logger.info(f"Account {user_no} deleted from {platform}.")
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _resolve_screenshot_path(raw_path: str) -> Path:
|
|
137
|
+
default_name = AccountManager.DEFAULT_REMOTE_SCREENSHOT_NAME
|
|
138
|
+
if not raw_path:
|
|
139
|
+
return Path(runtime_config.APP_DATA_DIR) / "outputs" / default_name
|
|
140
|
+
|
|
141
|
+
path = Path(raw_path).expanduser()
|
|
142
|
+
if (path.exists() and path.is_dir()) or raw_path.endswith(("/", "\\")):
|
|
143
|
+
return path / default_name
|
|
144
|
+
if path.suffix.lower() in {".png", ".jpg", ".jpeg"}:
|
|
145
|
+
return path
|
|
146
|
+
return path.with_suffix(".png")
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _find_account(platform_enum: Platform, account_uid: str) -> Optional[dict]:
|
|
150
|
+
account_uid = str(account_uid or "").strip()
|
|
151
|
+
if not account_uid:
|
|
152
|
+
return None
|
|
153
|
+
accounts = DatabaseManager.get_all_accounts(only_active=False)
|
|
154
|
+
for item in accounts:
|
|
155
|
+
if item.get("platform") != platform_enum.value:
|
|
156
|
+
continue
|
|
157
|
+
if str(item.get("user_no") or "").strip() == account_uid:
|
|
158
|
+
return item
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
@staticmethod
|
|
162
|
+
def get_login_profile(platform_enum: Platform):
|
|
163
|
+
return PlatformFactory.get_login_profile(platform_enum)
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
async def login_and_add_account(
|
|
167
|
+
platform_enum: Platform,
|
|
168
|
+
*,
|
|
169
|
+
mode: str = "local",
|
|
170
|
+
timeout_seconds: int = 240,
|
|
171
|
+
screenshot_path: str = "",
|
|
172
|
+
account_uid: str = "",
|
|
173
|
+
progress_callback: Callable[[dict], None] | None = None,
|
|
174
|
+
) -> dict:
|
|
175
|
+
"""Interactive login process via browser."""
|
|
176
|
+
logger.info(f"Starting browser for {platform_enum.value} login, mode={mode}...")
|
|
177
|
+
|
|
178
|
+
def _emit_progress(event: str, **payload) -> None:
|
|
179
|
+
if progress_callback is None:
|
|
180
|
+
return
|
|
181
|
+
try:
|
|
182
|
+
progress_callback({"event": event, **payload})
|
|
183
|
+
except Exception as emit_err:
|
|
184
|
+
logger.debug(f"Ignored progress callback error: {emit_err}")
|
|
185
|
+
|
|
186
|
+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError, async_playwright
|
|
187
|
+
from rednote_cli._runtime.common.app_utils import get_system_chrome_path
|
|
188
|
+
|
|
189
|
+
if mode not in {"local", "remote"}:
|
|
190
|
+
return {
|
|
191
|
+
"success": False,
|
|
192
|
+
"reason": "invalid_mode",
|
|
193
|
+
"message": f"Unsupported login mode: {mode}",
|
|
194
|
+
"mode": mode,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
result = {
|
|
198
|
+
"success": False,
|
|
199
|
+
"mode": mode,
|
|
200
|
+
"timeout_seconds": timeout_seconds,
|
|
201
|
+
"login_url": "",
|
|
202
|
+
"screenshot_path": None,
|
|
203
|
+
"account_uid": None,
|
|
204
|
+
"user_no": None,
|
|
205
|
+
"nickname": None,
|
|
206
|
+
"reused_session": False,
|
|
207
|
+
}
|
|
208
|
+
profile = AccountManager.get_login_profile(platform_enum)
|
|
209
|
+
result["login_url"] = profile.login_url
|
|
210
|
+
target_account_uid = str(account_uid or "").strip()
|
|
211
|
+
target_account = None
|
|
212
|
+
if target_account_uid:
|
|
213
|
+
target_account = AccountManager._find_account(platform_enum, target_account_uid)
|
|
214
|
+
if not target_account:
|
|
215
|
+
result["reason"] = "account_not_found"
|
|
216
|
+
result["message"] = f"账号不存在: {target_account_uid}"
|
|
217
|
+
result["account_uid"] = target_account_uid
|
|
218
|
+
return result
|
|
219
|
+
result["account_uid"] = target_account_uid
|
|
220
|
+
|
|
221
|
+
headless = runtime_config.resolve_login_mode_headless(mode)
|
|
222
|
+
close_timeout_seconds = 1
|
|
223
|
+
browser = None
|
|
224
|
+
context = None
|
|
225
|
+
p = None
|
|
226
|
+
|
|
227
|
+
p = await async_playwright().start()
|
|
228
|
+
try:
|
|
229
|
+
executable_path = get_system_chrome_path()
|
|
230
|
+
browser = await p.chromium.launch(
|
|
231
|
+
headless=headless,
|
|
232
|
+
executable_path=executable_path,
|
|
233
|
+
args=["--disable-blink-features=AutomationControlled"]
|
|
234
|
+
)
|
|
235
|
+
context_kwargs = {"viewport": {'width': 1280, 'height': 800}}
|
|
236
|
+
if target_account and target_account.get("storage_state"):
|
|
237
|
+
context_kwargs["storage_state"] = target_account.get("storage_state")
|
|
238
|
+
logger.info(f"Loaded stored session for account: {target_account_uid}")
|
|
239
|
+
context = await browser.new_context(**context_kwargs)
|
|
240
|
+
page = await context.new_page()
|
|
241
|
+
await stealth_async(page)
|
|
242
|
+
|
|
243
|
+
extractor = PlatformFactory.get_extractor(platform_enum, page)
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
logger.info(f"Opening login page: {profile.login_url}")
|
|
247
|
+
await page.goto(profile.login_url, wait_until="domcontentloaded")
|
|
248
|
+
await page.wait_for_timeout(500)
|
|
249
|
+
|
|
250
|
+
is_logged_in = await page.locator(profile.login_selector).count() > 0
|
|
251
|
+
result["reused_session"] = bool(target_account and is_logged_in)
|
|
252
|
+
if not is_logged_in:
|
|
253
|
+
if target_account:
|
|
254
|
+
logger.info(f"Stored session expired for account {target_account_uid}, fallback to QR login.")
|
|
255
|
+
if mode == "remote":
|
|
256
|
+
screenshot_file = AccountManager._resolve_screenshot_path(screenshot_path)
|
|
257
|
+
screenshot_file.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
try:
|
|
259
|
+
await page.wait_for_selector(profile.qr_selector, timeout=15_000)
|
|
260
|
+
except PlaywrightTimeoutError:
|
|
261
|
+
logger.warning("QR element not found before screenshot, continue anyway.")
|
|
262
|
+
await page.screenshot(path=str(screenshot_file), full_page=False)
|
|
263
|
+
result["screenshot_path"] = str(screenshot_file.resolve())
|
|
264
|
+
logger.info(f"Remote login screenshot saved: {result['screenshot_path']}")
|
|
265
|
+
_emit_progress(
|
|
266
|
+
"login.qr_ready",
|
|
267
|
+
mode=mode,
|
|
268
|
+
login_url=profile.login_url,
|
|
269
|
+
screenshot_path=result["screenshot_path"],
|
|
270
|
+
timeout_seconds=timeout_seconds,
|
|
271
|
+
account_uid=result["account_uid"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
logger.info("Please complete login in the browser...")
|
|
275
|
+
try:
|
|
276
|
+
await page.wait_for_selector(profile.login_selector, timeout=timeout_seconds * 1000)
|
|
277
|
+
except PlaywrightTimeoutError:
|
|
278
|
+
logger.error(f"Login timed out after {timeout_seconds}s")
|
|
279
|
+
result["reason"] = "login_timeout"
|
|
280
|
+
result["message"] = f"扫码登录超时({timeout_seconds} 秒)"
|
|
281
|
+
return result
|
|
282
|
+
elif target_account:
|
|
283
|
+
logger.info(f"Session still valid for account {target_account_uid}, skip QR login.")
|
|
284
|
+
|
|
285
|
+
logger.info("Login detected!")
|
|
286
|
+
|
|
287
|
+
storage_state = await context.storage_state()
|
|
288
|
+
info = await extractor.get_self_info()
|
|
289
|
+
user_no = str((info or {}).get(profile.account_id_field) or "").strip()
|
|
290
|
+
if info and user_no:
|
|
291
|
+
if target_account_uid and target_account:
|
|
292
|
+
expected = str(target_account.get("user_no") or "").strip()
|
|
293
|
+
if expected and user_no != expected:
|
|
294
|
+
result["reason"] = "account_mismatch"
|
|
295
|
+
result["message"] = (
|
|
296
|
+
f"指定账号 {target_account_uid} 与当前登录账号 {user_no} 不一致,"
|
|
297
|
+
"请确认扫码账号或去掉 --account 参数"
|
|
298
|
+
)
|
|
299
|
+
return result
|
|
300
|
+
elif target_account_uid and user_no != target_account_uid:
|
|
301
|
+
result["reason"] = "account_mismatch"
|
|
302
|
+
result["message"] = (
|
|
303
|
+
f"指定账号 {target_account_uid} 与当前登录账号 {user_no} 不一致,"
|
|
304
|
+
"请确认扫码账号或去掉 --account 参数"
|
|
305
|
+
)
|
|
306
|
+
return result
|
|
307
|
+
|
|
308
|
+
account_data = {
|
|
309
|
+
"platform": platform_enum.value,
|
|
310
|
+
"user_no": user_no,
|
|
311
|
+
"nickname": info.get(profile.nickname_field),
|
|
312
|
+
"is_logged_in": not bool(info.get(profile.guest_field)),
|
|
313
|
+
"check_time": int(time.time()),
|
|
314
|
+
"storage_state": storage_state
|
|
315
|
+
}
|
|
316
|
+
DatabaseManager.upsert_account(account_data)
|
|
317
|
+
logger.info(f"Account {info.get(profile.nickname_field)} added successfully!")
|
|
318
|
+
result["success"] = True
|
|
319
|
+
result["account_uid"] = user_no
|
|
320
|
+
result["user_no"] = user_no
|
|
321
|
+
result["nickname"] = info.get(profile.nickname_field)
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
logger.info("Failed to fetch user info after login.")
|
|
325
|
+
result["reason"] = "self_info_unavailable"
|
|
326
|
+
result["message"] = "登录成功但未获取到用户信息"
|
|
327
|
+
return result
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.error(f"Login failed or timed out: {e}")
|
|
330
|
+
result["reason"] = "login_failed"
|
|
331
|
+
result["message"] = str(e)
|
|
332
|
+
return result
|
|
333
|
+
finally:
|
|
334
|
+
if context is not None:
|
|
335
|
+
try:
|
|
336
|
+
await asyncio.wait_for(context.close(), timeout=close_timeout_seconds)
|
|
337
|
+
except Exception as close_err:
|
|
338
|
+
logger.warning(f"Context close timeout/error ignored: {close_err}")
|
|
339
|
+
if browser is not None:
|
|
340
|
+
try:
|
|
341
|
+
await asyncio.wait_for(browser.close(), timeout=close_timeout_seconds)
|
|
342
|
+
except Exception as close_err:
|
|
343
|
+
logger.warning(f"Browser close timeout/error ignored: {close_err}")
|
|
344
|
+
finally:
|
|
345
|
+
if p is not None:
|
|
346
|
+
try:
|
|
347
|
+
await asyncio.wait_for(p.stop(), timeout=close_timeout_seconds)
|
|
348
|
+
except Exception as close_err:
|
|
349
|
+
logger.warning(f"Playwright stop timeout/error ignored: {close_err}")
|
|
File without changes
|