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,247 @@
1
+ import asyncio
2
+ import hashlib
3
+ import json
4
+ import platform
5
+ from contextlib import asynccontextmanager
6
+ from typing import Optional, AsyncGenerator, Union, Callable, Awaitable, Dict
7
+
8
+ from loguru import logger
9
+ from playwright.async_api import (
10
+ async_playwright,
11
+ Browser,
12
+ BrowserContext,
13
+ Page,
14
+ Playwright,
15
+ Response,
16
+ )
17
+
18
+ from rednote_cli._runtime.common.app_utils import get_system_chrome_path
19
+ from rednote_cli._runtime.common.config import (
20
+ BROWSER_LOCALE,
21
+ BROWSER_TIMEZONE,
22
+ resolve_browser_headless,
23
+ )
24
+
25
+ # Handle playwright-stealth version differences
26
+ try:
27
+ from playwright_stealth import stealth_async
28
+ except ImportError:
29
+ try:
30
+ from playwright_stealth import Stealth
31
+
32
+
33
+ async def stealth_async(page):
34
+ await Stealth().apply_stealth_async(page)
35
+ except ImportError:
36
+ async def stealth_async(page):
37
+ logger.warning("Stealth module not found, proceeding without stealth.")
38
+ pass
39
+
40
+ class BrowserManager:
41
+ _instance = None
42
+
43
+ def __new__(cls):
44
+ if cls._instance is None:
45
+ cls._instance = super(BrowserManager, cls).__new__(cls)
46
+ cls._instance.is_running = False
47
+ cls._instance._local_playwrights: Dict[int, Playwright] = {}
48
+ cls._instance._local_browsers: Dict[int, Browser] = {}
49
+ cls._instance._loop_semaphores: Dict[int, asyncio.Semaphore] = {}
50
+ cls._instance._loop_start_locks: Dict[int, asyncio.Lock] = {}
51
+ cls._instance._profile_cache: Dict[str, dict] = {}
52
+
53
+ return cls._instance
54
+
55
+ @staticmethod
56
+ def _pick_by_hash(digest: str, start: int, end: int, offset: int) -> int:
57
+ span = end - start + 1
58
+ return start + (int(digest[offset: offset + 8], 16) % span)
59
+
60
+ def _resolve_profile(self, account_key: Optional[str]) -> dict:
61
+ key = account_key or "default"
62
+ cached = self._profile_cache.get(key)
63
+ if cached:
64
+ return cached
65
+
66
+ digest = hashlib.sha256(key.encode("utf-8")).hexdigest()
67
+ system = platform.system()
68
+
69
+ if system == "Darwin":
70
+ user_agent = (
71
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
72
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
73
+ "Chrome/131.0.0.0 Safari/537.36"
74
+ )
75
+ device_scale_factor = 2.0
76
+ else:
77
+ user_agent = (
78
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
79
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
80
+ "Chrome/131.0.0.0 Safari/537.36"
81
+ )
82
+ device_scale_factor = 1.0
83
+
84
+ profile = {
85
+ "user_agent": user_agent,
86
+ "viewport": {
87
+ "width": self._pick_by_hash(digest, 1366, 1920, 0),
88
+ "height": self._pick_by_hash(digest, 768, 1080, 8),
89
+ },
90
+ "device_scale_factor": device_scale_factor,
91
+ }
92
+ self._profile_cache[key] = profile
93
+ return profile
94
+
95
+ @staticmethod
96
+ def _normalize_storage_state(storage_state: Optional[Union[dict, str]]) -> Optional[dict]:
97
+ if isinstance(storage_state, str) and storage_state:
98
+ try:
99
+ return json.loads(storage_state)
100
+ except Exception:
101
+ return None
102
+ return storage_state if isinstance(storage_state, dict) else None
103
+
104
+ @staticmethod
105
+ def _get_loop_id() -> int:
106
+ return id(asyncio.get_running_loop())
107
+
108
+ def _get_loop_semaphore(self) -> asyncio.Semaphore:
109
+ loop_id = id(asyncio.get_running_loop())
110
+ if loop_id not in self._loop_semaphores:
111
+ self._loop_semaphores[loop_id] = asyncio.Semaphore(10)
112
+ return self._loop_semaphores[loop_id]
113
+
114
+ def _get_loop_start_lock(self, loop_id: int) -> asyncio.Lock:
115
+ if loop_id not in self._loop_start_locks:
116
+ self._loop_start_locks[loop_id] = asyncio.Lock()
117
+ return self._loop_start_locks[loop_id]
118
+
119
+ async def _ensure_loop_browser(self, headless: bool | None = None) -> int:
120
+ headless = resolve_browser_headless(headless)
121
+ loop_id = self._get_loop_id()
122
+ if loop_id in self._local_browsers:
123
+ return loop_id
124
+
125
+ async with self._get_loop_start_lock(loop_id):
126
+ if loop_id in self._local_browsers:
127
+ return loop_id
128
+
129
+ logger.info(f"DEBUG: [BrowserManager] loop={loop_id} 正在以直连模式启动浏览器...")
130
+ launch_args = [
131
+ "--disable-blink-features=AutomationControlled",
132
+ "--no-sandbox",
133
+ "--disable-dev-shm-usage",
134
+ ]
135
+
136
+ launch_kwargs = {
137
+ "headless": headless,
138
+ "args": launch_args,
139
+ }
140
+ browser_bin = get_system_chrome_path()
141
+ if browser_bin:
142
+ launch_kwargs["executable_path"] = browser_bin
143
+
144
+ playwright = await async_playwright().start()
145
+ browser = await playwright.chromium.launch(**launch_kwargs)
146
+ self._local_playwrights[loop_id] = playwright
147
+ self._local_browsers[loop_id] = browser
148
+ self._loop_semaphores.setdefault(loop_id, asyncio.Semaphore(10))
149
+ self.is_running = True
150
+ logger.info(f"DEBUG: [BrowserManager] loop={loop_id} 浏览器直连模式启动完成")
151
+ return loop_id
152
+
153
+ async def start(self, headless: bool | None = None):
154
+ """为当前事件循环启动浏览器管理服务。"""
155
+ await self._ensure_loop_browser(headless=headless)
156
+
157
+ async def stop(self):
158
+ """停止所有事件循环持有的浏览器资源。"""
159
+ if not self.is_running:
160
+ return
161
+
162
+ for loop_id, browser in list(self._local_browsers.items()):
163
+ try:
164
+ await browser.close()
165
+ except Exception as e:
166
+ logger.warning(f"DEBUG: [BrowserManager] loop={loop_id} 关闭浏览器失败: {e}")
167
+ self._local_browsers.clear()
168
+
169
+ for loop_id, playwright in list(self._local_playwrights.items()):
170
+ try:
171
+ await playwright.stop()
172
+ except Exception as e:
173
+ logger.warning(f"DEBUG: [BrowserManager] loop={loop_id} 停止 playwright 失败: {e}")
174
+ self._local_playwrights.clear()
175
+ self._loop_semaphores.clear()
176
+ self._loop_start_locks.clear()
177
+
178
+ self.is_running = False
179
+ logger.info("DEBUG: [BrowserManager] 浏览器已停止")
180
+
181
+ @asynccontextmanager
182
+ async def get_context(
183
+ self,
184
+ storage_state: Optional[Union[dict, str]] = None,
185
+ headless: bool | None = None,
186
+ account_key: Optional[str] = None,
187
+ timezone_id: Optional[str] = None,
188
+ locale: Optional[str] = None,
189
+ ) -> AsyncGenerator[BrowserContext, None]:
190
+ """获取浏览器上下文,确保账号画像稳定。"""
191
+ headless = resolve_browser_headless(headless)
192
+ loop_id = await self._ensure_loop_browser(headless=headless)
193
+ browser = self._local_browsers.get(loop_id)
194
+ if not browser:
195
+ raise RuntimeError(f"Browser not initialized for loop={loop_id}")
196
+
197
+ semaphore = self._get_loop_semaphore()
198
+ profile = self._resolve_profile(account_key)
199
+ normalized_state = self._normalize_storage_state(storage_state)
200
+
201
+ async with semaphore:
202
+ context = await browser.new_context(
203
+ permissions=["geolocation"],
204
+ user_agent=profile["user_agent"],
205
+ viewport=profile["viewport"],
206
+ device_scale_factor=profile["device_scale_factor"],
207
+ storage_state=normalized_state,
208
+ ignore_https_errors=True,
209
+ locale=locale or BROWSER_LOCALE,
210
+ timezone_id=timezone_id or BROWSER_TIMEZONE,
211
+ )
212
+ try:
213
+ yield context
214
+ finally:
215
+ await context.close()
216
+
217
+ @asynccontextmanager
218
+ async def get_page(
219
+ self,
220
+ storage_state: Optional[Union[dict, str]] = None,
221
+ headless: bool | None = None,
222
+ account_key: Optional[str] = None,
223
+ timezone_id: Optional[str] = None,
224
+ locale: Optional[str] = None,
225
+ ) -> AsyncGenerator[Page, None]:
226
+ """获取页面上下文。"""
227
+ async with self.get_context(
228
+ storage_state=storage_state,
229
+ headless=headless,
230
+ account_key=account_key,
231
+ timezone_id=timezone_id,
232
+ locale=locale,
233
+ ) as context:
234
+ page = await context.new_page()
235
+ await stealth_async(page)
236
+ yield page
237
+
238
+ @asynccontextmanager
239
+ async def observe_responses(self, page: Page, handler: Callable[[Response], Awaitable[None]]):
240
+ page.on("response", handler)
241
+ try:
242
+ yield
243
+ finally:
244
+ page.remove_listener("response", handler)
245
+
246
+
247
+ browser_manager = BrowserManager()
File without changes
@@ -0,0 +1,334 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from urllib.parse import quote
7
+
8
+ from loguru import logger
9
+
10
+ import rednote_cli._runtime.common.config as runtime_config
11
+
12
+
13
+ def _data_root() -> Path:
14
+ root = Path(runtime_config.APP_DATA_DIR)
15
+ root.mkdir(parents=True, exist_ok=True)
16
+ return root
17
+
18
+
19
+ def _accounts_root() -> Path:
20
+ root = _data_root() / "accounts"
21
+ root.mkdir(parents=True, exist_ok=True)
22
+ return root
23
+
24
+
25
+ def _settings_file() -> Path:
26
+ return _data_root() / "settings.json"
27
+
28
+
29
+ def _task_schedules_file() -> Path:
30
+ return _data_root() / "task_schedules.json"
31
+
32
+
33
+ def _meta_file() -> Path:
34
+ return _accounts_root() / ".meta.json"
35
+
36
+
37
+ def _safe_user_no(user_no: str) -> str:
38
+ return quote(str(user_no), safe="")
39
+
40
+
41
+ def _account_file(platform: str, user_no: str) -> Path:
42
+ platform_dir = _accounts_root() / str(platform)
43
+ platform_dir.mkdir(parents=True, exist_ok=True)
44
+ return platform_dir / f"{_safe_user_no(user_no)}.json"
45
+
46
+
47
+ def _load_json(path: Path, default):
48
+ if not path.exists():
49
+ return default
50
+ try:
51
+ return json.loads(path.read_text(encoding="utf-8"))
52
+ except Exception:
53
+ return default
54
+
55
+
56
+ def _write_json_atomic(path: Path, payload) -> None:
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ tmp = path.with_suffix(path.suffix + ".tmp")
59
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
60
+ tmp.replace(path)
61
+
62
+
63
+ def _iter_account_files() -> list[Path]:
64
+ root = _accounts_root()
65
+ return [p for p in root.rglob("*.json") if p.name != ".meta.json"]
66
+
67
+
68
+ def _next_account_id() -> int:
69
+ meta_path = _meta_file()
70
+ meta = _load_json(meta_path, {"next_id": 1})
71
+ next_id = int(meta.get("next_id", 1))
72
+ meta["next_id"] = next_id + 1
73
+ _write_json_atomic(meta_path, meta)
74
+ return next_id
75
+
76
+
77
+ def _normalize_storage_state(value):
78
+ if isinstance(value, str) and value:
79
+ try:
80
+ return json.loads(value)
81
+ except Exception:
82
+ return None
83
+ if isinstance(value, dict):
84
+ return value
85
+ return None
86
+
87
+
88
+ def _normalize_account_record(raw: dict) -> dict:
89
+ record = dict(raw)
90
+ record["storage_state"] = _normalize_storage_state(record.get("storage_state"))
91
+ if "is_logged_in" in record:
92
+ record["is_logged_in"] = bool(record.get("is_logged_in"))
93
+ return record
94
+
95
+
96
+ def _ensure_file_store_layout() -> None:
97
+ _accounts_root()
98
+ for file_path, default in [
99
+ (_settings_file(), {}),
100
+ (_task_schedules_file(), {}),
101
+ (_meta_file(), {"next_id": 1}),
102
+ ]:
103
+ if not file_path.exists():
104
+ _write_json_atomic(file_path, default)
105
+
106
+
107
+ def _has_existing_file_data() -> bool:
108
+ if _iter_account_files():
109
+ return True
110
+ if _load_json(_settings_file(), {}):
111
+ return True
112
+ return False
113
+
114
+
115
+ def _migrate_from_legacy_sqlite_once() -> None:
116
+ marker = _data_root() / ".migrated_from_sqlite"
117
+ if marker.exists():
118
+ return
119
+
120
+ if _has_existing_file_data():
121
+ marker.write_text(str(int(time.time())), encoding="utf-8")
122
+ return
123
+
124
+ db_file = Path(runtime_config.DB_FILE)
125
+ if not db_file.exists():
126
+ marker.write_text(str(int(time.time())), encoding="utf-8")
127
+ return
128
+
129
+ try:
130
+ import sqlite3
131
+
132
+ conn = sqlite3.connect(db_file)
133
+ conn.row_factory = sqlite3.Row
134
+ cursor = conn.cursor()
135
+
136
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'")
137
+ has_accounts = cursor.fetchone() is not None
138
+ accounts_count = 0
139
+ if has_accounts:
140
+ cursor.execute("SELECT * FROM accounts")
141
+ rows = cursor.fetchall()
142
+ for row in rows:
143
+ account = _normalize_account_record(dict(row))
144
+ platform = account.get("platform")
145
+ user_no = account.get("user_no")
146
+ if not platform or not user_no:
147
+ continue
148
+ target = _account_file(platform, user_no)
149
+ _write_json_atomic(target, account)
150
+ accounts_count += 1
151
+
152
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='settings'")
153
+ has_settings = cursor.fetchone() is not None
154
+ settings_count = 0
155
+ if has_settings:
156
+ cursor.execute("SELECT key, value FROM settings")
157
+ settings_rows = cursor.fetchall()
158
+ settings = {str(r["key"]): r["value"] for r in settings_rows}
159
+ _write_json_atomic(_settings_file(), settings)
160
+ settings_count = len(settings)
161
+
162
+ conn.close()
163
+ marker.write_text(
164
+ json.dumps(
165
+ {
166
+ "migrated_at": int(time.time()),
167
+ "accounts_count": accounts_count,
168
+ "settings_count": settings_count,
169
+ },
170
+ ensure_ascii=False,
171
+ ),
172
+ encoding="utf-8",
173
+ )
174
+ logger.info(
175
+ "Migrated legacy sqlite data to file store: accounts={} settings={}",
176
+ accounts_count,
177
+ settings_count,
178
+ )
179
+ except Exception as exc:
180
+ logger.warning("Skipped sqlite migration: {}", exc)
181
+ marker.write_text(str(int(time.time())), encoding="utf-8")
182
+
183
+
184
+ def init_db():
185
+ """Initialize file-based store and migrate legacy sqlite once if needed."""
186
+ _ensure_file_store_layout()
187
+ _migrate_from_legacy_sqlite_once()
188
+
189
+
190
+ class DatabaseManager:
191
+ """File-backed storage manager (source of truth)."""
192
+
193
+ @staticmethod
194
+ def upsert_account(account_data: dict):
195
+ now = int(time.time())
196
+ platform = str(account_data.get("platform") or "").strip()
197
+ user_no = str(account_data.get("user_no") or "").strip()
198
+ if not platform or not user_no:
199
+ raise ValueError("platform and user_no are required")
200
+
201
+ path = _account_file(platform, user_no)
202
+ existing = _load_json(path, {}) if path.exists() else {}
203
+
204
+ payload = {
205
+ "id": int(existing.get("id") or _next_account_id()),
206
+ "platform": platform,
207
+ "user_no": user_no,
208
+ "nickname": account_data.get("nickname"),
209
+ "is_logged_in": bool(account_data.get("is_logged_in")),
210
+ "check_time": account_data.get("check_time"),
211
+ "usage_time": account_data.get("usage_time", existing.get("usage_time")),
212
+ "storage_state": _normalize_storage_state(account_data.get("storage_state")),
213
+ "creater": existing.get("creater", "admin"),
214
+ "created": int(existing.get("created") or now),
215
+ "modifier": "admin",
216
+ "modified": now,
217
+ }
218
+ _write_json_atomic(path, payload)
219
+
220
+ @staticmethod
221
+ def get_all_accounts(only_active=False):
222
+ records = []
223
+ for path in _iter_account_files():
224
+ data = _load_json(path, {})
225
+ if not data:
226
+ continue
227
+ record = _normalize_account_record(data)
228
+ if only_active and not bool(record.get("is_logged_in")):
229
+ continue
230
+ records.append(record)
231
+ records.sort(key=lambda x: int(x.get("modified") or 0), reverse=True)
232
+ return records
233
+
234
+ @staticmethod
235
+ def delete_account(platform, user_no):
236
+ path = _account_file(str(platform), str(user_no))
237
+ if path.exists():
238
+ path.unlink()
239
+
240
+ @staticmethod
241
+ def get_setting(key: str, default=None):
242
+ settings = _load_json(_settings_file(), {})
243
+ return settings.get(key, default)
244
+
245
+ @staticmethod
246
+ def set_setting(key: str, value: str):
247
+ settings = _load_json(_settings_file(), {})
248
+ settings[str(key)] = value
249
+ _write_json_atomic(_settings_file(), settings)
250
+
251
+ @staticmethod
252
+ def get_task_schedule(platform: str) -> dict:
253
+ schedules = _load_json(_task_schedules_file(), {})
254
+ schedule = schedules.get(platform)
255
+ if schedule:
256
+ return schedule
257
+ return {
258
+ "platform": platform,
259
+ "task_type": "login_check",
260
+ "cron_expression": "",
261
+ "jitter_seconds": 0,
262
+ "alert_enabled": 0,
263
+ "alert_email": "",
264
+ "is_active": 0,
265
+ }
266
+
267
+ @staticmethod
268
+ def upsert_task_schedule(data: dict):
269
+ schedules = _load_json(_task_schedules_file(), {})
270
+ platform = str(data.get("platform") or "").strip()
271
+ if not platform:
272
+ raise ValueError("platform is required")
273
+ now = int(time.time())
274
+ existing = schedules.get(platform, {})
275
+ schedules[platform] = {
276
+ "platform": platform,
277
+ "task_type": data.get("task_type", "login_check"),
278
+ "cron_expression": data.get("cron_expression"),
279
+ "jitter_seconds": data.get("jitter_seconds", 0),
280
+ "next_run_time": data.get("next_run_time"),
281
+ "alert_enabled": data.get("alert_enabled", 0),
282
+ "alert_email": data.get("alert_email", ""),
283
+ "is_active": data.get("is_active", 1),
284
+ "created": int(existing.get("created") or now),
285
+ "modified": now,
286
+ }
287
+ _write_json_atomic(_task_schedules_file(), schedules)
288
+
289
+ @staticmethod
290
+ def get_all_task_schedules():
291
+ schedules = _load_json(_task_schedules_file(), {})
292
+ return list(schedules.values())
293
+
294
+ @staticmethod
295
+ def delete_task_schedule(platform: str):
296
+ schedules = _load_json(_task_schedules_file(), {})
297
+ schedules.pop(platform, None)
298
+ _write_json_atomic(_task_schedules_file(), schedules)
299
+
300
+ @staticmethod
301
+ def update_next_run_time(platform: str, next_run: int):
302
+ schedules = _load_json(_task_schedules_file(), {})
303
+ item = schedules.get(platform)
304
+ if not item:
305
+ return
306
+ item["next_run_time"] = next_run
307
+ item["modified"] = int(time.time())
308
+ schedules[platform] = item
309
+ _write_json_atomic(_task_schedules_file(), schedules)
310
+
311
+ @staticmethod
312
+ def set_account_login_status(platform: str, user_no: str, is_logged_in: bool) -> bool:
313
+ path = _account_file(platform, user_no)
314
+ data = _load_json(path, {})
315
+ if not data:
316
+ return False
317
+ data["is_logged_in"] = bool(is_logged_in)
318
+ data["modified"] = int(time.time())
319
+ _write_json_atomic(path, data)
320
+ return True
321
+
322
+ @staticmethod
323
+ def update_account_status(account_id: int, is_logged_in: bool, check_time: int, storage_state: dict = None):
324
+ for path in _iter_account_files():
325
+ data = _load_json(path, {})
326
+ if int(data.get("id") or 0) != int(account_id):
327
+ continue
328
+ data["is_logged_in"] = bool(is_logged_in)
329
+ data["check_time"] = check_time
330
+ if storage_state is not None:
331
+ data["storage_state"] = _normalize_storage_state(storage_state)
332
+ data["modified"] = int(time.time())
333
+ _write_json_atomic(path, data)
334
+ return
File without changes
@@ -0,0 +1,62 @@
1
+ import asyncio
2
+ import random
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Optional
5
+
6
+ from loguru import logger
7
+ from playwright.async_api import Page
8
+ from yt_dlp import YoutubeDL
9
+ from yt_dlp.extractor.common import InfoExtractor
10
+
11
+
12
+ class RiskControlException(Exception):
13
+ """Exception raised when platform risk control is detected."""
14
+ pass
15
+
16
+
17
+ class RobustnessMixin:
18
+ """Mixin providing human-like interaction and risk detection."""
19
+ page: Page
20
+
21
+ async def human_scroll(self, container_locator=None, distance_range=(800, 1500), delay_range=(1.0, 2.5)):
22
+ """Simulate human-like scrolling with random distance and delays."""
23
+ distance = random.randint(*distance_range)
24
+ logger.info(f"Human scroll: {distance}px")
25
+ if container_locator:
26
+ await container_locator.evaluate(f"el => el.scrollBy(0, {distance})")
27
+ else:
28
+ await self.page.mouse.wheel(0, distance)
29
+ await asyncio.sleep(random.uniform(*delay_range))
30
+
31
+ async def human_click(self, selector: str):
32
+ """Simulate human-like click using ghost-cursor (pseudo-implementation)."""
33
+ # Note: Actual ghost-cursor requires more complex setup, using a simplified version here
34
+ # that moves the mouse before clicking.
35
+ element = self.page.locator(selector).first
36
+ if await element.is_visible():
37
+ box = await element.bounding_box()
38
+ if box:
39
+ x = box['x'] + box['width'] / 2
40
+ y = box['y'] + box['height'] / 2
41
+ await self.page.mouse.move(x + random.uniform(-5, 5), y + random.uniform(-5, 5), steps=10)
42
+ await asyncio.sleep(random.uniform(0.2, 0.5))
43
+ await element.click()
44
+
45
+
46
+ class BaseExtractor(InfoExtractor, RobustnessMixin):
47
+ """Base class for platform-specific data extraction."""
48
+
49
+ def __init__(self, page: Page):
50
+ super().__init__()
51
+ self.page = page
52
+ self.set_downloader(YoutubeDL({'quiet': True}))
53
+
54
+ @abstractmethod
55
+ async def get_self_info(self):
56
+ """Extract information about the currently logged-in user."""
57
+ pass
58
+
59
+ @abstractmethod
60
+ async def get_user_info(self, user_id: str):
61
+ """Extract information about a specific user."""
62
+ pass
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from rednote_cli._runtime.common.enums import Platform
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class PlatformLoginProfile:
10
+ login_url: str
11
+ login_selector: str
12
+ qr_selector: str
13
+ account_id_field: str = "user_no"
14
+ nickname_field: str = "nickname"
15
+ guest_field: str = "guest"
16
+
17
+
18
+ class PlatformFactory:
19
+ """Factory for creating platform-specific runtime objects."""
20
+
21
+ _extractor_registry: dict[Platform, type] = {}
22
+ _login_profile_registry: dict[Platform, PlatformLoginProfile] = {}
23
+
24
+ @classmethod
25
+ def register_extractor(cls, platform_enum: Platform, extractor_cls: type) -> None:
26
+ cls._extractor_registry[platform_enum] = extractor_cls
27
+
28
+ @classmethod
29
+ def register_login_profile(cls, platform_enum: Platform, profile: PlatformLoginProfile) -> None:
30
+ cls._login_profile_registry[platform_enum] = profile
31
+
32
+ @classmethod
33
+ def register_platform(
34
+ cls,
35
+ platform_enum: Platform,
36
+ *,
37
+ extractor_cls: type,
38
+ login_profile: PlatformLoginProfile,
39
+ ) -> None:
40
+ cls.register_extractor(platform_enum, extractor_cls)
41
+ cls.register_login_profile(platform_enum, login_profile)
42
+
43
+ @classmethod
44
+ def get_extractor(cls, platform_enum: Platform, page):
45
+ extractor_cls = cls._extractor_registry.get(platform_enum)
46
+ if extractor_cls is None:
47
+ raise ValueError(f"Unsupported platform: {platform_enum}")
48
+ return extractor_cls(page)
49
+
50
+ @classmethod
51
+ def get_login_profile(cls, platform_enum: Platform) -> PlatformLoginProfile:
52
+ profile = cls._login_profile_registry.get(platform_enum)
53
+ if profile is None:
54
+ raise ValueError(f"Unsupported platform: {platform_enum}")
55
+ return profile