@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.
@@ -27,10 +27,15 @@ from scripts.core.config_loader import config_get, load_tikomni_config, resolve_
27
27
  from scripts.core.progress_report import build_progress_reporter
28
28
  from scripts.core.storage_router import resolve_author_directory_name
29
29
  from scripts.core.tikomni_common import resolve_runtime, write_json_stdout
30
+ from scripts.pipelines.input_contracts import normalize_douyin_creator_input
31
+ from scripts.pipelines.schema import build_author_profile
30
32
  from scripts.pipelines.douyin_creator_home_helpers import collect_and_adapt
31
33
  from scripts.pipelines.home_asr import enrich_author_home_asr
32
34
  from scripts.writers.write_work_fact_card import build_work_fact_card, persist_output_envelope, write_work_fact_card
33
35
 
36
+ DEFAULT_MAX_ITEMS = 200
37
+ MAX_ITEMS_HARD_LIMIT = 200
38
+
34
39
 
35
40
  def _write_collection_artifacts(
36
41
  *,
@@ -81,11 +86,12 @@ def run_douyin_creator_home(
81
86
  *,
82
87
  input_value: str,
83
88
  config: Dict[str, Any],
84
- runtime: Dict[str, Any],
89
+ runtime: Dict[str, Any] | None,
85
90
  max_items: int,
86
91
  write_card: bool,
87
92
  persist_output: bool,
88
93
  ) -> Dict[str, Any]:
94
+ bounded_max_items = max(1, min(int(max_items), MAX_ITEMS_HARD_LIMIT))
89
95
  progress = build_progress_reporter(
90
96
  workflow="social-media-crawl",
91
97
  platform="douyin",
@@ -94,15 +100,69 @@ def run_douyin_creator_home(
94
100
  scope="workflow",
95
101
  )
96
102
  progress.started(stage="author_home.workflow", message="douyin author_home workflow started")
103
+ preflight = normalize_douyin_creator_input(input_value)
104
+ normalized_input_value = str(preflight.get("input_value") or "")
105
+ if preflight.get("error_reason"):
106
+ request_id = ensure_request_id(None, fallback_seed=input_value)
107
+ empty_profile = build_author_profile(platform="douyin", request_id=request_id)
108
+ extract_trace = [
109
+ {
110
+ "step": "input.preflight",
111
+ "ok": False,
112
+ "input_kind": "creator_url_or_sec_uid",
113
+ "normalized_input_value": normalized_input_value or None,
114
+ "error_reason": preflight.get("error_reason"),
115
+ "missing_fields": list(preflight.get("missing_fields") or []),
116
+ }
117
+ ]
118
+ envelope = {
119
+ "object_type": "creator",
120
+ "platform": "douyin",
121
+ "input": input_value,
122
+ "normalized": {
123
+ "creator_profile": {**empty_profile, "request_id": request_id, "extract_trace": extract_trace},
124
+ "work_collection": {
125
+ "platform": "douyin",
126
+ "platform_author_id": "",
127
+ "count": 0,
128
+ "items": [],
129
+ "request_id": request_id,
130
+ "extract_trace": extract_trace,
131
+ },
132
+ },
133
+ "completeness": evaluate_collection(empty_profile, []),
134
+ "missing_fields": normalize_missing_fields(preflight.get("missing_fields")),
135
+ "error_reason": str(preflight.get("error_reason") or "invalid_creator_input"),
136
+ "extract_trace": extract_trace,
137
+ "request_id": request_id,
138
+ "card_write": {
139
+ "enabled": bool(write_card),
140
+ "ok": False,
141
+ "count": 0,
142
+ "results": [],
143
+ "reason": "skipped_invalid_input",
144
+ },
145
+ "collection_artifacts": {},
146
+ "output_persist": {"enabled": False, "skipped": True, "reason": "invalid_input"},
147
+ }
148
+ progress.done(
149
+ stage="author_home.workflow",
150
+ message="douyin author_home workflow finished",
151
+ data={"request_id": request_id, "works_count": 0, "error_reason": envelope["error_reason"]},
152
+ )
153
+ return envelope
154
+
155
+ if runtime is None:
156
+ raise ValueError("runtime_required_for_valid_input")
97
157
 
98
158
  raw, profile, works, missing = collect_and_adapt(
99
- input_value=input_value,
159
+ input_value=normalized_input_value or input_value,
100
160
  base_url=runtime["base_url"],
101
161
  token=runtime["token"],
102
162
  timeout_ms=runtime["timeout_ms"],
103
163
  page_size=20,
104
164
  pages_max=50,
105
- max_items=max(1, int(max_items)),
165
+ max_items=bounded_max_items,
106
166
  progress=progress.child(scope="author_home.collect"),
107
167
  )
108
168
 
@@ -138,7 +198,7 @@ def run_douyin_creator_home(
138
198
 
139
199
  request_id = ensure_request_id(
140
200
  raw.get("request_id") or profile.get("request_id"),
141
- fallback_seed=input_value,
201
+ fallback_seed=normalized_input_value or input_value,
142
202
  )
143
203
  extract_trace = list(raw.get("extract_trace") or []) + list(asr_bundle.get("trace") or [])
144
204
 
@@ -206,7 +266,12 @@ def main() -> None:
206
266
  parser.add_argument("--allow-process-env", action="store_true", help="Allow process env overrides")
207
267
  parser.add_argument("--base-url", default=None, help="Override Tikomni base URL")
208
268
  parser.add_argument("--timeout-ms", type=int, default=None, help="Override timeout in ms")
209
- parser.add_argument("--max-items", type=int, default=5, help="Max works to collect from homepage")
269
+ parser.add_argument(
270
+ "--max-items",
271
+ type=int,
272
+ default=DEFAULT_MAX_ITEMS,
273
+ help=f"Max works to collect from homepage (default full crawl, capped at {MAX_ITEMS_HARD_LIMIT})",
274
+ )
210
275
  parser.set_defaults(write_card=True, persist_output=True)
211
276
  parser.add_argument("--write-card", dest="write_card", action="store_true", help="Write work fact cards")
212
277
  parser.add_argument("--no-write-card", dest="write_card", action="store_false", help="Skip card writing")
@@ -215,6 +280,19 @@ def main() -> None:
215
280
  args = parser.parse_args()
216
281
 
217
282
  config, _ = load_tikomni_config(args.config, env_file=args.env_file, allow_process_env=args.allow_process_env)
283
+ preflight = normalize_douyin_creator_input(args.input)
284
+ if preflight.get("error_reason"):
285
+ write_json_stdout(
286
+ run_douyin_creator_home(
287
+ input_value=args.input,
288
+ config=config,
289
+ runtime=None,
290
+ max_items=int(args.max_items),
291
+ write_card=bool(args.write_card),
292
+ persist_output=bool(args.persist_output),
293
+ )
294
+ )
295
+ return
218
296
  runtime = resolve_runtime(
219
297
  env_file=args.env_file,
220
298
  api_key_env=str(config_get(config, "runtime.auth_env_key", "TIKOMNI_API_KEY")),
@@ -224,7 +302,7 @@ def main() -> None:
224
302
  )
225
303
  write_json_stdout(
226
304
  run_douyin_creator_home(
227
- input_value=args.input,
305
+ input_value=str(preflight.get("input_value") or args.input),
228
306
  config=config,
229
307
  runtime=runtime,
230
308
  max_items=int(args.max_items),
@@ -31,6 +31,13 @@ from scripts.core.config_loader import config_get, load_tikomni_config
31
31
  from scripts.core.extract_pipeline import resolve_trace_error_context
32
32
  from scripts.core.progress_report import ProgressReporter, build_progress_reporter
33
33
  from scripts.pipelines.douyin_video_type_matrix import normalize_douyin_video_type
34
+ from scripts.pipelines.douyin_metadata import (
35
+ extract_douyin_author as extract_shared_douyin_author,
36
+ extract_douyin_caption as extract_shared_douyin_caption,
37
+ extract_douyin_metrics as extract_shared_douyin_metrics,
38
+ extract_douyin_title as extract_shared_douyin_title,
39
+ )
40
+ from scripts.pipelines.input_contracts import normalize_douyin_work_input
34
41
  from scripts.core.asr_pipeline import derive_asr_clean_text, run_u2_asr_with_timeout_retry
35
42
  from scripts.pipelines.select_low_quality_video_url import select_low_quality_video_url
36
43
  from scripts.core.tikomni_common import (
@@ -156,14 +163,8 @@ def _normalize_input(
156
163
  input_value: Optional[str],
157
164
  share_url: Optional[str],
158
165
  ) -> Dict[str, Optional[str]]:
159
- normalized_share = (share_url or "").strip() or None
160
-
161
- if input_value and not normalized_share:
162
- candidate = input_value.strip()
163
- if candidate.startswith("http://") or candidate.startswith("https://"):
164
- normalized_share = candidate
165
-
166
- return {"share_url": normalized_share}
166
+ normalized = normalize_douyin_work_input(input_value, share_url)
167
+ return {"share_url": normalize_text(normalized.get("share_url")) or None}
167
168
 
168
169
 
169
170
  def _extract_aweme_detail(payload: Any) -> Optional[Dict[str, Any]]:
@@ -238,76 +239,19 @@ def _normalize_duration_ms(item: Dict[str, Any]) -> Optional[int]:
238
239
 
239
240
 
240
241
  def _pick_title(item: Dict[str, Any]) -> str:
241
- for key in ("item_title", "title", "desc", "preview_title"):
242
- value = item.get(key)
243
- text = normalize_text(value)
244
- if text:
245
- return text
246
- return ""
242
+ return extract_shared_douyin_title(item)
247
243
 
248
244
 
249
245
  def _pick_desc(item: Dict[str, Any]) -> str:
250
- for key in ("desc", "item_title", "title", "preview_title"):
251
- value = item.get(key)
252
- text = normalize_text(value)
253
- if text:
254
- return text
255
- return ""
246
+ return extract_shared_douyin_caption(item)
256
247
 
257
248
 
258
249
  def _extract_author(item: Dict[str, Any]) -> Dict[str, Optional[str]]:
259
- author = item.get("author")
260
- if not isinstance(author, dict):
261
- author = {}
262
-
263
- author_platform_id = normalize_text(author.get("uid")) or normalize_text(author.get("id")) or normalize_text(item.get("author_user_id"))
264
- author_handle = normalize_text(author.get("short_id")) or normalize_text(author.get("nickname"))
265
- douyin_sec_uid = normalize_text(author.get("sec_uid"))
266
- douyin_aweme_author_id = normalize_text(item.get("author_user_id")) or author_platform_id
267
-
268
- return {
269
- "author_handle": author_handle or None,
270
- "platform_author_id": author_platform_id or None,
271
- "author_platform_id": author_platform_id or None,
272
- "douyin_sec_uid": douyin_sec_uid or None,
273
- "douyin_aweme_author_id": douyin_aweme_author_id or None,
274
- "nickname": normalize_text(author.get("nickname")) or None,
275
- "signature": normalize_text(author.get("signature")) or None,
276
- }
250
+ return extract_shared_douyin_author(item)
277
251
 
278
252
 
279
253
  def _extract_metrics(item: Dict[str, Any]) -> Dict[str, Optional[int]]:
280
- statistics = item.get("statistics")
281
- if not isinstance(statistics, dict):
282
- statistics = {}
283
-
284
- def metric(*keys: str, default: Optional[int] = 0) -> Optional[int]:
285
- for key in keys:
286
- value = _safe_int(statistics.get(key))
287
- if value is not None:
288
- return value
289
- value = _safe_int(item.get(key))
290
- if value is not None:
291
- return value
292
- return default
293
-
294
- metrics = {
295
- "digg_count": metric("digg_count"),
296
- "comment_count": metric("comment_count"),
297
- "collect_count": metric("collect_count"),
298
- "share_count": metric("share_count", "forward_count"),
299
- "play_count": metric("play_count", default=None),
300
- }
301
- play_count = metrics.get("play_count")
302
- engagement_floor = max(
303
- int(metrics.get("digg_count") or 0),
304
- int(metrics.get("comment_count") or 0),
305
- int(metrics.get("collect_count") or 0),
306
- int(metrics.get("share_count") or 0),
307
- )
308
- if play_count is not None and int(play_count) <= 0 and engagement_floor > 0:
309
- metrics["play_count"] = None
310
- return metrics
254
+ return extract_shared_douyin_metrics(item)
311
255
 
312
256
 
313
257
  def _extract_platform_work_id(item: Dict[str, Any]) -> Optional[str]:
@@ -633,6 +577,7 @@ def _build_result(
633
577
  cover_image: Optional[str] = None,
634
578
  asr_source: str = "fallback_none",
635
579
  timings: Optional[Dict[str, int]] = None,
580
+ missing_fields: Optional[List[Dict[str, str]]] = None,
636
581
  ) -> Dict[str, Any]:
637
582
  summary_block = summarize_content(raw_content, source="douyin:single-video-low-quality")
638
583
  insights = list(summary_block.get("insights", []))
@@ -698,13 +643,13 @@ def _build_result(
698
643
  "insights": insights,
699
644
  "confidence": confidence,
700
645
  "error_reason": error_reason,
701
- "missing_fields": _build_missing_fields(
646
+ "missing_fields": list(missing_fields or _build_missing_fields(
702
647
  title=title,
703
648
  desc=desc,
704
649
  platform_work_id=platform_work_id,
705
650
  video_down_url=video_down_url,
706
651
  author=author,
707
- ),
652
+ )),
708
653
  "extract_trace": extract_trace,
709
654
  "fallback_trace": fallback_trace,
710
655
  "request_id": request_id,
@@ -741,7 +686,8 @@ def run_douyin_single_video(
741
686
  workflow_started_at = time.perf_counter()
742
687
  timings = _empty_timings()
743
688
  parse_started_at = time.perf_counter()
744
- source_input = _normalize_input(input_value, share_url)
689
+ preflight = normalize_douyin_work_input(input_value, share_url)
690
+ source_input = {"share_url": normalize_text(preflight.get("share_url")) or None}
745
691
  timings["url_parse_ms"] = _elapsed_ms(parse_started_at)
746
692
  if progress is not None:
747
693
  progress.started(
@@ -749,6 +695,66 @@ def run_douyin_single_video(
749
695
  message="douyin single_video workflow started",
750
696
  data={"analysis_mode": analysis_mode, "write_card": bool(write_card), "persist_output": bool(persist_output)},
751
697
  )
698
+ preflight_trace = [
699
+ {
700
+ "step": "input.preflight",
701
+ "ok": preflight.get("error_reason") is None,
702
+ "input_kind": "share_url",
703
+ "normalized_share_url": source_input.get("share_url"),
704
+ "error_reason": preflight.get("error_reason"),
705
+ "missing_fields": list(preflight.get("missing_fields") or []),
706
+ }
707
+ ]
708
+ if preflight.get("error_reason"):
709
+ result = _build_result(
710
+ source_input=source_input,
711
+ platform_work_id=None,
712
+ title="",
713
+ desc="",
714
+ duration_ms=None,
715
+ video_down_url=None,
716
+ author={"author_handle": None, "author_platform_id": None, "douyin_sec_uid": None, "douyin_aweme_author_id": None, "nickname": None, "signature": None},
717
+ metrics=_empty_metrics(),
718
+ tags=[],
719
+ is_video=False,
720
+ video_type_reason="invalid_input",
721
+ raw_content="",
722
+ confidence="low",
723
+ error_reason=str(preflight.get("error_reason") or "invalid_share_url"),
724
+ extract_trace=preflight_trace,
725
+ fallback_trace=[],
726
+ request_id=None,
727
+ u2_task_id=None,
728
+ u2_task_status="UNKNOWN",
729
+ u2_gate_reason="invalid_input",
730
+ analysis_mode=analysis_mode,
731
+ timings=timings,
732
+ missing_fields=list(preflight.get("missing_fields") or []),
733
+ )
734
+ if write_card:
735
+ card_started_at = time.perf_counter()
736
+ result["card_write"] = write_work_fact_card(
737
+ payload=result,
738
+ platform="douyin",
739
+ card_type=card_type,
740
+ card_root=card_root,
741
+ content_kind=content_kind,
742
+ storage_config=storage_config,
743
+ analysis_mode=analysis_mode,
744
+ progress=progress.child(scope="card_write") if progress is not None else None,
745
+ )
746
+ timings["card_write_ms"] = _elapsed_ms(card_started_at)
747
+ timings["llm_analysis_ms"] = _safe_int((result.get("card_write") or {}).get("llm_analysis_ms"))
748
+ timings["total_ms"] = _elapsed_ms(workflow_started_at)
749
+ result["timings"] = dict(timings)
750
+ _update_pipeline_status(result)
751
+ return _finalize_result(
752
+ result=result,
753
+ source_input=source_input,
754
+ platform_work_id=None,
755
+ storage_config=storage_config,
756
+ persist_output=persist_output,
757
+ )
752
758
  if not source_input.get("share_url"):
753
759
  result = _build_result(
754
760
  source_input=source_input,
@@ -765,7 +771,7 @@ def run_douyin_single_video(
765
771
  raw_content="",
766
772
  confidence="low",
767
773
  error_reason="missing_share_url",
768
- extract_trace=[],
774
+ extract_trace=preflight_trace,
769
775
  fallback_trace=[],
770
776
  request_id=None,
771
777
  u2_task_id=None,
@@ -28,9 +28,14 @@ from scripts.core.progress_report import build_progress_reporter
28
28
  from scripts.core.storage_router import resolve_author_directory_name
29
29
  from scripts.core.tikomni_common import resolve_runtime, write_json_stdout
30
30
  from scripts.pipelines.home_asr import enrich_author_home_asr
31
+ from scripts.pipelines.input_contracts import normalize_xhs_creator_input
32
+ from scripts.pipelines.schema import build_author_profile
31
33
  from scripts.pipelines.xiaohongshu_creator_home_helpers import collect_and_adapt
32
34
  from scripts.writers.write_work_fact_card import build_work_fact_card, persist_output_envelope, write_work_fact_card
33
35
 
36
+ DEFAULT_MAX_ITEMS = 200
37
+ MAX_ITEMS_HARD_LIMIT = 200
38
+
34
39
 
35
40
  def _write_collection_artifacts(
36
41
  *,
@@ -81,11 +86,12 @@ def run_xiaohongshu_creator_home(
81
86
  *,
82
87
  input_value: str,
83
88
  config: Dict[str, Any],
84
- runtime: Dict[str, Any],
89
+ runtime: Dict[str, Any] | None,
85
90
  max_items: int,
86
91
  write_card: bool,
87
92
  persist_output: bool,
88
93
  ) -> Dict[str, Any]:
94
+ bounded_max_items = max(1, min(int(max_items), MAX_ITEMS_HARD_LIMIT))
89
95
  progress = build_progress_reporter(
90
96
  workflow="social-media-crawl",
91
97
  platform="xiaohongshu",
@@ -94,15 +100,69 @@ def run_xiaohongshu_creator_home(
94
100
  scope="workflow",
95
101
  )
96
102
  progress.started(stage="author_home.workflow", message="xiaohongshu author_home workflow started")
103
+ preflight = normalize_xhs_creator_input(input_value)
104
+ normalized_input_value = str(preflight.get("input_value") or "")
105
+ if preflight.get("error_reason"):
106
+ request_id = ensure_request_id(None, fallback_seed=input_value)
107
+ empty_profile = build_author_profile(platform="xiaohongshu", request_id=request_id)
108
+ extract_trace = [
109
+ {
110
+ "step": "input.preflight",
111
+ "ok": False,
112
+ "input_kind": "creator_url_or_user_id",
113
+ "normalized_input_value": normalized_input_value or None,
114
+ "error_reason": preflight.get("error_reason"),
115
+ "missing_fields": list(preflight.get("missing_fields") or []),
116
+ }
117
+ ]
118
+ envelope = {
119
+ "object_type": "creator",
120
+ "platform": "xiaohongshu",
121
+ "input": input_value,
122
+ "normalized": {
123
+ "creator_profile": {**empty_profile, "request_id": request_id, "extract_trace": extract_trace},
124
+ "work_collection": {
125
+ "platform": "xiaohongshu",
126
+ "platform_author_id": "",
127
+ "count": 0,
128
+ "items": [],
129
+ "request_id": request_id,
130
+ "extract_trace": extract_trace,
131
+ },
132
+ },
133
+ "completeness": evaluate_collection(empty_profile, []),
134
+ "missing_fields": normalize_missing_fields(preflight.get("missing_fields")),
135
+ "error_reason": str(preflight.get("error_reason") or "invalid_creator_input"),
136
+ "extract_trace": extract_trace,
137
+ "request_id": request_id,
138
+ "card_write": {
139
+ "enabled": bool(write_card),
140
+ "ok": False,
141
+ "count": 0,
142
+ "results": [],
143
+ "reason": "skipped_invalid_input",
144
+ },
145
+ "collection_artifacts": {},
146
+ "output_persist": {"enabled": False, "skipped": True, "reason": "invalid_input"},
147
+ }
148
+ progress.done(
149
+ stage="author_home.workflow",
150
+ message="xiaohongshu author_home workflow finished",
151
+ data={"request_id": request_id, "works_count": 0, "error_reason": envelope["error_reason"]},
152
+ )
153
+ return envelope
154
+
155
+ if runtime is None:
156
+ raise ValueError("runtime_required_for_valid_input")
97
157
 
98
158
  raw, profile, works, missing = collect_and_adapt(
99
- input_value=input_value,
159
+ input_value=normalized_input_value or input_value,
100
160
  base_url=runtime["base_url"],
101
161
  token=runtime["token"],
102
162
  timeout_ms=runtime["timeout_ms"],
103
163
  page_size=20,
104
164
  pages_max=50,
105
- max_items=max(1, int(max_items)),
165
+ max_items=bounded_max_items,
106
166
  progress=progress.child(scope="author_home.collect"),
107
167
  )
108
168
 
@@ -138,7 +198,7 @@ def run_xiaohongshu_creator_home(
138
198
 
139
199
  request_id = ensure_request_id(
140
200
  raw.get("request_id") or profile.get("request_id"),
141
- fallback_seed=input_value,
201
+ fallback_seed=normalized_input_value or input_value,
142
202
  )
143
203
  extract_trace = list(raw.get("extract_trace") or []) + list(asr_bundle.get("trace") or [])
144
204
 
@@ -206,7 +266,12 @@ def main() -> None:
206
266
  parser.add_argument("--allow-process-env", action="store_true", help="Allow process env overrides")
207
267
  parser.add_argument("--base-url", default=None, help="Override Tikomni base URL")
208
268
  parser.add_argument("--timeout-ms", type=int, default=None, help="Override timeout in ms")
209
- parser.add_argument("--max-items", type=int, default=5, help="Max works to collect from homepage")
269
+ parser.add_argument(
270
+ "--max-items",
271
+ type=int,
272
+ default=DEFAULT_MAX_ITEMS,
273
+ help=f"Max works to collect from homepage (default full crawl, capped at {MAX_ITEMS_HARD_LIMIT})",
274
+ )
210
275
  parser.set_defaults(write_card=True, persist_output=True)
211
276
  parser.add_argument("--write-card", dest="write_card", action="store_true", help="Write work fact cards")
212
277
  parser.add_argument("--no-write-card", dest="write_card", action="store_false", help="Skip card writing")
@@ -215,6 +280,19 @@ def main() -> None:
215
280
  args = parser.parse_args()
216
281
 
217
282
  config, _ = load_tikomni_config(args.config, env_file=args.env_file, allow_process_env=args.allow_process_env)
283
+ preflight = normalize_xhs_creator_input(args.input)
284
+ if preflight.get("error_reason"):
285
+ write_json_stdout(
286
+ run_xiaohongshu_creator_home(
287
+ input_value=args.input,
288
+ config=config,
289
+ runtime=None,
290
+ max_items=int(args.max_items),
291
+ write_card=bool(args.write_card),
292
+ persist_output=bool(args.persist_output),
293
+ )
294
+ )
295
+ return
218
296
  runtime = resolve_runtime(
219
297
  env_file=args.env_file,
220
298
  api_key_env=str(config_get(config, "runtime.auth_env_key", "TIKOMNI_API_KEY")),
@@ -224,7 +302,7 @@ def main() -> None:
224
302
  )
225
303
  write_json_stdout(
226
304
  run_xiaohongshu_creator_home(
227
- input_value=args.input,
305
+ input_value=str(preflight.get("input_value") or args.input),
228
306
  config=config,
229
307
  runtime=runtime,
230
308
  max_items=int(args.max_items),