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.
Files changed (89) hide show
  1. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/PKG-INFO +7 -1
  2. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/README.md +6 -0
  3. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/pyproject.toml +1 -1
  4. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/__init__.py +1 -1
  5. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/errors.py +4 -0
  6. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/account_manager.py +93 -11
  7. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/base.py +13 -2
  8. rednote-cli-0.1.5/src/rednote_cli/_runtime/platforms/rednote/__init__.py +17 -0
  9. rednote-cli-0.1.5/src/rednote_cli/_runtime/platforms/rednote/issues.py +243 -0
  10. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/services/scraper_service.py +24 -8
  11. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_extractor.py +43 -19
  12. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_publisher.py +81 -16
  13. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/auth_login.py +11 -17
  14. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/note.py +11 -8
  15. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/user.py +11 -8
  16. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/runtime.py +7 -4
  17. rednote-cli-0.1.5/src/rednote_cli/cli/xsec_help.py +51 -0
  18. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/PKG-INFO +7 -1
  19. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/SOURCES.txt +3 -0
  20. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/setup.cfg +0 -0
  21. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/__init__.py +0 -0
  22. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/__init__.py +0 -0
  23. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/app_utils.py +0 -0
  24. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/config.py +0 -0
  25. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/common/enums.py +0 -0
  26. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/__init__.py +0 -0
  27. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/browser/__init__.py +0 -0
  28. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/browser/manager.py +0 -0
  29. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/database/__init__.py +0 -0
  30. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/core/database/manager.py +0 -0
  31. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/__init__.py +0 -0
  32. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/factory.py +0 -0
  33. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/__init__.py +0 -0
  34. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/media.py +0 -0
  35. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/models.py +0 -0
  36. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/platforms/publishing/validator.py +0 -0
  37. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/_runtime/services/__init__.py +0 -0
  38. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/__init__.py +0 -0
  39. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/__init__.py +0 -0
  40. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/event_stream.py +0 -0
  41. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/formatter_json.py +0 -0
  42. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/formatter_table.py +0 -0
  43. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/output/writer.py +0 -0
  44. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/persistence/__init__.py +0 -0
  45. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/persistence/file_account_repo.py +0 -0
  46. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/__init__.py +0 -0
  47. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/__init__.py +0 -0
  48. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/extractor.py +0 -0
  49. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/publisher.py +0 -0
  50. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/adapters/platform/rednote/runtime_registration.py +0 -0
  51. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/__init__.py +0 -0
  52. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/__init__.py +0 -0
  53. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/input_models.py +0 -0
  54. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/dto/output_models.py +0 -0
  55. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/__init__.py +0 -0
  56. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/account_list.py +0 -0
  57. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/account_mutation.py +0 -0
  58. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/auth_status.py +0 -0
  59. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/doctor.py +0 -0
  60. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/init_runtime.py +0 -0
  61. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/note_get.py +0 -0
  62. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/note_search.py +0 -0
  63. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/publish_note.py +0 -0
  64. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_get.py +0 -0
  65. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_search.py +0 -0
  66. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/application/use_cases/user_self.py +0 -0
  67. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/__init__.py +0 -0
  68. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/__main__.py +0 -0
  69. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/__init__.py +0 -0
  70. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/account.py +0 -0
  71. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/doctor.py +0 -0
  72. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/init.py +0 -0
  73. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/publish.py +0 -0
  74. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/commands/search.py +0 -0
  75. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/main.py +0 -0
  76. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/options.py +0 -0
  77. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/cli/utils.py +0 -0
  78. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/__init__.py +0 -0
  79. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/errors.py +0 -0
  80. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/domain/note_search_filters.py +0 -0
  81. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/__init__.py +0 -0
  82. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/exit_codes.py +0 -0
  83. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/logger.py +0 -0
  84. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/paths.py +0 -0
  85. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli/infra/platforms.py +0 -0
  86. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/dependency_links.txt +0 -0
  87. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/entry_points.txt +0 -0
  88. {rednote-cli-0.1.3 → rednote-cli-0.1.5}/src/rednote_cli.egg-info/requires.txt +0 -0
  89. {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
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`
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "rednote-cli"
10
- version = "0.1.3"
10
+ version = "0.1.5"
11
11
  description = "Rednote platform CLI"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.12"
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.1.3"
5
+ __version__ = "0.1.5"
@@ -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
- 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,
336
+ remote_state = await AccountManager._prepare_remote_login_screenshot(
337
+ page,
268
338
  login_url=profile.login_url,
269
- screenshot_path=result["screenshot_path"],
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 RiskControlException(Exception):
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
- pass
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")
@@ -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 {"uploaded", "submitted", "success", "verified"}:
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=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()