rednote-cli 0.1.3__tar.gz → 0.1.5__tar.gz
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-0.1.3 → rednote-cli-0.1.5}/PKG-INFO +7 -1
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/README.md +6 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/pyproject.toml +1 -1
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/__init__.py +1 -1
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/errors.py +4 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/account_manager.py +93 -11
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/base.py +13 -2
- rednote-cli-0.1.5/src/rednote_cli/_runtime/platforms/rednote/__init__.py +17 -0
- rednote-cli-0.1.5/src/rednote_cli/_runtime/platforms/rednote/issues.py +243 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/services/scraper_service.py +24 -8
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_extractor.py +43 -19
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_publisher.py +81 -16
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/auth_login.py +11 -17
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/note.py +11 -8
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/user.py +11 -8
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/runtime.py +7 -4
- rednote-cli-0.1.5/src/rednote_cli/cli/xsec_help.py +51 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/PKG-INFO +7 -1
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/SOURCES.txt +3 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/setup.cfg +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/app_utils.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/config.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/enums.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/browser/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/browser/manager.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/database/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/database/manager.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/factory.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/media.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/models.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/validator.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/services/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/event_stream.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/formatter_json.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/formatter_table.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/writer.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/persistence/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/persistence/file_account_repo.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/extractor.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/publisher.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_registration.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/input_models.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/output_models.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/account_list.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/account_mutation.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/auth_status.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/doctor.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/init_runtime.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/note_get.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/note_search.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/publish_note.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_get.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_search.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_self.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/__main__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/account.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/doctor.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/init.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/publish.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/search.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/main.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/options.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/utils.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/errors.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/note_search_filters.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/__init__.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/exit_codes.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/logger.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/paths.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/platforms.py +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/dependency_links.txt +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/entry_points.txt +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/requires.txt +0 -0
- {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: rednote-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Rednote platform CLI
|
|
5
5
|
Requires-Python: >=3.12
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -61,10 +61,12 @@ rednote-cli search note --keyword 旅行 --size 10 --sort-by latest --note-type
|
|
|
61
61
|
rednote-cli search note --keyword <kw> [--size N] [--sort-by <comprehensive|latest|most_liked|most_commented|most_favorited>] [--note-type <all|video|image_text>] [--publish-time <all|day|week|half_year>] [--search-scope <all|viewed|unviewed|following>] [--location <all|local|nearby>] [--account <user_id>] [--input <file|->]
|
|
62
62
|
|
|
63
63
|
rednote-cli note --note-id <id> [--xsec-token t] [--xsec-source src] [--comment-size N] [--sub-comment-size N] [--account <user_id>] [--input <file|->]
|
|
64
|
+
# 如上游已拿到 xsec_token,默认优先携带;发现页里的笔记=pc_feed,搜索页里的笔记=pc_search,达人收藏页里的笔记=pc_collect,达人点赞页里的笔记=pc_like,达人笔记页里的笔记=pc_user
|
|
64
65
|
|
|
65
66
|
rednote-cli search user --keyword <kw> [--size N] [--account <user_id>] [--input <file|->]
|
|
66
67
|
|
|
67
68
|
rednote-cli user --user-id <id> [--xsec-token t] [--xsec-source src] [--account <user_id>] [--input <file|->]
|
|
69
|
+
# 如上游已拿到 xsec_token,默认优先携带;发现页里的达人=pc_feed,搜索页里的达人=pc_search,达人收藏页里的达人=pc_collect,达人点赞页里的达人=pc_like,笔记页里的达人=pc_note
|
|
68
70
|
|
|
69
71
|
rednote-cli user self --account <user_id>
|
|
70
72
|
|
|
@@ -84,6 +86,10 @@ rednote-cli publish --target video --account <account_uid> [--video <path-or-url
|
|
|
84
86
|
- `user`
|
|
85
87
|
- `publish`
|
|
86
88
|
|
|
89
|
+
`note` 的 `--input` 里如果带 `xsec_source`,它表示笔记来源:发现页笔记=`pc_feed`,搜索页笔记=`pc_search`,达人收藏页笔记=`pc_collect`,达人点赞页笔记=`pc_like`,达人笔记页笔记=`pc_user`。
|
|
90
|
+
|
|
91
|
+
`user` 的 `--input` 里如果带 `xsec_source`,它表示达人来源:发现页达人=`pc_feed`,搜索页达人=`pc_search`,达人收藏页达人=`pc_collect`,达人点赞页达人=`pc_like`,笔记页达人=`pc_note`。
|
|
92
|
+
|
|
87
93
|
不支持 `--input` 的命令:
|
|
88
94
|
- `init runtime`
|
|
89
95
|
- `doctor run`
|
|
@@ -54,10 +54,12 @@ rednote-cli search note --keyword 旅行 --size 10 --sort-by latest --note-type
|
|
|
54
54
|
rednote-cli search note --keyword <kw> [--size N] [--sort-by <comprehensive|latest|most_liked|most_commented|most_favorited>] [--note-type <all|video|image_text>] [--publish-time <all|day|week|half_year>] [--search-scope <all|viewed|unviewed|following>] [--location <all|local|nearby>] [--account <user_id>] [--input <file|->]
|
|
55
55
|
|
|
56
56
|
rednote-cli note --note-id <id> [--xsec-token t] [--xsec-source src] [--comment-size N] [--sub-comment-size N] [--account <user_id>] [--input <file|->]
|
|
57
|
+
# 如上游已拿到 xsec_token,默认优先携带;发现页里的笔记=pc_feed,搜索页里的笔记=pc_search,达人收藏页里的笔记=pc_collect,达人点赞页里的笔记=pc_like,达人笔记页里的笔记=pc_user
|
|
57
58
|
|
|
58
59
|
rednote-cli search user --keyword <kw> [--size N] [--account <user_id>] [--input <file|->]
|
|
59
60
|
|
|
60
61
|
rednote-cli user --user-id <id> [--xsec-token t] [--xsec-source src] [--account <user_id>] [--input <file|->]
|
|
62
|
+
# 如上游已拿到 xsec_token,默认优先携带;发现页里的达人=pc_feed,搜索页里的达人=pc_search,达人收藏页里的达人=pc_collect,达人点赞页里的达人=pc_like,笔记页里的达人=pc_note
|
|
61
63
|
|
|
62
64
|
rednote-cli user self --account <user_id>
|
|
63
65
|
|
|
@@ -77,6 +79,10 @@ rednote-cli publish --target video --account <account_uid> [--video <path-or-url
|
|
|
77
79
|
- `user`
|
|
78
80
|
- `publish`
|
|
79
81
|
|
|
82
|
+
`note` 的 `--input` 里如果带 `xsec_source`,它表示笔记来源:发现页笔记=`pc_feed`,搜索页笔记=`pc_search`,达人收藏页笔记=`pc_collect`,达人点赞页笔记=`pc_like`,达人笔记页笔记=`pc_user`。
|
|
83
|
+
|
|
84
|
+
`user` 的 `--input` 里如果带 `xsec_source`,它表示达人来源:发现页达人=`pc_feed`,搜索页达人=`pc_search`,达人收藏页达人=`pc_collect`,达人点赞页达人=`pc_like`,笔记页达人=`pc_note`。
|
|
85
|
+
|
|
80
86
|
不支持 `--input` 的命令:
|
|
81
87
|
- `init runtime`
|
|
82
88
|
- `doctor run`
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
class PublishNoteException(Exception):
|
|
2
2
|
"""Base exception for publish-note workflow."""
|
|
3
3
|
|
|
4
|
+
def __init__(self, message: str = "", details: dict | None = None):
|
|
5
|
+
super().__init__(message)
|
|
6
|
+
self.details = details
|
|
7
|
+
|
|
4
8
|
|
|
5
9
|
class InvalidPublishParameterError(PublishNoteException, ValueError):
|
|
6
10
|
"""Raised when publish-note inputs are invalid."""
|
|
@@ -12,6 +12,7 @@ from rednote_cli._runtime.common.enums import Platform
|
|
|
12
12
|
from rednote_cli._runtime.core.browser.manager import stealth_async
|
|
13
13
|
from rednote_cli._runtime.core.database.manager import DatabaseManager
|
|
14
14
|
from rednote_cli._runtime.platforms.factory import PlatformFactory
|
|
15
|
+
from rednote_cli._runtime.platforms.rednote import build_unknown_issue, detect_issue_from_page
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class AccountLeaseManager:
|
|
@@ -162,6 +163,83 @@ class AccountManager:
|
|
|
162
163
|
def get_login_profile(platform_enum: Platform):
|
|
163
164
|
return PlatformFactory.get_login_profile(platform_enum)
|
|
164
165
|
|
|
166
|
+
@staticmethod
|
|
167
|
+
async def _prepare_remote_login_screenshot(
|
|
168
|
+
page,
|
|
169
|
+
*,
|
|
170
|
+
login_url: str,
|
|
171
|
+
qr_selector: str,
|
|
172
|
+
screenshot_file: Path,
|
|
173
|
+
mode: str,
|
|
174
|
+
timeout_seconds: int,
|
|
175
|
+
account_uid: str | None,
|
|
176
|
+
emit_progress: Callable[[str], None],
|
|
177
|
+
) -> dict:
|
|
178
|
+
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
179
|
+
|
|
180
|
+
max_attempts = 2
|
|
181
|
+
last_issue = None
|
|
182
|
+
|
|
183
|
+
for attempt in range(1, max_attempts + 1):
|
|
184
|
+
qr = page.locator(qr_selector).first
|
|
185
|
+
try:
|
|
186
|
+
await qr.wait_for(state="visible", timeout=15_000)
|
|
187
|
+
await page.screenshot(path=str(screenshot_file), full_page=False)
|
|
188
|
+
screenshot_path = str(screenshot_file.resolve())
|
|
189
|
+
logger.info(f"Remote login screenshot saved: {screenshot_path}")
|
|
190
|
+
emit_progress(
|
|
191
|
+
"login.qr_ready",
|
|
192
|
+
mode=mode,
|
|
193
|
+
login_url=login_url,
|
|
194
|
+
screenshot_path=screenshot_path,
|
|
195
|
+
timeout_seconds=timeout_seconds,
|
|
196
|
+
account_uid=account_uid,
|
|
197
|
+
)
|
|
198
|
+
return {
|
|
199
|
+
"ready": True,
|
|
200
|
+
"screenshot_path": screenshot_path,
|
|
201
|
+
"issue": None,
|
|
202
|
+
}
|
|
203
|
+
except PlaywrightTimeoutError:
|
|
204
|
+
logger.warning("QR element not found before screenshot, analyzing page state.")
|
|
205
|
+
|
|
206
|
+
issue = await detect_issue_from_page(page, include_body=True)
|
|
207
|
+
if issue is None:
|
|
208
|
+
issue = build_unknown_issue(
|
|
209
|
+
source="page",
|
|
210
|
+
failure_reason="登录二维码未就绪",
|
|
211
|
+
next_action="请刷新页面后重试登录",
|
|
212
|
+
)
|
|
213
|
+
last_issue = issue
|
|
214
|
+
logger.warning(
|
|
215
|
+
"Remote login issue detected: "
|
|
216
|
+
f"{issue.reason_code} source={issue.source} retry={issue.retry_recommended}"
|
|
217
|
+
)
|
|
218
|
+
if issue.retry_recommended and attempt < max_attempts:
|
|
219
|
+
await page.reload(wait_until="domcontentloaded")
|
|
220
|
+
await page.wait_for_timeout(1500)
|
|
221
|
+
continue
|
|
222
|
+
break
|
|
223
|
+
|
|
224
|
+
await page.screenshot(path=str(screenshot_file), full_page=False)
|
|
225
|
+
screenshot_path = str(screenshot_file.resolve())
|
|
226
|
+
issue_details = last_issue.to_details() if last_issue is not None else build_unknown_issue().to_details()
|
|
227
|
+
logger.info(f"Remote login screenshot saved: {screenshot_path}")
|
|
228
|
+
emit_progress(
|
|
229
|
+
"login.issue_detected",
|
|
230
|
+
mode=mode,
|
|
231
|
+
login_url=login_url,
|
|
232
|
+
screenshot_path=screenshot_path,
|
|
233
|
+
timeout_seconds=timeout_seconds,
|
|
234
|
+
account_uid=account_uid,
|
|
235
|
+
issue=issue_details,
|
|
236
|
+
)
|
|
237
|
+
return {
|
|
238
|
+
"ready": False,
|
|
239
|
+
"screenshot_path": screenshot_path,
|
|
240
|
+
"issue": last_issue,
|
|
241
|
+
}
|
|
242
|
+
|
|
165
243
|
@staticmethod
|
|
166
244
|
async def login_and_add_account(
|
|
167
245
|
platform_enum: Platform,
|
|
@@ -255,21 +333,25 @@ class AccountManager:
|
|
|
255
333
|
if mode == "remote":
|
|
256
334
|
screenshot_file = AccountManager._resolve_screenshot_path(screenshot_path)
|
|
257
335
|
screenshot_file.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
-
|
|
259
|
-
|
|
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,
|
|
336
|
+
remote_state = await AccountManager._prepare_remote_login_screenshot(
|
|
337
|
+
page,
|
|
268
338
|
login_url=profile.login_url,
|
|
269
|
-
|
|
339
|
+
qr_selector=profile.qr_selector,
|
|
340
|
+
screenshot_file=screenshot_file,
|
|
341
|
+
mode=mode,
|
|
270
342
|
timeout_seconds=timeout_seconds,
|
|
271
343
|
account_uid=result["account_uid"],
|
|
344
|
+
emit_progress=_emit_progress,
|
|
272
345
|
)
|
|
346
|
+
result["screenshot_path"] = remote_state["screenshot_path"]
|
|
347
|
+
if not remote_state["ready"]:
|
|
348
|
+
issue = remote_state.get("issue") or build_unknown_issue()
|
|
349
|
+
result["reason"] = "login_issue"
|
|
350
|
+
result["message"] = (
|
|
351
|
+
f"登录失败: {issue.failure_reason},{issue.next_action}"
|
|
352
|
+
)
|
|
353
|
+
result["details"] = issue.to_details()
|
|
354
|
+
return result
|
|
273
355
|
|
|
274
356
|
logger.info("Please complete login in the browser...")
|
|
275
357
|
try:
|
|
@@ -9,9 +9,20 @@ from yt_dlp import YoutubeDL
|
|
|
9
9
|
from yt_dlp.extractor.common import InfoExtractor
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
class
|
|
12
|
+
class PlatformActionableError(Exception):
|
|
13
|
+
"""Exception carrying structured details for upstream/platform issues."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, message: str = "", *, details: dict | None = None, code: str = "INTERNAL_ERROR"):
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.details = details
|
|
18
|
+
self.code = code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RiskControlException(PlatformActionableError):
|
|
13
22
|
"""Exception raised when platform risk control is detected."""
|
|
14
|
-
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str = "命中风控", *, details: dict | None = None):
|
|
25
|
+
super().__init__(message, details=details, code="RISK_CONTROL_TRIGGERED")
|
|
15
26
|
|
|
16
27
|
|
|
17
28
|
class RobustnessMixin:
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from rednote_cli._runtime.platforms.rednote.issues import (
|
|
2
|
+
RednoteIssue,
|
|
3
|
+
build_unknown_issue,
|
|
4
|
+
classify_issue_from_text,
|
|
5
|
+
collect_visible_feedback_texts,
|
|
6
|
+
detect_issue_from_page,
|
|
7
|
+
detect_issue_from_texts,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"RednoteIssue",
|
|
12
|
+
"build_unknown_issue",
|
|
13
|
+
"classify_issue_from_text",
|
|
14
|
+
"collect_visible_feedback_texts",
|
|
15
|
+
"detect_issue_from_page",
|
|
16
|
+
"detect_issue_from_texts",
|
|
17
|
+
]
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
from playwright.async_api import Page
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
VISIBLE_FEEDBACK_SELECTORS = (
|
|
10
|
+
"[role='alert']",
|
|
11
|
+
".d-message-notice",
|
|
12
|
+
".d-message-notice-content",
|
|
13
|
+
".d-message__content",
|
|
14
|
+
".d-modal",
|
|
15
|
+
".d-dialog",
|
|
16
|
+
".toast",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RednoteIssue:
|
|
22
|
+
failure_type: str
|
|
23
|
+
reason_code: str
|
|
24
|
+
failure_reason: str
|
|
25
|
+
next_action: str
|
|
26
|
+
agent_action: str
|
|
27
|
+
source: str
|
|
28
|
+
retry_recommended: bool = False
|
|
29
|
+
cli_code: str = "INTERNAL_ERROR"
|
|
30
|
+
observed_text: str = ""
|
|
31
|
+
|
|
32
|
+
def to_details(self) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"failure_type": self.failure_type,
|
|
35
|
+
"failure_reason": self.failure_reason,
|
|
36
|
+
"next_action": self.next_action,
|
|
37
|
+
"retry_recommended": self.retry_recommended,
|
|
38
|
+
"agent_action": self.agent_action,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_ISSUE_RULES: tuple[dict, ...] = (
|
|
43
|
+
{
|
|
44
|
+
"markers": ("未绑定手机号",),
|
|
45
|
+
"failure_type": "PHONE_NOT_BOUND",
|
|
46
|
+
"reason_code": "PHONE_NOT_BOUND",
|
|
47
|
+
"next_action": "请给账号绑定手机号",
|
|
48
|
+
"agent_action": "REQUEST_USER_ACTION",
|
|
49
|
+
"retry_recommended": False,
|
|
50
|
+
"cli_code": "INTERNAL_ERROR",
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"markers": ("请扫码通过验证", "请通过验证", "验证码"),
|
|
54
|
+
"failure_type": "VERIFICATION_REQUIRED",
|
|
55
|
+
"reason_code": "VERIFICATION_REQUIRED",
|
|
56
|
+
"next_action": "请使用已登录小红书 APP 扫码完成验证",
|
|
57
|
+
"agent_action": "REQUEST_USER_ACTION",
|
|
58
|
+
"retry_recommended": False,
|
|
59
|
+
"cli_code": "RISK_CONTROL_TRIGGERED",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"markers": ("薯队长遇到了点小麻烦",),
|
|
63
|
+
"failure_type": "RISK_CONTROL",
|
|
64
|
+
"reason_code": "CAPTAIN_POTATO",
|
|
65
|
+
"next_action": "请暂停当前操作,稍后重试或联系开发者",
|
|
66
|
+
"agent_action": "STOP",
|
|
67
|
+
"retry_recommended": False,
|
|
68
|
+
"cli_code": "RISK_CONTROL_TRIGGERED",
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"markers": ("IP存在风险", "安全限制"),
|
|
72
|
+
"failure_type": "NETWORK_ENV_RISK",
|
|
73
|
+
"reason_code": "IP_RISK",
|
|
74
|
+
"next_action": "请切换可靠网络环境后重试",
|
|
75
|
+
"agent_action": "STOP",
|
|
76
|
+
"retry_recommended": False,
|
|
77
|
+
"cli_code": "RISK_CONTROL_TRIGGERED",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
"markers": ("未连接到服务器,刷新一下试试",),
|
|
81
|
+
"failure_type": "TRANSIENT_NETWORK",
|
|
82
|
+
"reason_code": "SERVER_DISCONNECTED",
|
|
83
|
+
"next_action": "请刷新页面后重试当前步骤",
|
|
84
|
+
"agent_action": "RETRY_LATER",
|
|
85
|
+
"retry_recommended": True,
|
|
86
|
+
"cli_code": "INTERNAL_ERROR",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"markers": ("当前笔记暂时无法浏览",),
|
|
90
|
+
"failure_type": "CONTENT_UNAVAILABLE",
|
|
91
|
+
"reason_code": "CONTENT_UNAVAILABLE",
|
|
92
|
+
"next_action": "请跳过该内容或联系开发者确认可见性限制",
|
|
93
|
+
"agent_action": "STOP",
|
|
94
|
+
"retry_recommended": False,
|
|
95
|
+
"cli_code": "INTERNAL_ERROR",
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"markers": ("你访问的页面不见了",),
|
|
99
|
+
"failure_type": "PAGE_UNAVAILABLE",
|
|
100
|
+
"reason_code": "PAGE_NOT_FOUND",
|
|
101
|
+
"next_action": "请检查链接参数或联系开发者确认页面访问路径",
|
|
102
|
+
"agent_action": "STOP",
|
|
103
|
+
"retry_recommended": False,
|
|
104
|
+
"cli_code": "INTERNAL_ERROR",
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"markers": ("Cannot read properties of undefined (reading 'status')",),
|
|
108
|
+
"failure_type": "LOGIN_PAGE_BROKEN",
|
|
109
|
+
"reason_code": "QR_WIDGET_SCRIPT_ERROR",
|
|
110
|
+
"next_action": "请刷新页面后重试登录;若仍失败请联系开发者",
|
|
111
|
+
"agent_action": "RETRY_LATER",
|
|
112
|
+
"retry_recommended": True,
|
|
113
|
+
"cli_code": "INTERNAL_ERROR",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _normalize_text(text: str) -> str:
|
|
119
|
+
return (text or "").strip()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _candidate_lines(text: str) -> list[str]:
|
|
123
|
+
normalized = _normalize_text(text)
|
|
124
|
+
if not normalized:
|
|
125
|
+
return []
|
|
126
|
+
return [line.strip() for line in normalized.splitlines() if line.strip()] or [normalized]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _match_rule(text: str, source: str) -> RednoteIssue | None:
|
|
130
|
+
lines = _candidate_lines(text)
|
|
131
|
+
for rule in _ISSUE_RULES:
|
|
132
|
+
for marker in rule["markers"]:
|
|
133
|
+
for line in lines:
|
|
134
|
+
if marker in line:
|
|
135
|
+
return RednoteIssue(
|
|
136
|
+
failure_type=rule["failure_type"],
|
|
137
|
+
reason_code=rule["reason_code"],
|
|
138
|
+
failure_reason=line,
|
|
139
|
+
next_action=rule["next_action"],
|
|
140
|
+
agent_action=rule["agent_action"],
|
|
141
|
+
source=source,
|
|
142
|
+
retry_recommended=rule["retry_recommended"],
|
|
143
|
+
cli_code=rule["cli_code"],
|
|
144
|
+
observed_text=line,
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def classify_issue_from_text(text: str, *, source: str = "page") -> RednoteIssue | None:
|
|
150
|
+
return _match_rule(text, source)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def detect_issue_from_texts(texts: Iterable[str], *, source: str = "page") -> RednoteIssue | None:
|
|
154
|
+
for text in texts:
|
|
155
|
+
issue = classify_issue_from_text(text, source=source)
|
|
156
|
+
if issue is not None:
|
|
157
|
+
return issue
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def build_unknown_issue(
|
|
162
|
+
*,
|
|
163
|
+
source: str = "unknown",
|
|
164
|
+
observed_text: str = "",
|
|
165
|
+
failure_reason: str = "未知原因",
|
|
166
|
+
next_action: str = "请联系开发者",
|
|
167
|
+
agent_action: str = "STOP",
|
|
168
|
+
) -> RednoteIssue:
|
|
169
|
+
return RednoteIssue(
|
|
170
|
+
failure_type="UNKNOWN",
|
|
171
|
+
reason_code="UNKNOWN",
|
|
172
|
+
failure_reason=failure_reason,
|
|
173
|
+
next_action=next_action,
|
|
174
|
+
agent_action=agent_action,
|
|
175
|
+
source=source,
|
|
176
|
+
retry_recommended=False,
|
|
177
|
+
cli_code="INTERNAL_ERROR",
|
|
178
|
+
observed_text=_normalize_text(observed_text),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def collect_visible_feedback_texts(page: Page) -> list[str]:
|
|
183
|
+
texts = await page.evaluate(
|
|
184
|
+
"""
|
|
185
|
+
(selectors) => {
|
|
186
|
+
const isVisible = (el) => {
|
|
187
|
+
if (!el) return false;
|
|
188
|
+
const style = window.getComputedStyle(el);
|
|
189
|
+
if (!style || style.visibility === 'hidden' || style.display === 'none') return false;
|
|
190
|
+
const rect = el.getBoundingClientRect();
|
|
191
|
+
return rect.width > 0 && rect.height > 0;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const nodes = [];
|
|
195
|
+
for (const selector of selectors) {
|
|
196
|
+
for (const el of document.querySelectorAll(selector)) {
|
|
197
|
+
if (isVisible(el)) nodes.push(el);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const el of document.querySelectorAll('[class*="message"], [class*="toast"], [class*="modal"], [class*="dialog"]')) {
|
|
202
|
+
if (isVisible(el)) nodes.push(el);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const uniqueTexts = [];
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
for (const el of nodes) {
|
|
208
|
+
const text = (el.innerText || el.textContent || '').trim();
|
|
209
|
+
if (!text || seen.has(text)) continue;
|
|
210
|
+
seen.add(text);
|
|
211
|
+
uniqueTexts.push(text);
|
|
212
|
+
}
|
|
213
|
+
return uniqueTexts;
|
|
214
|
+
}
|
|
215
|
+
""",
|
|
216
|
+
list(VISIBLE_FEEDBACK_SELECTORS),
|
|
217
|
+
)
|
|
218
|
+
return texts if isinstance(texts, list) else []
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def detect_issue_from_page(
|
|
222
|
+
page: Page,
|
|
223
|
+
*,
|
|
224
|
+
body_text: str = "",
|
|
225
|
+
include_body: bool = True,
|
|
226
|
+
) -> RednoteIssue | None:
|
|
227
|
+
visible_texts = await collect_visible_feedback_texts(page)
|
|
228
|
+
issue = detect_issue_from_texts(visible_texts, source="toast")
|
|
229
|
+
if issue is not None:
|
|
230
|
+
return issue
|
|
231
|
+
|
|
232
|
+
if not include_body:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
effective_body_text = _normalize_text(body_text)
|
|
236
|
+
if not effective_body_text:
|
|
237
|
+
body = page.locator("body").first
|
|
238
|
+
if await body.count() == 0:
|
|
239
|
+
return None
|
|
240
|
+
effective_body_text = _normalize_text(await body.inner_text() or "")
|
|
241
|
+
if not effective_body_text:
|
|
242
|
+
return None
|
|
243
|
+
return classify_issue_from_text(effective_body_text, source="page")
|
{rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/services/scraper_service.py
RENAMED
|
@@ -44,22 +44,36 @@ def _build_publish_result_from_raw(raw_result) -> PublishResult:
|
|
|
44
44
|
if isinstance(raw_result, dict):
|
|
45
45
|
uploaded_count = int(raw_result.get("uploaded_count", 0) or 0)
|
|
46
46
|
status = str(raw_result.get("status", "")).strip().lower()
|
|
47
|
-
if status in {"
|
|
48
|
-
if status == "uploaded":
|
|
49
|
-
stage = PublishStage.UPLOAD_DONE
|
|
50
|
-
elif status == "verified":
|
|
51
|
-
stage = PublishStage.VERIFIED
|
|
52
|
-
else:
|
|
53
|
-
stage = PublishStage.SUBMITTED
|
|
47
|
+
if status in {"success", "verified"}:
|
|
54
48
|
return PublishResult(
|
|
55
49
|
success=True,
|
|
56
|
-
stage=
|
|
50
|
+
stage=PublishStage.VERIFIED,
|
|
57
51
|
uploaded_count=uploaded_count,
|
|
58
52
|
publish_url=str(raw_result.get("publish_url") or ""),
|
|
59
53
|
publish_id=str(raw_result.get("publish_id") or ""),
|
|
60
54
|
message=str(raw_result.get("message") or ""),
|
|
61
55
|
data=raw_result,
|
|
62
56
|
)
|
|
57
|
+
if status == "uploaded":
|
|
58
|
+
return PublishResult(
|
|
59
|
+
success=False,
|
|
60
|
+
stage=PublishStage.UPLOAD_DONE,
|
|
61
|
+
uploaded_count=uploaded_count,
|
|
62
|
+
publish_url=str(raw_result.get("publish_url") or ""),
|
|
63
|
+
publish_id=str(raw_result.get("publish_id") or ""),
|
|
64
|
+
message=str(raw_result.get("message") or "素材已上传,但未完成发布"),
|
|
65
|
+
data=raw_result,
|
|
66
|
+
)
|
|
67
|
+
if status == "submitted":
|
|
68
|
+
return PublishResult(
|
|
69
|
+
success=False,
|
|
70
|
+
stage=PublishStage.SUBMITTED,
|
|
71
|
+
uploaded_count=uploaded_count,
|
|
72
|
+
publish_url=str(raw_result.get("publish_url") or ""),
|
|
73
|
+
publish_id=str(raw_result.get("publish_id") or ""),
|
|
74
|
+
message=str(raw_result.get("message") or "发布动作已提交,但未验证是否真正发布成功"),
|
|
75
|
+
data=raw_result,
|
|
76
|
+
)
|
|
63
77
|
return PublishResult(
|
|
64
78
|
success=False,
|
|
65
79
|
stage=PublishStage.FAILED,
|
|
@@ -215,6 +229,8 @@ async def publish_note(
|
|
|
215
229
|
schedule_at=request.schedule_at,
|
|
216
230
|
)
|
|
217
231
|
result = _build_publish_result_from_raw(raw_result)
|
|
232
|
+
if not result.success:
|
|
233
|
+
raise PublishExecutionError(result.message or f"发布未完成(stage={result.stage.value})")
|
|
218
234
|
if result.stage == PublishStage.UPLOAD_DONE:
|
|
219
235
|
result.data["pipeline_stage"] = PublishStage.UPLOAD_DONE.value
|
|
220
236
|
return result.to_dict()
|