@tikomni/skills 0.1.7 → 0.1.9

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.
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env python3
2
+ """Shared input normalization and validation for social-media pipelines."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Dict, List, Optional
8
+ from urllib.parse import parse_qs, urlparse
9
+
10
+ from scripts.core.tikomni_common import normalize_text
11
+
12
+ _HTTP_URL_RE = re.compile(r"https?://[^\s<>'\",。!?;:)】》]+", re.IGNORECASE)
13
+ _URL_TRAILING_PUNCTUATION = ".,!?;:)]}>'\",。!?;:)】》、"
14
+ _XHS_NOTE_ID_RE = re.compile(r"^[0-9A-Za-z]{16,32}$")
15
+ _XHS_USER_ID_RE = re.compile(r"^[0-9A-Za-z]{8,32}$")
16
+ _DOUYIN_SEC_UID_RE = re.compile(r"^MS4wLjA[A-Za-z0-9_-]{8,}$")
17
+
18
+ _DOUYIN_HOST_TOKENS = ("douyin.com", "iesdouyin.com", "v.douyin.com")
19
+ _XHS_HOST_TOKENS = ("xiaohongshu.com", "xhslink.com")
20
+
21
+
22
+ def _dedupe_keep_order(items: List[str]) -> List[str]:
23
+ unique: List[str] = []
24
+ seen = set()
25
+ for item in items:
26
+ if item in seen:
27
+ continue
28
+ unique.append(item)
29
+ seen.add(item)
30
+ return unique
31
+
32
+
33
+ def _strip_url_punctuation(value: str) -> str:
34
+ return value.rstrip(_URL_TRAILING_PUNCTUATION)
35
+
36
+
37
+ def extract_http_urls(text: Optional[str]) -> List[str]:
38
+ raw = normalize_text(text)
39
+ if not raw:
40
+ return []
41
+ matches = [_strip_url_punctuation(match.group(0)) for match in _HTTP_URL_RE.finditer(raw)]
42
+ return _dedupe_keep_order([item for item in matches if item])
43
+
44
+
45
+ def _first_platform_url(text: Optional[str], host_tokens: tuple[str, ...]) -> Optional[str]:
46
+ for url in extract_http_urls(text):
47
+ host = urlparse(url).netloc.lower()
48
+ if any(token in host for token in host_tokens):
49
+ return url
50
+ return None
51
+
52
+
53
+ def _text_contains_host(text: Optional[str], host_tokens: tuple[str, ...]) -> bool:
54
+ lowered = normalize_text(text).lower()
55
+ return any(token in lowered for token in host_tokens)
56
+
57
+
58
+ def looks_like_douyin_sec_uid(value: Optional[str]) -> bool:
59
+ return bool(_DOUYIN_SEC_UID_RE.fullmatch(normalize_text(value)))
60
+
61
+
62
+ def looks_like_xhs_note_id(value: Optional[str]) -> bool:
63
+ return bool(_XHS_NOTE_ID_RE.fullmatch(normalize_text(value)))
64
+
65
+
66
+ def looks_like_xhs_user_id(value: Optional[str]) -> bool:
67
+ return bool(_XHS_USER_ID_RE.fullmatch(normalize_text(value)))
68
+
69
+
70
+ def extract_douyin_share_url(text: Optional[str]) -> Optional[str]:
71
+ return _first_platform_url(text, _DOUYIN_HOST_TOKENS)
72
+
73
+
74
+ def extract_douyin_sec_uid(text: Optional[str]) -> Optional[str]:
75
+ raw = normalize_text(text)
76
+ if not raw:
77
+ return None
78
+ if looks_like_douyin_sec_uid(raw):
79
+ return raw
80
+
81
+ for url in extract_http_urls(raw):
82
+ query = parse_qs(urlparse(url).query)
83
+ for key in ("sec_uid", "sec_user_id"):
84
+ candidate = normalize_text((query.get(key) or [""])[0])
85
+ if looks_like_douyin_sec_uid(candidate):
86
+ return candidate
87
+
88
+ match = re.search(r"(?:sec_uid|sec_user_id)=([A-Za-z0-9._-]+)", raw)
89
+ if match:
90
+ candidate = normalize_text(match.group(1))
91
+ if looks_like_douyin_sec_uid(candidate):
92
+ return candidate
93
+ return None
94
+
95
+
96
+ def extract_xhs_note_id(text: Optional[str]) -> Optional[str]:
97
+ raw = normalize_text(text)
98
+ if not raw:
99
+ return None
100
+ if looks_like_xhs_note_id(raw):
101
+ return raw
102
+
103
+ for pattern in (
104
+ r"/explore/([0-9A-Za-z]+)",
105
+ r"/discovery/item/([0-9A-Za-z]+)",
106
+ r"note_id=([0-9A-Za-z]+)",
107
+ ):
108
+ match = re.search(pattern, raw)
109
+ if not match:
110
+ continue
111
+ candidate = normalize_text(match.group(1))
112
+ if looks_like_xhs_note_id(candidate):
113
+ return candidate
114
+ return None
115
+
116
+
117
+ def extract_xhs_user_id(text: Optional[str]) -> Optional[str]:
118
+ raw = normalize_text(text)
119
+ if not raw:
120
+ return None
121
+ if looks_like_xhs_user_id(raw):
122
+ return raw
123
+
124
+ for pattern in (
125
+ r"/user/profile/([0-9A-Za-z]+)",
126
+ r"(?:user_id|userid)=([0-9A-Za-z]+)",
127
+ ):
128
+ match = re.search(pattern, raw)
129
+ if not match:
130
+ continue
131
+ candidate = normalize_text(match.group(1))
132
+ if looks_like_xhs_user_id(candidate):
133
+ return candidate
134
+ return None
135
+
136
+
137
+ def extract_xhs_note_share_url(text: Optional[str]) -> Optional[str]:
138
+ for url in extract_http_urls(text):
139
+ parsed = urlparse(url)
140
+ host = parsed.netloc.lower()
141
+ path = parsed.path.lower()
142
+ query = parsed.query.lower()
143
+ if "xhslink.com" in host:
144
+ return url
145
+ if "xiaohongshu.com" not in host:
146
+ continue
147
+ if "/explore/" in path or "/discovery/item/" in path or "note_id=" in query:
148
+ return url
149
+ return None
150
+
151
+
152
+ def extract_xhs_creator_share_url(text: Optional[str]) -> Optional[str]:
153
+ for url in extract_http_urls(text):
154
+ parsed = urlparse(url)
155
+ host = parsed.netloc.lower()
156
+ path = parsed.path.lower()
157
+ query = parsed.query.lower()
158
+ if "xhslink.com" in host:
159
+ return url
160
+ if "xiaohongshu.com" not in host:
161
+ continue
162
+ if "/user/profile/" in path or "/user/" in path or "user_id=" in query or "userid=" in query:
163
+ return url
164
+ return None
165
+
166
+
167
+ def text_has_douyin_short_link(text: Optional[str]) -> bool:
168
+ return _text_contains_host(text, ("v.douyin.com",))
169
+
170
+
171
+ def text_has_xhs_short_link(text: Optional[str]) -> bool:
172
+ return _text_contains_host(text, ("xhslink.com",))
173
+
174
+
175
+ def normalize_douyin_work_input(input_value: Optional[str], share_url: Optional[str]) -> Dict[str, object]:
176
+ explicit_share = normalize_text(share_url)
177
+ raw_input = normalize_text(input_value)
178
+ normalized_share = extract_douyin_share_url(explicit_share) or extract_douyin_share_url(raw_input)
179
+
180
+ if normalized_share:
181
+ return {
182
+ "share_url": normalized_share,
183
+ "error_reason": None,
184
+ "missing_fields": [],
185
+ }
186
+
187
+ if raw_input:
188
+ if text_has_douyin_short_link(raw_input):
189
+ return {
190
+ "share_url": None,
191
+ "error_reason": "short_link_detected_but_unresolved",
192
+ "missing_fields": [{"field": "share_url", "reason": "short_link_unresolved"}],
193
+ }
194
+ return {
195
+ "share_url": None,
196
+ "error_reason": "invalid_share_url",
197
+ "missing_fields": [{"field": "share_url", "reason": "invalid_input"}],
198
+ }
199
+
200
+ return {
201
+ "share_url": None,
202
+ "error_reason": None,
203
+ "missing_fields": [],
204
+ }
205
+
206
+
207
+ def normalize_xhs_note_input(
208
+ input_value: Optional[str],
209
+ share_text: Optional[str],
210
+ note_id: Optional[str],
211
+ ) -> Dict[str, object]:
212
+ explicit_note_id = normalize_text(note_id)
213
+ if explicit_note_id and not looks_like_xhs_note_id(explicit_note_id):
214
+ return {
215
+ "share_text": None,
216
+ "note_id": None,
217
+ "error_reason": "invalid_note_id",
218
+ "missing_fields": [{"field": "note_id", "reason": "invalid_format"}],
219
+ }
220
+
221
+ explicit_share = normalize_text(share_text)
222
+ raw_input = normalize_text(input_value)
223
+
224
+ normalized_note_id = explicit_note_id or extract_xhs_note_id(explicit_share) or extract_xhs_note_id(raw_input)
225
+ normalized_share = extract_xhs_note_share_url(explicit_share) or extract_xhs_note_share_url(raw_input)
226
+
227
+ if normalized_note_id or normalized_share:
228
+ return {
229
+ "share_text": normalized_share or None,
230
+ "note_id": normalized_note_id or None,
231
+ "error_reason": None,
232
+ "missing_fields": [],
233
+ }
234
+
235
+ candidate = explicit_share or raw_input
236
+ if candidate:
237
+ if text_has_xhs_short_link(candidate):
238
+ return {
239
+ "share_text": None,
240
+ "note_id": None,
241
+ "error_reason": "short_link_detected_but_unresolved",
242
+ "missing_fields": [{"field": "note_id", "reason": "short_link_unresolved"}],
243
+ }
244
+ return {
245
+ "share_text": None,
246
+ "note_id": None,
247
+ "error_reason": "invalid_note_id",
248
+ "missing_fields": [{"field": "note_id", "reason": "invalid_format"}],
249
+ }
250
+
251
+ return {
252
+ "share_text": None,
253
+ "note_id": None,
254
+ "error_reason": None,
255
+ "missing_fields": [],
256
+ }
257
+
258
+
259
+ def normalize_douyin_creator_input(input_value: Optional[str]) -> Dict[str, object]:
260
+ raw_input = normalize_text(input_value)
261
+ normalized_input = extract_douyin_sec_uid(raw_input) or extract_douyin_share_url(raw_input) or raw_input or None
262
+
263
+ if extract_douyin_sec_uid(raw_input) or extract_douyin_share_url(raw_input):
264
+ return {
265
+ "input_value": normalized_input,
266
+ "error_reason": None,
267
+ "missing_fields": [],
268
+ }
269
+
270
+ if raw_input:
271
+ if text_has_douyin_short_link(raw_input):
272
+ return {
273
+ "input_value": None,
274
+ "error_reason": "short_link_detected_but_unresolved",
275
+ "missing_fields": [{"field": "platform_author_id", "reason": "short_link_unresolved"}],
276
+ }
277
+ return {
278
+ "input_value": None,
279
+ "error_reason": "invalid_creator_input",
280
+ "missing_fields": [{"field": "platform_author_id", "reason": "invalid_format"}],
281
+ }
282
+
283
+ return {
284
+ "input_value": None,
285
+ "error_reason": None,
286
+ "missing_fields": [],
287
+ }
288
+
289
+
290
+ def normalize_xhs_creator_input(input_value: Optional[str]) -> Dict[str, object]:
291
+ raw_input = normalize_text(input_value)
292
+ normalized_input = extract_xhs_user_id(raw_input) or extract_xhs_creator_share_url(raw_input) or None
293
+
294
+ if normalized_input:
295
+ return {
296
+ "input_value": normalized_input,
297
+ "error_reason": None,
298
+ "missing_fields": [],
299
+ }
300
+
301
+ if raw_input:
302
+ if text_has_xhs_short_link(raw_input):
303
+ return {
304
+ "input_value": None,
305
+ "error_reason": "short_link_detected_but_unresolved",
306
+ "missing_fields": [{"field": "platform_author_id", "reason": "short_link_unresolved"}],
307
+ }
308
+ return {
309
+ "input_value": None,
310
+ "error_reason": "invalid_creator_input",
311
+ "missing_fields": [{"field": "platform_author_id", "reason": "invalid_format"}],
312
+ }
313
+
314
+ return {
315
+ "input_value": None,
316
+ "error_reason": None,
317
+ "missing_fields": [],
318
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env python3
2
+ """Shared media URL classification helpers."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Iterable, List
7
+
8
+
9
+ def _is_http_url(url: str) -> bool:
10
+ lower = (url or "").lower()
11
+ return lower.startswith("http://") or lower.startswith("https://")
12
+
13
+
14
+ def is_probable_image_url(url: str) -> bool:
15
+ lower = (url or "").lower()
16
+ if not _is_http_url(lower):
17
+ return False
18
+ image_tokens = [
19
+ ".jpg",
20
+ ".jpeg",
21
+ ".png",
22
+ ".webp",
23
+ ".gif",
24
+ "imageview2",
25
+ "imagemogr2",
26
+ "redimage",
27
+ "frame/",
28
+ "sns-img",
29
+ "sns-webpic",
30
+ "notes_pre_post",
31
+ "/image/",
32
+ "/img/",
33
+ ]
34
+ return any(token in lower for token in image_tokens)
35
+
36
+
37
+ def is_probable_audio_url(url: str) -> bool:
38
+ lower = (url or "").lower()
39
+ if not _is_http_url(lower):
40
+ return False
41
+ audio_tokens = [
42
+ ".m4a",
43
+ ".mp3",
44
+ ".aac",
45
+ ".wav",
46
+ ".flac",
47
+ ".ogg",
48
+ "/audio/",
49
+ "sns-audio",
50
+ "redaudio",
51
+ ]
52
+ return any(token in lower for token in audio_tokens)
53
+
54
+
55
+ def is_probable_video_url(url: str) -> bool:
56
+ lower = (url or "").lower()
57
+ if not _is_http_url(lower):
58
+ return False
59
+ if is_probable_image_url(lower) or is_probable_audio_url(lower):
60
+ return False
61
+ video_tokens = [
62
+ ".mp4",
63
+ ".m3u8",
64
+ ".mov",
65
+ ".flv",
66
+ "/video/",
67
+ "sns-video",
68
+ "redvideo",
69
+ "play",
70
+ "stream",
71
+ "master",
72
+ "vod",
73
+ ]
74
+ return any(token in lower for token in video_tokens)
75
+
76
+
77
+ def filter_video_urls(urls: Iterable[str]) -> List[str]:
78
+ unique: List[str] = []
79
+ seen = set()
80
+ for raw in urls:
81
+ url = str(raw or "").strip()
82
+ if not url or url in seen or not is_probable_video_url(url):
83
+ continue
84
+ unique.append(url)
85
+ seen.add(url)
86
+ return unique
@@ -13,6 +13,14 @@ from scripts.pipelines.schema import (
13
13
  validate_work_item,
14
14
  validate_works_collection,
15
15
  )
