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,373 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import random
5
+ import time
6
+ from datetime import datetime
7
+ from typing import Optional
8
+
9
+ from loguru import logger
10
+ from playwright.async_api import Page, TimeoutError
11
+
12
+ from rednote_cli._runtime.common.errors import InvalidPublishParameterError
13
+
14
+ URL_OF_PUBLISH = "https://creator.xiaohongshu.com/publish/publish?source=official"
15
+
16
+
17
+ class RednotePublisher:
18
+ """
19
+ 图文发布流程实现。
20
+ 业务逻辑严格对齐 publish_note.py 中 PublishAction。
21
+ """
22
+
23
+ def __init__(self, page: Page):
24
+ self.page = page
25
+
26
+ async def _rand_sleep(self, low: float, high: float) -> None:
27
+ await self.page.wait_for_timeout(int(random.uniform(low, high) * 1000))
28
+
29
+ async def open_publish_page(self, tab_name: str = "上传图文") -> None:
30
+ await self.page.goto(URL_OF_PUBLISH, wait_until="domcontentloaded", timeout=300_000)
31
+ await self.page.wait_for_timeout(2000)
32
+ await self.page.locator("div.upload-content").first.wait_for(state="visible", timeout=30_000)
33
+ await self.must_click_publish_tab(tab_name)
34
+ await self.page.wait_for_timeout(1000)
35
+
36
+ async def remove_pop_cover(self) -> None:
37
+ pop = self.page.locator("div.d-popover")
38
+ if await pop.count() > 0:
39
+ await self.page.evaluate(
40
+ """
41
+ () => {
42
+ const node = document.querySelector('div.d-popover');
43
+ if (node) node.remove();
44
+ }
45
+ """
46
+ )
47
+ x = 380 + random.randint(0, 100)
48
+ y = 20 + random.randint(0, 60)
49
+ await self.page.mouse.click(x, y)
50
+
51
+ async def must_click_publish_tab(self, tab_name: str) -> None:
52
+ deadline = time.time() + 15
53
+ while time.time() < deadline:
54
+ tabs = self.page.locator("div.creator-tab")
55
+ count = await tabs.count()
56
+ for i in range(count):
57
+ tab = tabs.nth(i)
58
+ if not await tab.is_visible():
59
+ continue
60
+
61
+ text = (await tab.inner_text() or "").strip()
62
+ if text != tab_name:
63
+ continue
64
+
65
+ blocked = await tab.evaluate(
66
+ """
67
+ (el) => {
68
+ const rect = el.getBoundingClientRect();
69
+ if (rect.width === 0 || rect.height === 0) return true;
70
+ const x = rect.left + rect.width / 2;
71
+ const y = rect.top + rect.height / 2;
72
+ const target = document.elementFromPoint(x, y);
73
+ return !(target === el || el.contains(target));
74
+ }
75
+ """
76
+ )
77
+ if blocked:
78
+ await self.remove_pop_cover()
79
+ await self.page.wait_for_timeout(200)
80
+ continue
81
+
82
+ await tab.click()
83
+ return
84
+
85
+ await self.page.wait_for_timeout(200)
86
+
87
+ raise TimeoutError(f"没有找到发布 TAB: {tab_name}")
88
+
89
+ async def upload_images(self, image_paths: list[str]) -> list[str]:
90
+ if not image_paths:
91
+ raise InvalidPublishParameterError("没有可上传的本地图片")
92
+
93
+ valid_paths: list[str] = []
94
+ for p in image_paths:
95
+ if os.path.exists(p):
96
+ valid_paths.append(p)
97
+ logger.info(f"有效图片: {p}")
98
+ else:
99
+ logger.warning(f"图片文件不存在,跳过: {p}")
100
+
101
+ if not valid_paths:
102
+ raise InvalidPublishParameterError("没有可上传的本地图片")
103
+
104
+ for i, path in enumerate(valid_paths):
105
+ selector = ".upload-input" if i == 0 else "input[type='file']"
106
+ file_input = self.page.locator(selector).first
107
+ await file_input.set_input_files(path)
108
+ logger.info(f"图片已提交上传: index={i + 1}, path={path}")
109
+ await self.wait_for_upload_complete(i + 1)
110
+ await self._rand_sleep(0.8, 1.3)
111
+
112
+ return valid_paths
113
+
114
+ async def wait_for_upload_complete(self, expected_count: int) -> None:
115
+ start = time.time()
116
+ last = expected_count - 1
117
+ while time.time() - start < 60:
118
+ current = await self.page.locator(".img-preview-area .pr").count()
119
+ if current != last:
120
+ logger.info(f"等待图片上传: current={current}, expected={expected_count}")
121
+ last = current
122
+ if current >= expected_count:
123
+ logger.info(f"图片上传完成: count={current}")
124
+ return
125
+ await self.page.wait_for_timeout(500)
126
+ raise TimeoutError(f"第 {expected_count} 张图片上传超时(60s)")
127
+
128
+ async def wait_for_publish_button_clickable(self, max_wait_seconds: int = 600):
129
+ start = time.time()
130
+ selector = ".publish-page-publish-btn button.bg-red"
131
+ logger.info("开始等待发布按钮可点击(视频)")
132
+ while time.time() - start < max_wait_seconds:
133
+ btn = self.page.locator(selector).first
134
+ if await btn.count() > 0 and await btn.is_visible():
135
+ disabled = await btn.get_attribute("disabled")
136
+ cls = await btn.get_attribute("class") or ""
137
+ if disabled is None and "disabled" not in cls:
138
+ return btn
139
+ if disabled is None:
140
+ return btn
141
+ await self.page.wait_for_timeout(1000)
142
+ raise TimeoutError("等待发布按钮可点击超时")
143
+
144
+ async def upload_video(self, video_path: str) -> str:
145
+ if not os.path.exists(video_path):
146
+ raise InvalidPublishParameterError(f"视频文件不存在: {video_path}")
147
+
148
+ file_input = self.page.locator(".upload-input").first
149
+ if await file_input.count() == 0:
150
+ file_input = self.page.locator("input[type='file']").first
151
+ if await file_input.count() == 0:
152
+ raise InvalidPublishParameterError("未找到视频上传输入框")
153
+
154
+ await file_input.set_input_files(video_path)
155
+ await self.wait_for_publish_button_clickable(max_wait_seconds=600)
156
+ logger.info(f"视频上传/处理完成,发布按钮可点击: {video_path}")
157
+ return video_path
158
+
159
+ async def get_content_element(self):
160
+ editor = self.page.locator("div.ql-editor").first
161
+ if await editor.count() > 0:
162
+ return editor
163
+
164
+ handle = await self.page.evaluate_handle(
165
+ """
166
+ () => {
167
+ const ps = Array.from(document.querySelectorAll('p'));
168
+ const placeholder = ps.find((p) => (p.getAttribute('data-placeholder') || '').includes('输入正文描述'));
169
+ if (!placeholder) return null;
170
+ let cur = placeholder;
171
+ for (let i = 0; i < 5 && cur; i++) {
172
+ cur = cur.parentElement;
173
+ if (!cur) break;
174
+ if ((cur.getAttribute('role') || '') === 'textbox') return cur;
175
+ }
176
+ return null;
177
+ }
178
+ """
179
+ )
180
+ elem = handle.as_element()
181
+ if elem is None:
182
+ raise InvalidPublishParameterError("没有找到正文输入框")
183
+ return elem
184
+
185
+ async def check_title_max_length(self) -> None:
186
+ suffix = self.page.locator("div.title-container div.max_suffix").first
187
+ if await suffix.count() == 0:
188
+ return
189
+ text = (await suffix.inner_text() or "").strip()
190
+ if "/" not in text:
191
+ raise InvalidPublishParameterError(f"标题长度超过限制: {text}")
192
+ curr, max_len = text.split("/", 1)
193
+ raise InvalidPublishParameterError(f"当前输入长度为{curr},最大长度为{max_len}")
194
+
195
+ async def check_content_max_length(self) -> None:
196
+ err = self.page.locator("div.edit-container div.length-error").first
197
+ if await err.count() == 0:
198
+ return
199
+ text = (await err.inner_text() or "").strip()
200
+ if "/" not in text:
201
+ raise InvalidPublishParameterError(f"正文长度超过限制: {text}")
202
+ curr, max_len = text.split("/", 1)
203
+ raise InvalidPublishParameterError(f"当前输入长度为{curr},最大长度为{max_len}")
204
+
205
+ async def input_tags(self, content_elem, tags: list[str]) -> None:
206
+ if not tags:
207
+ return
208
+
209
+ await content_elem.click()
210
+ await self.page.wait_for_timeout(1000)
211
+ for _ in range(20):
212
+ await self.page.keyboard.press("ArrowDown")
213
+ await self.page.wait_for_timeout(10)
214
+ await self.page.keyboard.press("Enter")
215
+ await self.page.keyboard.press("Enter")
216
+ await self.page.wait_for_timeout(1000)
217
+
218
+ for tag in tags:
219
+ clean_tag = tag.lstrip("#")
220
+ await self.page.keyboard.type("#", delay=80)
221
+ for ch in clean_tag:
222
+ await self.page.keyboard.type(ch, delay=50)
223
+ await self.page.wait_for_timeout(1000)
224
+ first_item = self.page.locator("#creator-editor-topic-container .item").first
225
+ if await first_item.count() > 0:
226
+ await first_item.click()
227
+ logger.info(f"成功点击标签联想: {clean_tag}")
228
+ else:
229
+ await self.page.keyboard.type(" ", delay=50)
230
+ logger.info(f"未命中标签联想,直接空格收尾: {clean_tag}")
231
+ await self.page.wait_for_timeout(500)
232
+
233
+ async def set_schedule_publish(self, schedule_time: datetime) -> None:
234
+ switch = self.page.locator(".post-time-wrapper .d-switch").first
235
+ await switch.click()
236
+ await self.page.wait_for_timeout(800)
237
+
238
+ date_text = schedule_time.strftime("%Y-%m-%d %H:%M")
239
+ date_input = self.page.locator(".date-picker-container input").first
240
+ await date_input.click()
241
+ if os.name == "nt":
242
+ await self.page.keyboard.press("Control+A")
243
+ else:
244
+ await self.page.keyboard.press("Meta+A")
245
+ await self.page.keyboard.press("Control+A")
246
+ await self.page.keyboard.type(date_text, delay=40)
247
+ await self.page.wait_for_timeout(500)
248
+ logger.info(f"定时发布时间已设置: {date_text}")
249
+
250
+ async def submit_publish(
251
+ self,
252
+ title: str,
253
+ content: str,
254
+ tags: list[str],
255
+ schedule_time: Optional[datetime],
256
+ ) -> None:
257
+ title_input = self.page.locator("div.d-input input").first
258
+ await title_input.click()
259
+ await title_input.fill(title)
260
+ await self.page.wait_for_timeout(500)
261
+ await self.check_title_max_length()
262
+ logger.info("标题长度检查通过")
263
+
264
+ await self.page.wait_for_timeout(1000)
265
+ content_elem = await self.get_content_element()
266
+ await content_elem.click()
267
+ await self.page.keyboard.type(content, delay=25)
268
+ await self.input_tags(content_elem, tags)
269
+
270
+ await self.page.wait_for_timeout(1000)
271
+ await self.check_content_max_length()
272
+ logger.info("正文长度检查通过")
273
+
274
+ if schedule_time is not None:
275
+ await self.set_schedule_publish(schedule_time)
276
+
277
+ submit_btn = self.page.locator(".publish-page-publish-btn button.bg-red").first
278
+ await submit_btn.click()
279
+ await self.page.wait_for_timeout(3000)
280
+
281
+ async def submit_publish_video(
282
+ self,
283
+ title: str,
284
+ content: str,
285
+ tags: list[str],
286
+ schedule_time: Optional[datetime],
287
+ ) -> None:
288
+ title_input = self.page.locator("div.d-input input").first
289
+ await title_input.click()
290
+ await title_input.fill(title)
291
+ await self.page.wait_for_timeout(500)
292
+ await self.check_title_max_length()
293
+ logger.info("标题长度检查通过")
294
+
295
+ await self.page.wait_for_timeout(1000)
296
+ content_elem = await self.get_content_element()
297
+ await content_elem.click()
298
+ await self.page.keyboard.type(content, delay=25)
299
+ await self.input_tags(content_elem, tags)
300
+
301
+ await self.page.wait_for_timeout(1000)
302
+ await self.check_content_max_length()
303
+ logger.info("正文长度检查通过")
304
+
305
+ if schedule_time is not None:
306
+ await self.set_schedule_publish(schedule_time)
307
+
308
+ btn = await self.wait_for_publish_button_clickable(max_wait_seconds=600)
309
+ await btn.click()
310
+ await self.page.wait_for_timeout(3000)
311
+
312
+ async def publish_image_note(
313
+ self,
314
+ image_list: list[str],
315
+ title: str = "",
316
+ content: str = "",
317
+ tags: Optional[list[str]] = None,
318
+ schedule_at: Optional[datetime] = None,
319
+ ) -> dict:
320
+ if not image_list:
321
+ raise InvalidPublishParameterError("图片不能为空")
322
+
323
+ await self.open_publish_page("上传图文")
324
+ uploaded = await self.upload_images(image_list)
325
+
326
+ tags = tags or []
327
+ normalized_tags = tags[:10] if len(tags) > 10 else tags
328
+ if len(tags) > 10:
329
+ logger.warning("标签数量超过 10,仅保留前 10 个")
330
+
331
+ await self.submit_publish(
332
+ title=title,
333
+ content=content,
334
+ tags=normalized_tags,
335
+ schedule_time=schedule_at,
336
+ )
337
+ return {
338
+ "target": "image",
339
+ "uploaded_count": len(uploaded),
340
+ "status": "submitted",
341
+ }
342
+
343
+ async def publish_video_note(
344
+ self,
345
+ video_path: str,
346
+ title: str = "",
347
+ content: str = "",
348
+ tags: Optional[list[str]] = None,
349
+ schedule_at: Optional[datetime] = None,
350
+ ) -> dict:
351
+ if not video_path:
352
+ raise InvalidPublishParameterError("视频不能为空")
353
+
354
+ await self.open_publish_page("上传视频")
355
+ uploaded = await self.upload_video(video_path)
356
+ await self._rand_sleep(0.8, 1.3)
357
+
358
+ tags = tags or []
359
+ normalized_tags = tags[:10] if len(tags) > 10 else tags
360
+ if len(tags) > 10:
361
+ logger.warning("标签数量超过 10,仅保留前 10 个")
362
+
363
+ await self.submit_publish_video(
364
+ title=title,
365
+ content=content,
366
+ tags=normalized_tags,
367
+ schedule_time=schedule_at,
368
+ )
369
+ return {
370
+ "target": "video",
371
+ "uploaded_count": 1 if uploaded else 0,
372
+ "status": "submitted",
373
+ }
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from rednote_cli._runtime.common.enums import Platform
4
+ from rednote_cli._runtime.platforms.factory import PlatformFactory, PlatformLoginProfile
5
+ from rednote_cli.adapters.platform.rednote.runtime_extractor import RednoteExtractor
6
+
7
+
8
+ def register_rednote_runtime() -> None:
9
+ PlatformFactory.register_platform(
10
+ Platform.REDNOTE,
11
+ extractor_cls=RednoteExtractor,
12
+ login_profile=PlatformLoginProfile(
13
+ login_url="https://www.xiaohongshu.com/explore",
14
+ login_selector=".main-container .user .link-wrapper .channel",
15
+ qr_selector=".login-container .qrcode-img",
16
+ account_id_field="red_id",
17
+ nickname_field="nickname",
18
+ guest_field="guest",
19
+ ),
20
+ )
@@ -0,0 +1 @@
1
+ """Application layer."""
@@ -0,0 +1 @@
1
+ """DTO models for CLI inputs and outputs."""
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+ from rednote_cli.domain.note_search_filters import normalize_filter_value
9
+
10
+
11
+ class CommonInput(BaseModel):
12
+ trace_id: str | None = None
13
+ account_uid: str | None = None
14
+
15
+
16
+ class NoteSearchInput(CommonInput):
17
+ keyword: str = Field(min_length=1)
18
+ size: int = Field(default=20, ge=1, le=100)
19
+ sort_by: str | None = None
20
+ note_type: str | None = None
21
+ publish_time: str | None = None
22
+ search_scope: str | None = None
23
+ location: str | None = None
24
+
25
+ @field_validator("sort_by", mode="before")
26
+ @classmethod
27
+ def _normalize_sort_by(cls, value: str | None) -> str | None:
28
+ return normalize_filter_value("sort_by", value)
29
+
30
+ @field_validator("note_type", mode="before")
31
+ @classmethod
32
+ def _normalize_note_type(cls, value: str | None) -> str | None:
33
+ return normalize_filter_value("note_type", value)
34
+
35
+ @field_validator("publish_time", mode="before")
36
+ @classmethod
37
+ def _normalize_publish_time(cls, value: str | None) -> str | None:
38
+ return normalize_filter_value("publish_time", value)
39
+
40
+ @field_validator("search_scope", mode="before")
41
+ @classmethod
42
+ def _normalize_search_scope(cls, value: str | None) -> str | None:
43
+ return normalize_filter_value("search_scope", value)
44
+
45
+ @field_validator("location", mode="before")
46
+ @classmethod
47
+ def _normalize_location(cls, value: str | None) -> str | None:
48
+ return normalize_filter_value("location", value)
49
+
50
+
51
+ class UserSearchInput(CommonInput):
52
+ keyword: str = Field(min_length=1)
53
+ size: int = Field(default=20, ge=1, le=100)
54
+
55
+
56
+ class UserGetInput(CommonInput):
57
+ user_id: str = Field(min_length=1)
58
+ xsec_token: str | None = None
59
+ xsec_source: str | None = "pc_feed"
60
+
61
+
62
+ class NoteGetInput(CommonInput):
63
+ note_id: str = Field(min_length=1)
64
+ xsec_token: str | None = None
65
+ xsec_source: str | None = "pc_feed"
66
+ comment_size: int = Field(default=10, ge=1, le=100)
67
+ sub_comment_size: int = Field(default=5, ge=1, le=50)
68
+
69
+
70
+ class PublishNoteInput(CommonInput):
71
+ target: str = Field(min_length=1)
72
+ image_list: list[str] = Field(default_factory=list)
73
+ video: str | None = None
74
+ title: str = ""
75
+ content: str = ""
76
+ tags: list[str] = Field(default_factory=list)
77
+ schedule_at: str | None = None
78
+
79
+ @field_validator("image_list")
80
+ @classmethod
81
+ def _validate_images(cls, value: list[str]) -> list[str]:
82
+ cleaned = [v.strip() for v in value if isinstance(v, str) and v.strip()]
83
+ return cleaned
84
+
85
+ @field_validator("video", mode="before")
86
+ @classmethod
87
+ def _validate_video(cls, value: str | None) -> str | None:
88
+ if value is None:
89
+ return None
90
+ if not isinstance(value, str):
91
+ raise ValueError("video 必须是字符串")
92
+ text = value.strip()
93
+ return text or None
94
+
95
+ @model_validator(mode="after")
96
+ def _validate_publish_target_media(self) -> "PublishNoteInput":
97
+ target_text = self.target.strip().lower()
98
+ if target_text == "image" and not self.image_list:
99
+ raise ValueError("image_list 至少包含一个有效项")
100
+ if target_text == "video" and not self.video:
101
+ raise ValueError("video 不能为空")
102
+ return self
103
+
104
+
105
+ class AccountListInput(CommonInput):
106
+ only_active: bool = True
107
+
108
+
109
+ class AuthLoginInput(CommonInput):
110
+ pass
111
+
112
+
113
+ class GenericInputEnvelope(BaseModel):
114
+ command: str
115
+ params: dict[str, Any]
116
+ trace_id: str | None = None
117
+ account_uid: str | None = None
118
+
119
+
120
+ class PublishScheduleModel(BaseModel):
121
+ schedule_at: datetime
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timezone
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class OutputMeta(BaseModel):
10
+ trace_id: str
11
+ duration_ms: int = Field(ge=0)
12
+ platform: str | None = None
13
+ account_uid: str | None = None
14
+
15
+
16
+ class SuccessOutput(BaseModel):
17
+ ok: bool = True
18
+ command: str
19
+ timestamp: str
20
+ data: Any
21
+ meta: OutputMeta
22
+
23
+
24
+ class ErrorDetail(BaseModel):
25
+ code: str
26
+ message: str
27
+ details: dict[str, Any] | None = None
28
+
29
+
30
+ class ErrorOutput(BaseModel):
31
+ ok: bool = False
32
+ command: str
33
+ timestamp: str
34
+ error: ErrorDetail
35
+ meta: OutputMeta
36
+
37
+
38
+ def _now_iso() -> str:
39
+ return datetime.now(timezone.utc).isoformat()
40
+
41
+
42
+ def build_success_output(
43
+ *,
44
+ command: str,
45
+ data: Any,
46
+ trace_id: str,
47
+ duration_ms: int,
48
+ platform: str | None = None,
49
+ account_uid: str | None = None,
50
+ ) -> SuccessOutput:
51
+ return SuccessOutput(
52
+ command=command,
53
+ timestamp=_now_iso(),
54
+ data=data,
55
+ meta=OutputMeta(
56
+ trace_id=trace_id,
57
+ duration_ms=duration_ms,
58
+ platform=platform,
59
+ account_uid=account_uid,
60
+ ),
61
+ )
62
+
63
+
64
+ def build_error_output(
65
+ *,
66
+ command: str,
67
+ code: str,
68
+ message: str,
69
+ trace_id: str,
70
+ duration_ms: int,
71
+ details: dict[str, Any] | None = None,
72
+ ) -> ErrorOutput:
73
+ return ErrorOutput(
74
+ command=command,
75
+ timestamp=_now_iso(),
76
+ error=ErrorDetail(code=code, message=message, details=details),
77
+ meta=OutputMeta(trace_id=trace_id, duration_ms=duration_ms),
78
+ )
@@ -0,0 +1 @@
1
+ """Use cases for CLI commands."""
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from rednote_cli.adapters.persistence.file_account_repo import FileAccountRepository
4
+ from rednote_cli.infra.platforms import REDNOTE_PLATFORM
5
+
6
+
7
+ def execute_account_list(only_active: bool = True) -> list[dict]:
8
+ repo = FileAccountRepository()
9
+ return repo.list_accounts(platform=REDNOTE_PLATFORM, only_active=only_active)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from rednote_cli.adapters.persistence.file_account_repo import FileAccountRepository
4
+ from rednote_cli.infra.platforms import REDNOTE_PLATFORM
5
+
6
+
7
+ def execute_account_activate(account_uid: str) -> dict:
8
+ repo = FileAccountRepository()
9
+ changed = repo.activate(platform=REDNOTE_PLATFORM, account_uid=account_uid)
10
+ return {"changed": changed, "platform": REDNOTE_PLATFORM, "account_uid": account_uid, "active": True}
11
+
12
+
13
+ def execute_account_deactivate(account_uid: str) -> dict:
14
+ repo = FileAccountRepository()
15
+ changed = repo.deactivate(platform=REDNOTE_PLATFORM, account_uid=account_uid)
16
+ return {"changed": changed, "platform": REDNOTE_PLATFORM, "account_uid": account_uid, "active": False}
17
+
18
+
19
+ def execute_account_delete(account_uid: str) -> dict:
20
+ repo = FileAccountRepository()
21
+ deleted = repo.delete(platform=REDNOTE_PLATFORM, account_uid=account_uid)
22
+ return {"deleted": deleted, "platform": REDNOTE_PLATFORM, "account_uid": account_uid}