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
|
@@ -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
|