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.
Files changed (81) hide show
  1. rednote_cli/__init__.py +5 -0
  2. rednote_cli/_runtime/__init__.py +0 -0
  3. rednote_cli/_runtime/common/__init__.py +0 -0
  4. rednote_cli/_runtime/common/app_utils.py +77 -0
  5. rednote_cli/_runtime/common/config.py +83 -0
  6. rednote_cli/_runtime/common/enums.py +17 -0
  7. rednote_cli/_runtime/common/errors.py +22 -0
  8. rednote_cli/_runtime/core/__init__.py +0 -0
  9. rednote_cli/_runtime/core/account_manager.py +349 -0
  10. rednote_cli/_runtime/core/browser/__init__.py +0 -0
  11. rednote_cli/_runtime/core/browser/manager.py +247 -0
  12. rednote_cli/_runtime/core/database/__init__.py +0 -0
  13. rednote_cli/_runtime/core/database/manager.py +334 -0
  14. rednote_cli/_runtime/platforms/__init__.py +0 -0
  15. rednote_cli/_runtime/platforms/base.py +62 -0
  16. rednote_cli/_runtime/platforms/factory.py +55 -0
  17. rednote_cli/_runtime/platforms/publishing/__init__.py +12 -0
  18. rednote_cli/_runtime/platforms/publishing/media.py +275 -0
  19. rednote_cli/_runtime/platforms/publishing/models.py +59 -0
  20. rednote_cli/_runtime/platforms/publishing/validator.py +124 -0
  21. rednote_cli/_runtime/services/__init__.py +1 -0
  22. rednote_cli/_runtime/services/scraper_service.py +235 -0
  23. rednote_cli/adapters/__init__.py +1 -0
  24. rednote_cli/adapters/output/__init__.py +1 -0
  25. rednote_cli/adapters/output/event_stream.py +29 -0
  26. rednote_cli/adapters/output/formatter_json.py +23 -0
  27. rednote_cli/adapters/output/formatter_table.py +39 -0
  28. rednote_cli/adapters/output/writer.py +17 -0
  29. rednote_cli/adapters/persistence/__init__.py +1 -0
  30. rednote_cli/adapters/persistence/file_account_repo.py +51 -0
  31. rednote_cli/adapters/platform/__init__.py +1 -0
  32. rednote_cli/adapters/platform/rednote/__init__.py +1 -0
  33. rednote_cli/adapters/platform/rednote/extractor.py +65 -0
  34. rednote_cli/adapters/platform/rednote/publisher.py +26 -0
  35. rednote_cli/adapters/platform/rednote/runtime_extractor.py +818 -0
  36. rednote_cli/adapters/platform/rednote/runtime_publisher.py +373 -0
  37. rednote_cli/adapters/platform/rednote/runtime_registration.py +20 -0
  38. rednote_cli/application/__init__.py +1 -0
  39. rednote_cli/application/dto/__init__.py +1 -0
  40. rednote_cli/application/dto/input_models.py +121 -0
  41. rednote_cli/application/dto/output_models.py +78 -0
  42. rednote_cli/application/use_cases/__init__.py +1 -0
  43. rednote_cli/application/use_cases/account_list.py +9 -0
  44. rednote_cli/application/use_cases/account_mutation.py +22 -0
  45. rednote_cli/application/use_cases/auth_login.py +64 -0
  46. rednote_cli/application/use_cases/auth_status.py +96 -0
  47. rednote_cli/application/use_cases/doctor.py +49 -0
  48. rednote_cli/application/use_cases/init_runtime.py +20 -0
  49. rednote_cli/application/use_cases/note_get.py +22 -0
  50. rednote_cli/application/use_cases/note_search.py +26 -0
  51. rednote_cli/application/use_cases/publish_note.py +25 -0
  52. rednote_cli/application/use_cases/user_get.py +18 -0
  53. rednote_cli/application/use_cases/user_search.py +8 -0
  54. rednote_cli/application/use_cases/user_self.py +8 -0
  55. rednote_cli/cli/__init__.py +1 -0
  56. rednote_cli/cli/__main__.py +5 -0
  57. rednote_cli/cli/commands/__init__.py +1 -0
  58. rednote_cli/cli/commands/account.py +204 -0
  59. rednote_cli/cli/commands/doctor.py +20 -0
  60. rednote_cli/cli/commands/init.py +20 -0
  61. rednote_cli/cli/commands/note.py +101 -0
  62. rednote_cli/cli/commands/publish.py +147 -0
  63. rednote_cli/cli/commands/search.py +185 -0
  64. rednote_cli/cli/commands/user.py +113 -0
  65. rednote_cli/cli/main.py +163 -0
  66. rednote_cli/cli/options.py +13 -0
  67. rednote_cli/cli/runtime.py +142 -0
  68. rednote_cli/cli/utils.py +74 -0
  69. rednote_cli/domain/__init__.py +1 -0
  70. rednote_cli/domain/errors.py +50 -0
  71. rednote_cli/domain/note_search_filters.py +155 -0
  72. rednote_cli/infra/__init__.py +1 -0
  73. rednote_cli/infra/exit_codes.py +30 -0
  74. rednote_cli/infra/logger.py +11 -0
  75. rednote_cli/infra/paths.py +31 -0
  76. rednote_cli/infra/platforms.py +4 -0
  77. rednote_cli-0.1.0.dist-info/METADATA +81 -0
  78. rednote_cli-0.1.0.dist-info/RECORD +81 -0
  79. rednote_cli-0.1.0.dist-info/WHEEL +5 -0
  80. rednote_cli-0.1.0.dist-info/entry_points.txt +2 -0
  81. rednote_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,5 @@
1
+ """Rednote Operator CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
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