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,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."""