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,12 @@
|
|
|
1
|
+
from rednote_cli._runtime.platforms.publishing.media import MediaPreprocessor
|
|
2
|
+
from rednote_cli._runtime.platforms.publishing.models import PublishRequest, PublishResult, PublishStage, PublishTarget
|
|
3
|
+
from rednote_cli._runtime.platforms.publishing.validator import normalize_publish_request
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"MediaPreprocessor",
|
|
7
|
+
"PublishRequest",
|
|
8
|
+
"PublishResult",
|
|
9
|
+
"PublishStage",
|
|
10
|
+
"PublishTarget",
|
|
11
|
+
"normalize_publish_request",
|
|
12
|
+
]
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tempfile
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.request import Request, urlopen
|
|
10
|
+
|
|
11
|
+
from rednote_cli._runtime.common.errors import InvalidPublishParameterError, PublishMediaPreparationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _is_url(path: str) -> bool:
|
|
15
|
+
try:
|
|
16
|
+
parsed = urllib.parse.urlparse(path)
|
|
17
|
+
return bool(parsed.scheme and parsed.netloc)
|
|
18
|
+
except Exception:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _guess_image_extension(file_head: bytes, content_type: str = "") -> str:
|
|
23
|
+
ct = (content_type or "").lower()
|
|
24
|
+
if file_head.startswith(b"\xFF\xD8\xFF"):
|
|
25
|
+
return ".jpg"
|
|
26
|
+
if file_head.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
27
|
+
return ".png"
|
|
28
|
+
if file_head.startswith((b"GIF87a", b"GIF89a")):
|
|
29
|
+
return ".gif"
|
|
30
|
+
if len(file_head) >= 12 and file_head[:4] == b"RIFF" and file_head[8:12] == b"WEBP":
|
|
31
|
+
return ".webp"
|
|
32
|
+
if file_head.startswith(b"BM"):
|
|
33
|
+
return ".bmp"
|
|
34
|
+
if file_head.startswith((b"II*\x00", b"MM\x00*")):
|
|
35
|
+
return ".tiff"
|
|
36
|
+
|
|
37
|
+
if "png" in ct:
|
|
38
|
+
return ".png"
|
|
39
|
+
if "jpeg" in ct or "jpg" in ct:
|
|
40
|
+
return ".jpg"
|
|
41
|
+
if "webp" in ct:
|
|
42
|
+
return ".webp"
|
|
43
|
+
if "gif" in ct:
|
|
44
|
+
return ".gif"
|
|
45
|
+
if "bmp" in ct:
|
|
46
|
+
return ".bmp"
|
|
47
|
+
if "tiff" in ct:
|
|
48
|
+
return ".tiff"
|
|
49
|
+
return ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _guess_video_extension(file_head: bytes, content_type: str = "", source_suffix: str = "") -> str:
|
|
53
|
+
suffix = (source_suffix or "").strip().lower()
|
|
54
|
+
if suffix in {".mp4", ".mov"}:
|
|
55
|
+
return suffix
|
|
56
|
+
|
|
57
|
+
ct = (content_type or "").lower()
|
|
58
|
+
if "video/mp4" in ct:
|
|
59
|
+
return ".mp4"
|
|
60
|
+
if "video/quicktime" in ct:
|
|
61
|
+
return ".mov"
|
|
62
|
+
|
|
63
|
+
# MP4/MOV both belong to ISO BMFF and commonly contain `ftyp` in the first bytes.
|
|
64
|
+
if len(file_head) >= 12 and file_head[4:8] == b"ftyp":
|
|
65
|
+
major_brand = file_head[8:12]
|
|
66
|
+
if major_brand == b"qt ":
|
|
67
|
+
return ".mov"
|
|
68
|
+
return ".mp4"
|
|
69
|
+
|
|
70
|
+
return ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _supported_extensions_text(extensions: set[str]) -> str:
|
|
74
|
+
return ", ".join(ext.lstrip(".") for ext in sorted(extensions))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MediaPreprocessor:
|
|
78
|
+
"""Prepare and validate media assets before publish flow."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
allowed_image_extensions: set[str] | None = None,
|
|
83
|
+
allowed_video_extensions: set[str] | None = None,
|
|
84
|
+
temp_prefix: str = "publish_upload_",
|
|
85
|
+
):
|
|
86
|
+
self.allowed_image_extensions = allowed_image_extensions or {".png", ".jpg", ".jpeg", ".webp"}
|
|
87
|
+
self.allowed_video_extensions = allowed_video_extensions or {".mp4", ".mov"}
|
|
88
|
+
self.temp_prefix = temp_prefix
|
|
89
|
+
self.temp_dir: str | None = None
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self) -> "MediaPreprocessor":
|
|
92
|
+
self.temp_dir = tempfile.mkdtemp(prefix=self.temp_prefix)
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
96
|
+
await self.cleanup()
|
|
97
|
+
|
|
98
|
+
async def cleanup(self) -> None:
|
|
99
|
+
if self.temp_dir and os.path.exists(self.temp_dir):
|
|
100
|
+
await asyncio.to_thread(shutil.rmtree, self.temp_dir, True)
|
|
101
|
+
self.temp_dir = None
|
|
102
|
+
|
|
103
|
+
async def prepare_images(self, media_list: list[str]) -> list[str]:
|
|
104
|
+
normalized = self._normalize_media_list(media_list, field_name="media_list")
|
|
105
|
+
final_paths: list[str] = []
|
|
106
|
+
|
|
107
|
+
for index, item in enumerate(normalized):
|
|
108
|
+
if _is_url(item):
|
|
109
|
+
final_paths.append(await self._download_image(item, index))
|
|
110
|
+
else:
|
|
111
|
+
final_paths.append(await self._validate_local_image(item))
|
|
112
|
+
return final_paths
|
|
113
|
+
|
|
114
|
+
async def prepare_videos(self, media_list: list[str]) -> list[str]:
|
|
115
|
+
normalized = self._normalize_media_list(media_list, field_name="media_list")
|
|
116
|
+
final_paths: list[str] = []
|
|
117
|
+
|
|
118
|
+
for index, item in enumerate(normalized):
|
|
119
|
+
if _is_url(item):
|
|
120
|
+
final_paths.append(await self._download_video(item, index))
|
|
121
|
+
else:
|
|
122
|
+
final_paths.append(await self._validate_local_video(item))
|
|
123
|
+
return final_paths
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _normalize_media_list(media_list: list, field_name: str) -> list[str]:
|
|
127
|
+
if media_list is None:
|
|
128
|
+
raise InvalidPublishParameterError(f"`{field_name}` 不能为空")
|
|
129
|
+
if not isinstance(media_list, (list, tuple)):
|
|
130
|
+
raise InvalidPublishParameterError(f"`{field_name}` 必须是 list 或 tuple")
|
|
131
|
+
if len(media_list) == 0:
|
|
132
|
+
raise InvalidPublishParameterError(f"`{field_name}` 至少包含 1 个文件")
|
|
133
|
+
|
|
134
|
+
normalized = []
|
|
135
|
+
for index, item in enumerate(media_list):
|
|
136
|
+
text = "" if item is None else str(item).strip()
|
|
137
|
+
if not text:
|
|
138
|
+
raise InvalidPublishParameterError(f"`{field_name}` 第 {index + 1} 项不能为空")
|
|
139
|
+
normalized.append(text)
|
|
140
|
+
return normalized
|
|
141
|
+
|
|
142
|
+
async def _validate_local_image(self, image_path: str) -> str:
|
|
143
|
+
local_path = Path(image_path)
|
|
144
|
+
if not local_path.exists():
|
|
145
|
+
raise InvalidPublishParameterError(f"本地图片不存在: {image_path}")
|
|
146
|
+
if not local_path.is_file():
|
|
147
|
+
raise InvalidPublishParameterError(f"路径不是一个有效文件: {image_path}")
|
|
148
|
+
|
|
149
|
+
suffix = local_path.suffix.lower()
|
|
150
|
+
if suffix not in self.allowed_image_extensions:
|
|
151
|
+
supported = _supported_extensions_text(self.allowed_image_extensions)
|
|
152
|
+
raise InvalidPublishParameterError(f"不支持的文件格式: {image_path} (仅支持 {supported})")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
head = await asyncio.to_thread(self._read_file_head, local_path, 64)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
raise PublishMediaPreparationError(f"读取本地图片失败: {image_path}。原因: {e}") from e
|
|
158
|
+
|
|
159
|
+
detected = _guess_image_extension(head)
|
|
160
|
+
if detected and detected not in self.allowed_image_extensions:
|
|
161
|
+
supported = _supported_extensions_text(self.allowed_image_extensions)
|
|
162
|
+
raise InvalidPublishParameterError(f"本地图片格式不支持: {image_path} (仅支持 {supported})")
|
|
163
|
+
|
|
164
|
+
return str(local_path.absolute())
|
|
165
|
+
|
|
166
|
+
async def _validate_local_video(self, video_path: str) -> str:
|
|
167
|
+
local_path = Path(video_path)
|
|
168
|
+
if not local_path.exists():
|
|
169
|
+
raise InvalidPublishParameterError(f"本地视频不存在: {video_path}")
|
|
170
|
+
if not local_path.is_file():
|
|
171
|
+
raise InvalidPublishParameterError(f"路径不是一个有效文件: {video_path}")
|
|
172
|
+
if local_path.stat().st_size <= 0:
|
|
173
|
+
raise InvalidPublishParameterError(f"本地视频文件为空: {video_path}")
|
|
174
|
+
|
|
175
|
+
suffix = local_path.suffix.lower()
|
|
176
|
+
if suffix not in self.allowed_video_extensions:
|
|
177
|
+
supported = _supported_extensions_text(self.allowed_video_extensions)
|
|
178
|
+
raise InvalidPublishParameterError(f"不支持的视频格式: {video_path} (仅支持 {supported})")
|
|
179
|
+
return str(local_path.absolute())
|
|
180
|
+
|
|
181
|
+
async def _download_image(self, image_url: str, index: int) -> str:
|
|
182
|
+
if not self.temp_dir:
|
|
183
|
+
self.temp_dir = tempfile.mkdtemp(prefix=self.temp_prefix)
|
|
184
|
+
return await asyncio.to_thread(self._download_image_sync, image_url, index)
|
|
185
|
+
|
|
186
|
+
async def _download_video(self, video_url: str, index: int) -> str:
|
|
187
|
+
if not self.temp_dir:
|
|
188
|
+
self.temp_dir = tempfile.mkdtemp(prefix=self.temp_prefix)
|
|
189
|
+
return await asyncio.to_thread(self._download_video_sync, video_url, index)
|
|
190
|
+
|
|
191
|
+
def _download_image_sync(self, image_url: str, index: int) -> str:
|
|
192
|
+
req = Request(image_url, method="GET")
|
|
193
|
+
req.add_header(
|
|
194
|
+
"User-Agent",
|
|
195
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
196
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
197
|
+
)
|
|
198
|
+
parsed = urllib.parse.urlparse(image_url)
|
|
199
|
+
req.add_header("Referer", f"{parsed.scheme}://{parsed.netloc}/")
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
with urlopen(req, timeout=20) as resp:
|
|
203
|
+
status = getattr(resp, "status", 200)
|
|
204
|
+
if status != 200:
|
|
205
|
+
raise PublishMediaPreparationError(f"图片下载失败,HTTP 状态码: {status}, url: {image_url}")
|
|
206
|
+
body = resp.read()
|
|
207
|
+
content_type = (resp.headers.get("Content-Type") or "").lower()
|
|
208
|
+
except Exception as e:
|
|
209
|
+
raise PublishMediaPreparationError(f"图片下载失败: {image_url}。原因: {e}") from e
|
|
210
|
+
|
|
211
|
+
ext = _guess_image_extension(body[:64], content_type=content_type)
|
|
212
|
+
if not ext:
|
|
213
|
+
raise InvalidPublishParameterError(f"无法识别图片格式: {image_url}")
|
|
214
|
+
if ext not in self.allowed_image_extensions:
|
|
215
|
+
supported = _supported_extensions_text(self.allowed_image_extensions)
|
|
216
|
+
raise InvalidPublishParameterError(f"不支持的图片格式: {image_url} (仅支持 {supported})")
|
|
217
|
+
|
|
218
|
+
target_path = Path(self.temp_dir) / f"download_{index}{ext}"
|
|
219
|
+
try:
|
|
220
|
+
with open(target_path, "wb") as output:
|
|
221
|
+
output.write(body)
|
|
222
|
+
except Exception as e:
|
|
223
|
+
raise PublishMediaPreparationError(f"保存图片失败: {image_url}。原因: {e}") from e
|
|
224
|
+
|
|
225
|
+
return str(target_path.absolute())
|
|
226
|
+
|
|
227
|
+
def _download_video_sync(self, video_url: str, index: int) -> str:
|
|
228
|
+
req = Request(video_url, method="GET")
|
|
229
|
+
req.add_header(
|
|
230
|
+
"User-Agent",
|
|
231
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
232
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
233
|
+
)
|
|
234
|
+
parsed = urllib.parse.urlparse(video_url)
|
|
235
|
+
req.add_header("Referer", f"{parsed.scheme}://{parsed.netloc}/")
|
|
236
|
+
|
|
237
|
+
source_suffix = Path(urllib.parse.urlparse(video_url).path).suffix.lower()
|
|
238
|
+
supported = _supported_extensions_text(self.allowed_video_extensions)
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with urlopen(req, timeout=60) as resp:
|
|
242
|
+
status = getattr(resp, "status", 200)
|
|
243
|
+
if status != 200:
|
|
244
|
+
raise PublishMediaPreparationError(f"视频下载失败,HTTP 状态码: {status}, url: {video_url}")
|
|
245
|
+
content_type = (resp.headers.get("Content-Type") or "").lower()
|
|
246
|
+
|
|
247
|
+
first_chunk = resp.read(64 * 1024)
|
|
248
|
+
if not first_chunk:
|
|
249
|
+
raise InvalidPublishParameterError(f"视频下载为空: {video_url}")
|
|
250
|
+
|
|
251
|
+
ext = _guess_video_extension(first_chunk[:64], content_type=content_type, source_suffix=source_suffix)
|
|
252
|
+
if not ext:
|
|
253
|
+
raise InvalidPublishParameterError(f"无法识别视频格式: {video_url}")
|
|
254
|
+
if ext not in self.allowed_video_extensions:
|
|
255
|
+
raise InvalidPublishParameterError(f"不支持的视频格式: {video_url} (仅支持 {supported})")
|
|
256
|
+
|
|
257
|
+
target_path = Path(self.temp_dir) / f"download_{index}{ext}"
|
|
258
|
+
with open(target_path, "wb") as output:
|
|
259
|
+
output.write(first_chunk)
|
|
260
|
+
while True:
|
|
261
|
+
chunk = resp.read(1024 * 1024)
|
|
262
|
+
if not chunk:
|
|
263
|
+
break
|
|
264
|
+
output.write(chunk)
|
|
265
|
+
except InvalidPublishParameterError:
|
|
266
|
+
raise
|
|
267
|
+
except Exception as e:
|
|
268
|
+
raise PublishMediaPreparationError(f"视频下载失败: {video_url}。原因: {e}") from e
|
|
269
|
+
|
|
270
|
+
return str(target_path.absolute())
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def _read_file_head(path: Path, size: int) -> bytes:
|
|
274
|
+
with open(path, "rb") as f:
|
|
275
|
+
return f.read(size)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PublishTarget(str, Enum):
|
|
10
|
+
IMAGE = "image"
|
|
11
|
+
VIDEO = "video"
|
|
12
|
+
ARTICLE = "article"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PublishStage(str, Enum):
|
|
16
|
+
INIT = "init"
|
|
17
|
+
MEDIA_READY = "media_ready"
|
|
18
|
+
PAGE_READY = "page_ready"
|
|
19
|
+
UPLOAD_DONE = "upload_done"
|
|
20
|
+
FORM_FILLED = "form_filled"
|
|
21
|
+
SUBMITTED = "submitted"
|
|
22
|
+
VERIFIED = "verified"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class PublishRequest:
|
|
28
|
+
target: PublishTarget
|
|
29
|
+
title: str
|
|
30
|
+
content: str
|
|
31
|
+
media_list: list[str]
|
|
32
|
+
tags: list[str] = field(default_factory=list)
|
|
33
|
+
schedule_at: Optional[datetime] = None
|
|
34
|
+
account_uid: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class PublishResult:
|
|
39
|
+
success: bool
|
|
40
|
+
stage: PublishStage
|
|
41
|
+
uploaded_count: int = 0
|
|
42
|
+
publish_url: str = ""
|
|
43
|
+
publish_id: str = ""
|
|
44
|
+
risk_flags: list[str] = field(default_factory=list)
|
|
45
|
+
message: str = ""
|
|
46
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, Any]:
|
|
49
|
+
payload = {
|
|
50
|
+
"success": self.success,
|
|
51
|
+
"stage": self.stage.value,
|
|
52
|
+
"uploaded_count": self.uploaded_count,
|
|
53
|
+
"publish_url": self.publish_url,
|
|
54
|
+
"publish_id": self.publish_id,
|
|
55
|
+
"risk_flags": self.risk_flags,
|
|
56
|
+
"message": self.message,
|
|
57
|
+
}
|
|
58
|
+
payload.update(self.data)
|
|
59
|
+
return payload
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Iterable, Optional, Union
|
|
5
|
+
|
|
6
|
+
from rednote_cli._runtime.common.errors import InvalidPublishParameterError, UnsupportedPublishTargetError
|
|
7
|
+
from rednote_cli._runtime.platforms.publishing.models import PublishRequest, PublishTarget
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def calc_title_length(title: str) -> int:
|
|
11
|
+
byte_len = 0
|
|
12
|
+
for ch in title:
|
|
13
|
+
byte_len += 2 if ord(ch) > 127 else 1
|
|
14
|
+
return (byte_len + 1) // 2
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_rfc3339(value: str) -> datetime:
|
|
18
|
+
text = value.strip()
|
|
19
|
+
if text.endswith("Z"):
|
|
20
|
+
text = text[:-1] + "+00:00"
|
|
21
|
+
dt = datetime.fromisoformat(text)
|
|
22
|
+
if dt.tzinfo is None:
|
|
23
|
+
raise InvalidPublishParameterError("`schedule_at` 必须包含时区,例如 +08:00")
|
|
24
|
+
return dt
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _normalize_target(target: Union[str, PublishTarget]) -> PublishTarget:
|
|
28
|
+
if isinstance(target, PublishTarget):
|
|
29
|
+
return target
|
|
30
|
+
if not target:
|
|
31
|
+
raise InvalidPublishParameterError("`target` 不能为空")
|
|
32
|
+
text = str(target).strip().lower()
|
|
33
|
+
for item in PublishTarget:
|
|
34
|
+
if item.value == text:
|
|
35
|
+
return item
|
|
36
|
+
raise UnsupportedPublishTargetError(f"不支持的 target: {target},当前支持: image, video, article")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _normalize_text_list(items: Optional[Iterable], field_name: str) -> list[str]:
|
|
40
|
+
if items is None:
|
|
41
|
+
return []
|
|
42
|
+
if not isinstance(items, (list, tuple)):
|
|
43
|
+
raise InvalidPublishParameterError(f"`{field_name}` 必须是 list 或 tuple")
|
|
44
|
+
|
|
45
|
+
normalized: list[str] = []
|
|
46
|
+
for index, item in enumerate(items):
|
|
47
|
+
text = "" if item is None else str(item).strip()
|
|
48
|
+
if not text:
|
|
49
|
+
raise InvalidPublishParameterError(f"`{field_name}` 第 {index + 1} 项不能为空")
|
|
50
|
+
normalized.append(text)
|
|
51
|
+
return normalized
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _normalize_media_list(media_list: Optional[Iterable], required: bool) -> list[str]:
|
|
55
|
+
if media_list is None and not required:
|
|
56
|
+
return []
|
|
57
|
+
normalized = _normalize_text_list(media_list, "media_list")
|
|
58
|
+
if required and not normalized:
|
|
59
|
+
raise InvalidPublishParameterError("`media_list` 至少包含 1 个素材")
|
|
60
|
+
return normalized
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _normalize_schedule(schedule_at: Optional[Union[str, datetime]]) -> Optional[datetime]:
|
|
64
|
+
if schedule_at is None:
|
|
65
|
+
return None
|
|
66
|
+
if isinstance(schedule_at, datetime):
|
|
67
|
+
dt = schedule_at
|
|
68
|
+
else:
|
|
69
|
+
dt = parse_rfc3339(str(schedule_at))
|
|
70
|
+
|
|
71
|
+
if dt.tzinfo is None:
|
|
72
|
+
raise InvalidPublishParameterError("`schedule_at` 必须包含时区,例如 +08:00")
|
|
73
|
+
|
|
74
|
+
now = datetime.now(dt.tzinfo)
|
|
75
|
+
min_time = now + timedelta(hours=1)
|
|
76
|
+
max_time = now + timedelta(days=14)
|
|
77
|
+
if dt < min_time:
|
|
78
|
+
raise InvalidPublishParameterError(
|
|
79
|
+
f"定时发布时间至少在 1 小时后,当前: {dt.strftime('%Y-%m-%d %H:%M')}, 最早: {min_time.strftime('%Y-%m-%d %H:%M')}"
|
|
80
|
+
)
|
|
81
|
+
if dt > max_time:
|
|
82
|
+
raise InvalidPublishParameterError(
|
|
83
|
+
f"定时发布时间不能超过 14 天,当前: {dt.strftime('%Y-%m-%d %H:%M')}, 最晚: {max_time.strftime('%Y-%m-%d %H:%M')}"
|
|
84
|
+
)
|
|
85
|
+
return dt
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalize_publish_request(
|
|
89
|
+
*,
|
|
90
|
+
target: Union[str, PublishTarget],
|
|
91
|
+
title: str = "",
|
|
92
|
+
content: str = "",
|
|
93
|
+
media_list: Optional[Iterable] = None,
|
|
94
|
+
tags: Optional[Iterable] = None,
|
|
95
|
+
schedule_at: Optional[Union[str, datetime]] = None,
|
|
96
|
+
account_uid: Optional[str] = None,
|
|
97
|
+
) -> PublishRequest:
|
|
98
|
+
normalized_target = _normalize_target(target)
|
|
99
|
+
media_required = normalized_target in {PublishTarget.IMAGE, PublishTarget.VIDEO}
|
|
100
|
+
normalized_media = _normalize_media_list(media_list, required=media_required)
|
|
101
|
+
normalized_tags = _normalize_text_list(tags, "tags")
|
|
102
|
+
|
|
103
|
+
if normalized_target == PublishTarget.VIDEO and len(normalized_media) != 1:
|
|
104
|
+
raise InvalidPublishParameterError("`target=video` 时 `media_list` 必须且仅包含 1 个视频素材")
|
|
105
|
+
|
|
106
|
+
title_text = (title or "").strip()
|
|
107
|
+
content_text = (content or "").strip()
|
|
108
|
+
if title_text and calc_title_length(title_text) > 20:
|
|
109
|
+
raise InvalidPublishParameterError("标题长度超过限制(最多 20)")
|
|
110
|
+
|
|
111
|
+
if len(normalized_tags) > 10:
|
|
112
|
+
normalized_tags = normalized_tags[:10]
|
|
113
|
+
|
|
114
|
+
normalized_schedule = _normalize_schedule(schedule_at)
|
|
115
|
+
|
|
116
|
+
return PublishRequest(
|
|
117
|
+
target=normalized_target,
|
|
118
|
+
title=title_text,
|
|
119
|
+
content=content_text,
|
|
120
|
+
media_list=normalized_media,
|
|
121
|
+
tags=normalized_tags,
|
|
122
|
+
schedule_at=normalized_schedule,
|
|
123
|
+
account_uid=(account_uid or "").strip() or None,
|
|
124
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Service package for CLI runtime."""
|