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,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}
|