16
+ from scripts.pipelines.douyin_metadata import (
17
+ extract_douyin_author,
18
+ extract_douyin_caption,
19
+ extract_douyin_metrics,
20
+ extract_douyin_title,
21
+ normalize_douyin_author_handle,
22
+ )
23
+ from scripts.pipelines.media_url_rules import is_probable_video_url as is_shared_probable_video_url
16
24
  from scripts.core.tikomni_common import deep_find_all, deep_find_first
17
25
  from scripts.pipelines.select_low_quality_video_url import select_low_quality_video_url
18
26
 
@@ -117,12 +125,7 @@ def _normalize_douyin_tags(value: Any) -> List[str]:
117
125
 
118
126
 
119
127
  def _is_probable_video_url(url: str) -> bool:
120
- lower = (url or "").lower()
121
- if not (lower.startswith("http://") or lower.startswith("https://")):
122
- return False
123
- if any(token in lower for token in [".jpg", ".jpeg", ".png", ".webp", "image", "img"]):
124
- return False
125
- return any(token in lower for token in [".mp4", ".m3u8", ".m4a", "video", "stream", "play"])
128
+ return is_shared_probable_video_url(url)
126
129
 
127
130
 
128
131
  def _extract_douyin_video_down_url(item: Dict[str, Any]) -> str:
@@ -348,14 +351,21 @@ def adapt_douyin_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[
348
351
 
349
352
  internal_author_id = _t(_first(profile_data, ["sec_user_id", "sec_uid"], raw.get("resolved_author_id")))
350
353
  stable_author_id = _t(_first(profile_data, ["uid", "user_id", "id"]))
351
- author_handle = _t(_first(profile_data, ["short_id", "unique_id", "douyin_id", "display_id"]))
354
+ author_handle = normalize_douyin_author_handle(
355
+ _first(profile_data, ["unique_id"]),
356
+ _first(profile_data, ["short_id"]),
357
+ _first(profile_data, ["douyin_id"]),
358
+ _first(profile_data, ["display_id"]),
359
+ _first(profile_data, ["nickname", "name"]),
360
+ )
361
+ nickname = _t(_first(profile_data, ["nickname", "name"]))
352
362
 
353
363
  author_id = internal_author_id or stable_author_id
354
364
  profile = build_author_profile(
355
365
  platform="douyin",
356
366
  platform_author_id=author_id,
357
367
  author_handle=author_handle,
358
- nickname=_t(_first(profile_data, ["nickname", "name"])),
368
+ nickname=nickname,
359
369
  ip_location=_t(_first(profile_data, ["ip_location", "ip_label", "ipLocation"])),
360
370
  fans_count=_i(_first(profile_data, ["follower_count", "fans_count", "mplatform_followers_count"])),
361
371
  liked_count=_i(_first(profile_data, ["total_favorited", "liked_count", "favoriting_count"])),
@@ -383,23 +393,27 @@ def adapt_douyin_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[
383
393
  if not isinstance(item, dict):
384
394
  continue
385
395
  aweme_id = _t(_first(item, ["aweme_id", "item_id", "id"]))
386
- metrics = {
387
- "like": _i(_first(item, ["digg_count", "like_count"], 0)),
388
- "comment": _i(_first(item, ["comment_count"], 0)),
389
- "collect": _i(_first(item, ["collect_count"], 0)),
390
- "share": _i(_first(item, ["share_count"], 0)),
391
- "play": _optional_i(_first(item, ["play_count", "view_count"], None)),
392
- }
396
+ author_info = extract_douyin_author(item)
397
+ metrics = extract_douyin_metrics(item)
393
398
  video_down_url = _extract_douyin_video_down_url(item)
394
399
  tags = _normalize_douyin_tags(_first(item, ["hashtags", "tags", "text_extra"], []))
400
+ work_author_handle = normalize_douyin_author_handle(
401
+ author_info.get("author_handle"),
402
+ author_handle,
403
+ nickname,
404
+ )
405
+ work_platform_author_id = _t(author_info.get("platform_author_id") or author_id)
406
+ work_author_platform_id = _t(author_info.get("author_platform_id") or stable_author_id or author_id)
407
+ work_nickname = _t(author_info.get("nickname") or nickname)
408
+ work_signature = _t(author_info.get("signature") or profile.get("signature"))
395
409
  work = build_work_item(
396
410
  platform="douyin",
397
411
  platform_work_id=aweme_id,
398
- platform_author_id=author_id,
399
- author_handle=author_handle,
400
- author_platform_id=stable_author_id or author_id,
401
- title=_t(_first(item, ["title"])),
402
- caption_raw=_t(_first(item, ["desc"])),
412
+ platform_author_id=work_platform_author_id,
413
+ author_handle=work_author_handle,
414
+ author_platform_id=work_author_platform_id,
415
+ title=extract_douyin_title(item),
416
+ caption_raw=extract_douyin_caption(item),
403
417
  subtitle_raw="",
404
418
  subtitle_source="missing",
405
419
  publish_time=_t(_first(item, ["create_time", "publish_time"])),
@@ -407,7 +421,12 @@ def adapt_douyin_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[
407
421
  content_type="video",
408
422
  duration_ms=_i(_first(item, ["duration_ms", "duration"], 0)),
409
423
  tags=tags,
410
- metrics=metrics,
424
+ metrics={
425
+ "digg_count": int(metrics.get("digg_count") or 0),
426
+ "comment_count": int(metrics.get("comment_count") or 0),
427
+ "collect_count": int(metrics.get("collect_count") or 0),
428
+ "share_count": int(metrics.get("share_count") or 0),
429
+ },
411
430
  cover_image=(
412
431
  _extract_first_url(_first(item, ["cover_url"], ""))
413
432
  or _extract_first_url(_first(item, ["cover"], ""))
@@ -420,18 +439,31 @@ def adapt_douyin_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[
420
439
  asr_error_reason="",
421
440
  asr_source="fallback_none",
422
441
  platform_native_refs={
423
- "douyin_sec_uid": internal_author_id,
424
- "douyin_aweme_author_id": stable_author_id or author_id,
442
+ "douyin_sec_uid": _t(author_info.get("douyin_sec_uid") or internal_author_id),
443
+ "douyin_aweme_author_id": _t(author_info.get("douyin_aweme_author_id") or stable_author_id or author_id),
444
+ "douyin_unique_id": _t(author_info.get("unique_id")),
425
445
  },
426
446
  raw_ref={"aweme_id": aweme_id, "raw_item": item},
427
447
  )
428
448
  work.update(
429
449
  {
430
- "digg_count": metrics["like"],
431
- "comment_count": metrics["comment"],
432
- "collect_count": metrics["collect"],
433
- "share_count": metrics["share"],
434
- "play_count": metrics["play"],
450
+ "author": {
451
+ "author_handle": work_author_handle,
452
+ "platform_author_id": work_platform_author_id,
453
+ "author_platform_id": work_author_platform_id,
454
+ "douyin_sec_uid": _t(author_info.get("douyin_sec_uid") or internal_author_id),
455
+ "douyin_aweme_author_id": _t(author_info.get("douyin_aweme_author_id") or stable_author_id or author_id),
456
+ "unique_id": _t(author_info.get("unique_id")),
457
+ "nickname": work_nickname,
458
+ "signature": work_signature,
459
+ },
460
+ "nickname": work_nickname,
461
+ "signature": work_signature,
462
+ "digg_count": int(metrics.get("digg_count") or 0),
463
+ "comment_count": int(metrics.get("comment_count") or 0),
464
+ "collect_count": int(metrics.get("collect_count") or 0),
465
+ "share_count": int(metrics.get("share_count") or 0),
466
+ "play_count": metrics.get("play_count"),
435
467
  }
436
468
  )
437
469
 
@@ -448,16 +480,18 @@ def adapt_xhs_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dic
448
480
 
449
481
  author_id = _t(_first(profile_data, ["user_id", "userid", "id"], raw.get("resolved_author_id")))
450
482
  author_handle = _t(_first(profile_data, ["red_id", "redid", "display_id", "username"]))
483
+ nickname = _t(_first(profile_data, ["nickname", "name"]))
484
+ signature = _t(_first(profile_data, ["desc", "signature", "bio", "introduction"]))
451
485
  profile = build_author_profile(
452
486
  platform="xiaohongshu",
453
487
  platform_author_id=author_id,
454
488
  author_handle=author_handle,
455
- nickname=_t(_first(profile_data, ["nickname", "name"])),
489
+ nickname=nickname,
456
490
  ip_location=_t(_first(profile_data, ["ip_location", "ip_location_desc", "ipLocation"])),
457
491
  fans_count=_i(_first(profile_data, ["fans", "fans_count", "follower_count", "followers"])),
458
492
  liked_count=_i(_first(profile_data, ["liked_count", "likes", "total_liked", "like_count"])),
459
493
  collected_count=_i(_first(profile_data, ["collected_count", "collect_count", "total_collected", "favorite_count"])),
460
- signature=_t(_first(profile_data, ["desc", "signature", "bio", "introduction"])),
494
+ signature=signature,
461
495
  avatar_url=_extract_xhs_avatar_url(profile_data),
462
496
  works_count=_i(_first(profile_data, ["notes", "note_count", "works_count", "post_count"])),
463
497
  verified=bool(_first(profile_data, ["official_verified", "verified"], False)),
@@ -480,6 +514,8 @@ def adapt_xhs_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dic
480
514
  "share": _i(_first(item, ["share_count"], 0)),
481
515
  "play": _optional_i(_first(item, ["view_count", "play_count"], None)),
482
516
  }
517
+ if (metrics["play"] or 0) <= 0 and max(metrics["like"], metrics["comment"], metrics["collect"], metrics["share"]) > 0:
518
+ metrics["play"] = None
483
519
  subtitle_inline = _extract_xhs_subtitle_inline(item)
484
520
  subtitle_urls = _extract_xhs_subtitle_urls(item)
485
521
  video_down_url = _extract_xhs_video_down_url(item)
@@ -489,6 +525,8 @@ def adapt_xhs_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dic
489
525
  cover_image = _extract_xhs_cover_image(item)
490
526
  source_url = _extract_xhs_source_url(item, note_id)
491
527
  share_url = _extract_xhs_share_url(item, note_id)
528
+ work_nickname = nickname
529
+ work_signature = signature
492
530
 
493
531
  work = build_work_item(
494
532
  platform="xiaohongshu",
@@ -523,6 +561,15 @@ def adapt_xhs_author_home(raw: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dic
523
561
  )
524
562
  work.update(
525
563
  {
564
+ "author": {
565
+ "author_handle": author_handle,
566
+ "platform_author_id": author_id,
567
+ "author_platform_id": author_id,
568
+ "nickname": work_nickname,
569
+ "signature": work_signature,
570
+ },
571
+ "nickname": work_nickname,
572
+ "signature": work_signature,
526
573
  "digg_count": metrics["like"],
527
574
  "comment_count": metrics["comment"],
528
575
  "collect_count": metrics["collect"],