@tikomni/skills 1.0.6 → 1.0.7

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 (23) hide show
  1. package/README.md +3 -3
  2. package/README.zh-CN.md +3 -3
  3. package/env.example +2 -2
  4. package/package.json +2 -2
  5. package/skills/social-media-crawl/SKILL.md +20 -16
  6. package/skills/social-media-crawl/agents/openai.yaml +2 -2
  7. package/skills/social-media-crawl/references/api-catalog/aliases.json +237 -0
  8. package/skills/social-media-crawl/references/api-catalog/capabilities.json +362 -0
  9. package/skills/social-media-crawl/references/api-catalog/metadata.json +11 -0
  10. package/skills/social-media-crawl/references/api-catalog/operations.json +100875 -0
  11. package/skills/social-media-crawl/references/api-catalog/overrides.json +10 -0
  12. package/skills/social-media-crawl/references/api-catalog/platforms.json +463 -0
  13. package/skills/social-media-crawl/references/api-routing-contract.md +73 -0
  14. package/skills/social-media-crawl/references/contracts/output-envelope.md +1 -1
  15. package/skills/social-media-crawl/scripts/core/api_catalog.py +104 -0
  16. package/skills/social-media-crawl/scripts/core/call_tikomni_api.py +269 -0
  17. package/skills/social-media-crawl/scripts/core/config_loader.py +0 -2
  18. package/skills/social-media-crawl/scripts/core/resolve_api_endpoint.py +176 -0
  19. package/skills/social-media-crawl/scripts/run_task.py +96 -0
  20. package/skills/social-media-crawl/tests/test_api_only_routing.py +130 -0
  21. package/skills/social-media-crawl/references/guides/generic-mcp-objects.md +0 -40
  22. package/skills/social-media-crawl/references/mcp-usage-contract.md +0 -40
  23. package/skills/social-media-crawl/scripts/core/mcp_dispatch.py +0 -161
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env python3
2
+ """Call TikOmni API by catalog endpoint_id."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+ import urllib.parse
12
+ from typing import Any, Dict, List, Optional, Tuple
13
+
14
+ SKILL_ROOT = Path(__file__).resolve().parents[2]
15
+ if str(SKILL_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(SKILL_ROOT))
17
+
18
+ from scripts.core.api_catalog import load_operations, operation_by_endpoint_id
19
+ from scripts.core.tikomni_common import call_json_api, resolve_runtime
20
+
21
+ RESERVED_PARAM_KEYS = {"path", "method"}
22
+
23
+
24
+ def _json_stdout(payload: Dict[str, Any]) -> None:
25
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
26
+
27
+
28
+ def _parse_params(raw_params: str) -> Dict[str, Any]:
29
+ if not raw_params:
30
+ return {}
31
+ payload = json.loads(raw_params)
32
+ if not isinstance(payload, dict):
33
+ raise ValueError("params_must_be_json_object")
34
+ return payload
35
+
36
+
37
+ def _schema_type(schema: Dict[str, Any]) -> str:
38
+ raw_type = schema.get("type")
39
+ if isinstance(raw_type, list):
40
+ return "|".join(str(item) for item in raw_type)
41
+ return str(raw_type or "")
42
+
43
+
44
+ def _validate_type(name: str, value: Any, schema: Dict[str, Any]) -> Optional[str]:
45
+ schema_type = _schema_type(schema)
46
+ if not schema_type:
47
+ return None
48
+ allowed = set(schema_type.split("|"))
49
+ if "null" in allowed and value is None:
50
+ return None
51
+ if "string" in allowed and isinstance(value, str):
52
+ return None
53
+ if "integer" in allowed and isinstance(value, int) and not isinstance(value, bool):
54
+ return None
55
+ if "number" in allowed and isinstance(value, (int, float)) and not isinstance(value, bool):
56
+ return None
57
+ if "boolean" in allowed and isinstance(value, bool):
58
+ return None
59
+ if "array" in allowed and isinstance(value, list):
60
+ return None
61
+ if "object" in allowed and isinstance(value, dict):
62
+ return None
63
+ return f"invalid_type:{name}:expected_{schema_type or 'declared'}"
64
+
65
+
66
+ def _parameter_maps(operation: Dict[str, Any]) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]], Dict[str, Dict[str, Any]]]:
67
+ path_params: Dict[str, Dict[str, Any]] = {}
68
+ query_params: Dict[str, Dict[str, Any]] = {}
69
+ header_params: Dict[str, Dict[str, Any]] = {}
70
+ for param in operation.get("parameters") or []:
71
+ if not isinstance(param, dict):
72
+ continue
73
+ name = str(param.get("name") or "")
74
+ location = str(param.get("in") or "query")
75
+ if not name:
76
+ continue
77
+ if location == "path":
78
+ path_params[name] = param
79
+ elif location == "header":
80
+ header_params[name] = param
81
+ else:
82
+ query_params[name] = param
83
+ return path_params, query_params, header_params
84
+
85
+
86
+ def _body_properties(operation: Dict[str, Any]) -> Tuple[Dict[str, Any], bool, bool]:
87
+ body = operation.get("request_body") if isinstance(operation.get("request_body"), dict) else None
88
+ if not body:
89
+ return {}, False, False
90
+ schema = body.get("schema") if isinstance(body.get("schema"), dict) else {}
91
+ props = schema.get("properties") if isinstance(schema.get("properties"), dict) else {}
92
+ return props, bool(body.get("required")), True
93
+
94
+
95
+ def _render_path(path: str, params: Dict[str, Any], path_params: Dict[str, Dict[str, Any]]) -> Tuple[str, List[str]]:
96
+ missing: List[str] = []
97
+
98
+ def replace(match: re.Match[str]) -> str:
99
+ name = match.group(1)
100
+ if name not in params or params.get(name) in (None, ""):
101
+ missing.append(name)
102
+ return match.group(0)
103
+ return urllib.parse.quote(str(params[name]), safe="")
104
+
105
+ rendered = re.sub(r"\{([^}]+)\}", replace, path)
106
+ for name, param in path_params.items():
107
+ if param.get("required") and (name not in params or params.get(name) in (None, "")):
108
+ missing.append(name)
109
+ return rendered, sorted(set(missing))
110
+
111
+
112
+ def build_request(operation: Dict[str, Any], params: Dict[str, Any]) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
113
+ input_schema = operation.get("input_schema") if isinstance(operation.get("input_schema"), dict) else {}
114
+ properties = input_schema.get("properties") if isinstance(input_schema.get("properties"), dict) else {}
115
+ reserved = sorted(key for key in params.keys() if key in RESERVED_PARAM_KEYS and key not in properties)
116
+ if reserved:
117
+ return None, {"code": "RESERVED_PARAM_KEY", "message": f"Reserved API routing keys are not allowed in params: {', '.join(reserved)}"}
118
+
119
+ required = set(str(item) for item in input_schema.get("required") or [])
120
+ missing_required = sorted(name for name in required if name not in params or params.get(name) in (None, ""))
121
+ if missing_required:
122
+ return None, {"code": "MISSING_REQUIRED_PARAMS", "message": f"Missing required params: {', '.join(missing_required)}"}
123
+
124
+ unknown = sorted(key for key in params.keys() if key not in properties)
125
+ if unknown:
126
+ return None, {"code": "UNKNOWN_PARAMS", "message": f"Unknown params for endpoint_id={operation.get('endpoint_id')}: {', '.join(unknown)}"}
127
+
128
+ for name, value in params.items():
129
+ schema = properties.get(name) if isinstance(properties.get(name), dict) else {}
130
+ error = _validate_type(name, value, schema)
131
+ if error:
132
+ return None, {"code": "INVALID_PARAM_TYPE", "message": error}
133
+
134
+ path_params, query_params, header_params = _parameter_maps(operation)
135
+ path, missing_path = _render_path(str(operation.get("path") or ""), params, path_params)
136
+ if missing_path:
137
+ return None, {"code": "MISSING_PATH_PARAMS", "message": f"Missing path params: {', '.join(missing_path)}"}
138
+
139
+ query = {name: params[name] for name in query_params.keys() if name in params}
140
+ headers = {name: str(params[name]) for name in header_params.keys() if name in params}
141
+ body_props, body_required, has_body = _body_properties(operation)
142
+ body: Optional[Dict[str, Any]] = None
143
+ if has_body:
144
+ if body_props:
145
+ body = {name: params[name] for name in body_props.keys() if name in params}
146
+ elif "body" in params:
147
+ raw_body = params["body"]
148
+ if not isinstance(raw_body, dict):
149
+ return None, {"code": "INVALID_PARAM_TYPE", "message": "invalid_type:body:expected_object"}
150
+ body = raw_body
151
+ elif body_required:
152
+ return None, {"code": "MISSING_REQUIRED_PARAMS", "message": "Missing required params: body"}
153
+ else:
154
+ body = {}
155
+
156
+ return {
157
+ "method": str(operation.get("method") or "GET"),
158
+ "path": path,
159
+ "query": query,
160
+ "headers": headers,
161
+ "body": body,
162
+ }, None
163
+
164
+
165
+ def call_endpoint(
166
+ *,
167
+ endpoint_id: str,
168
+ params: Dict[str, Any],
169
+ env_file: Optional[str] = None,
170
+ base_url: Optional[str] = None,
171
+ timeout_ms: Optional[int] = None,
172
+ allow_process_env: bool = False,
173
+ dry_run: bool = False,
174
+ ) -> Dict[str, Any]:
175
+ operations = load_operations()
176
+ operation = operation_by_endpoint_id(operations, endpoint_id)
177
+ if not operation:
178
+ return {
179
+ "ok": False,
180
+ "code": "UNKNOWN_ENDPOINT_ID",
181
+ "endpoint_id": endpoint_id,
182
+ "error_reason": f"endpoint_id_not_found:{endpoint_id}",
183
+ }
184
+
185
+ request_plan, validation_error = build_request(operation, params)
186
+ if validation_error:
187
+ return {
188
+ "ok": False,
189
+ "code": validation_error["code"],
190
+ "endpoint_id": endpoint_id,
191
+ "platform": operation.get("platform"),
192
+ "capability": operation.get("capability"),
193
+ "variant": operation.get("variant"),
194
+ "error_reason": validation_error["message"],
195
+ "alternatives": operation.get("alternatives") or [],
196
+ }
197
+
198
+ if dry_run:
199
+ return {
200
+ "ok": True,
201
+ "dry_run": True,
202
+ "endpoint_id": endpoint_id,
203
+ "platform": operation.get("platform"),
204
+ "capability": operation.get("capability"),
205
+ "variant": operation.get("variant"),
206
+ "alternatives": operation.get("alternatives") or [],
207
+ "request": request_plan,
208
+ }
209
+
210
+ runtime = resolve_runtime(
211
+ env_file=env_file,
212
+ api_key_env="TIKOMNI_API_KEY",
213
+ base_url=base_url,
214
+ timeout_ms=timeout_ms,
215
+ allow_process_env=allow_process_env,
216
+ )
217
+ response = call_json_api(
218
+ base_url=str(runtime["base_url"]),
219
+ path=str(request_plan["path"]),
220
+ token=str(runtime["token"]),
221
+ method=str(request_plan["method"]),
222
+ timeout_ms=int(runtime["timeout_ms"]),
223
+ params=request_plan.get("query") if isinstance(request_plan.get("query"), dict) else None,
224
+ body=request_plan.get("body") if isinstance(request_plan.get("body"), dict) else None,
225
+ extra_headers=request_plan.get("headers") if isinstance(request_plan.get("headers"), dict) else None,
226
+ )
227
+ return {
228
+ "ok": bool(response.get("ok")),
229
+ "status_code": response.get("status_code"),
230
+ "request_id": response.get("request_id"),
231
+ "endpoint_id": endpoint_id,
232
+ "platform": operation.get("platform"),
233
+ "capability": operation.get("capability"),
234
+ "variant": operation.get("variant"),
235
+ "alternatives": operation.get("alternatives") or [],
236
+ "error_reason": response.get("error_reason"),
237
+ "data": response.get("data") if response.get("ok") else {},
238
+ }
239
+
240
+
241
+ def main(argv: Optional[List[str]] = None) -> int:
242
+ parser = argparse.ArgumentParser(description="Call TikOmni API by endpoint_id.")
243
+ parser.add_argument("--endpoint-id", required=True)
244
+ parser.add_argument("--params", default="{}")
245
+ parser.add_argument("--env-file", default=None)
246
+ parser.add_argument("--base-url", default=None)
247
+ parser.add_argument("--timeout-ms", type=int, default=None)
248
+ parser.add_argument("--allow-process-env", action="store_true")
249
+ parser.add_argument("--dry-run", action="store_true")
250
+ args = parser.parse_args(argv)
251
+ try:
252
+ params = _parse_params(args.params)
253
+ payload = call_endpoint(
254
+ endpoint_id=args.endpoint_id,
255
+ params=params,
256
+ env_file=args.env_file,
257
+ base_url=args.base_url,
258
+ timeout_ms=args.timeout_ms,
259
+ allow_process_env=bool(args.allow_process_env),
260
+ dry_run=bool(args.dry_run),
261
+ )
262
+ except Exception as exc:
263
+ payload = {"ok": False, "code": "CALLER_EXCEPTION", "error_reason": str(exc)}
264
+ _json_stdout(payload)
265
+ return 0 if payload.get("ok") else 2
266
+
267
+
268
+ if __name__ == "__main__":
269
+ sys.exit(main())
@@ -23,7 +23,6 @@ BUILTIN_DEFAULT_CONFIG: Dict[str, Any] = {
23
23
  "profile": "default",
24
24
  "runtime": {
25
25
  "base_url": "https://api.tikomni.com",
26
- "mcp_url": "https://mcp.tikomni.com/mcp",
27
26
  "auth_env_key": "TIKOMNI_API_KEY",
28
27
  "timeout_ms": 60000,
29
28
  "u2_pending_timeout_sec": 60,
@@ -246,7 +245,6 @@ def apply_env_overrides(config: Dict[str, Any], env_values: Optional[Dict[str, s
246
245
 
247
246
  for env_key, config_key in {
248
247
  "TIKOMNI_BASE_URL": "base_url",
249
- "TIKOMNI_MCP_URL": "mcp_url",
250
248
  }.items():
251
249
  value = _env_text(env_key, env_values=env_values)
252
250
  if value is not None:
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env python3
2
+ """Resolve a TikOmni API endpoint_id from platform and capability."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[2]
13
+ if str(SKILL_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from scripts.core.api_catalog import (
17
+ infer_alias_from_text,
18
+ load_aliases,
19
+ load_operations,
20
+ resolve_alias,
21
+ sorted_operations,
22
+ )
23
+
24
+
25
+ def _json_stdout(payload: Dict[str, Any]) -> None:
26
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
27
+
28
+
29
+ def _load_context(raw_json: Optional[str]) -> Dict[str, Any]:
30
+ if not raw_json:
31
+ return {}
32
+ try:
33
+ payload = json.loads(raw_json)
34
+ except Exception:
35
+ return {"_raw": raw_json}
36
+ return payload if isinstance(payload, dict) else {"value": payload}
37
+
38
+
39
+ def _suggestions(platform: str, capability: str, operations: List[Dict[str, Any]]) -> List[Dict[str, str]]:
40
+ suggestions: List[Dict[str, str]] = []
41
+ if platform == "wechat" and capability.startswith("article"):
42
+ suggestions.append({"platform": "wechat_mp", "capability": capability})
43
+ if platform == "wechat" and capability in {"video_detail", "comments", "search", "hot_search"}:
44
+ suggestions.append({"platform": "wechat_channels", "capability": capability})
45
+
46
+ for operation in operations:
47
+ if capability and operation.get("capability") != capability:
48
+ continue
49
+ item = {
50
+ "platform": str(operation.get("platform") or ""),
51
+ "capability": str(operation.get("capability") or ""),
52
+ }
53
+ if item["platform"] and item not in suggestions:
54
+ suggestions.append(item)
55
+ if len(suggestions) >= 8:
56
+ break
57
+ return suggestions
58
+
59
+
60
+ def resolve_endpoint(
61
+ *,
62
+ platform: str = "",
63
+ capability: str = "",
64
+ intent: str = "",
65
+ url: str = "",
66
+ context: Optional[Dict[str, Any]] = None,
67
+ include_demo: bool = False,
68
+ ) -> Dict[str, Any]:
69
+ aliases = load_aliases()
70
+ operations = load_operations()
71
+ context = context or {}
72
+
73
+ platform_aliases = aliases.get("platforms") if isinstance(aliases.get("platforms"), dict) else {}
74
+ capability_aliases = aliases.get("capabilities") if isinstance(aliases.get("capabilities"), dict) else {}
75
+ text = " ".join(
76
+ str(value)
77
+ for value in [
78
+ intent,
79
+ url,
80
+ context.get("intent"),
81
+ context.get("url"),
82
+ context.get("share_url"),
83
+ context.get("input"),
84
+ ]
85
+ if value
86
+ )
87
+
88
+ resolved_platform = resolve_alias(platform, platform_aliases)
89
+ if not resolved_platform:
90
+ resolved_platform = infer_alias_from_text(text, platform_aliases)
91
+
92
+ resolved_capability = resolve_alias(capability, capability_aliases)
93
+ if not resolved_capability:
94
+ resolved_capability = infer_alias_from_text(text, capability_aliases)
95
+
96
+ if not resolved_platform or not resolved_capability:
97
+ return {
98
+ "ok": False,
99
+ "code": "ENDPOINT_NOT_RESOLVED",
100
+ "message": "platform and capability are required for deterministic API endpoint resolution.",
101
+ "platform": resolved_platform,
102
+ "capability": resolved_capability,
103
+ "suggestions": _suggestions(resolved_platform, resolved_capability, operations),
104
+ }
105
+
106
+ candidates = [
107
+ operation
108
+ for operation in operations
109
+ if operation.get("platform") == resolved_platform
110
+ and operation.get("capability") == resolved_capability
111
+ and (include_demo or not operation.get("demo"))
112
+ ]
113
+ if not candidates:
114
+ return {
115
+ "ok": False,
116
+ "code": "ENDPOINT_NOT_RESOLVED",
117
+ "message": (
118
+ "No TikOmni public API endpoint matches "
119
+ f"platform={resolved_platform} capability={resolved_capability}."
120
+ ),
121
+ "platform": resolved_platform,
122
+ "capability": resolved_capability,
123
+ "suggestions": _suggestions(resolved_platform, resolved_capability, operations),
124
+ }
125
+
126
+ ordered = sorted_operations(candidates)
127
+ recommended = ordered[0]
128
+ alternatives = [
129
+ {
130
+ "endpoint_id": str(operation.get("endpoint_id") or ""),
131
+ "variant": str(operation.get("variant") or ""),
132
+ "method": str(operation.get("method") or ""),
133
+ "path": str(operation.get("path") or ""),
134
+ "summary": str(operation.get("summary") or ""),
135
+ }
136
+ for operation in ordered[1:]
137
+ ]
138
+ return {
139
+ "ok": True,
140
+ "platform": resolved_platform,
141
+ "capability": resolved_capability,
142
+ "recommended": {
143
+ "endpoint_id": str(recommended.get("endpoint_id") or ""),
144
+ "method": str(recommended.get("method") or ""),
145
+ "path": str(recommended.get("path") or ""),
146
+ "variant": str(recommended.get("variant") or ""),
147
+ "summary": str(recommended.get("summary") or ""),
148
+ },
149
+ "alternatives": alternatives,
150
+ "reason": "catalog_recommended_or_variant_priority",
151
+ }
152
+
153
+
154
+ def main(argv: Optional[List[str]] = None) -> int:
155
+ parser = argparse.ArgumentParser(description="Resolve a TikOmni API endpoint_id.")
156
+ parser.add_argument("--platform", default="")
157
+ parser.add_argument("--capability", default="")
158
+ parser.add_argument("--intent", default="")
159
+ parser.add_argument("--url", default="")
160
+ parser.add_argument("--json", default="", help="Optional JSON context.")
161
+ parser.add_argument("--include-demo", action="store_true")
162
+ args = parser.parse_args(argv)
163
+ payload = resolve_endpoint(
164
+ platform=args.platform,
165
+ capability=args.capability,
166
+ intent=args.intent,
167
+ url=args.url,
168
+ context=_load_context(args.json),
169
+ include_demo=bool(args.include_demo),
170
+ )
171
+ _json_stdout(payload)
172
+ return 0 if payload.get("ok") else 2
173
+
174
+
175
+ if __name__ == "__main__":
176
+ sys.exit(main())
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal one-shot TikOmni API task runner."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional
11
+
12
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
13
+ if str(SKILL_ROOT) not in sys.path:
14
+ sys.path.insert(0, str(SKILL_ROOT))
15
+
16
+ from scripts.core.call_tikomni_api import call_endpoint
17
+ from scripts.core.resolve_api_endpoint import resolve_endpoint
18
+
19
+
20
+ def _json_stdout(payload: Dict[str, Any]) -> None:
21
+ print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
22
+
23
+
24
+ def _parse_params(raw_params: str) -> Dict[str, Any]:
25
+ if not raw_params:
26
+ return {}
27
+ payload = json.loads(raw_params)
28
+ if not isinstance(payload, dict):
29
+ raise ValueError("params_must_be_json_object")
30
+ return payload
31
+
32
+
33
+ def run_task(
34
+ *,
35
+ intent: str,
36
+ params: Dict[str, Any],
37
+ platform: str = "",
38
+ capability: str = "",
39
+ dry_run: bool = False,
40
+ allow_process_env: bool = False,
41
+ ) -> Dict[str, Any]:
42
+ resolved = resolve_endpoint(
43
+ platform=platform,
44
+ capability=capability,
45
+ intent=intent,
46
+ context=params,
47
+ )
48
+ if not resolved.get("ok"):
49
+ return resolved
50
+
51
+ recommended = resolved.get("recommended") if isinstance(resolved.get("recommended"), dict) else {}
52
+ endpoint_id = str(recommended.get("endpoint_id") or "")
53
+ if not endpoint_id:
54
+ return {
55
+ "ok": False,
56
+ "code": "ENDPOINT_NOT_RESOLVED",
57
+ "error_reason": "resolver_returned_empty_endpoint_id",
58
+ "resolution": resolved,
59
+ }
60
+
61
+ response = call_endpoint(
62
+ endpoint_id=endpoint_id,
63
+ params=params,
64
+ allow_process_env=allow_process_env,
65
+ dry_run=dry_run,
66
+ )
67
+ response["resolution"] = resolved
68
+ return response
69
+
70
+
71
+ def main(argv: Optional[List[str]] = None) -> int:
72
+ parser = argparse.ArgumentParser(description="Run a deterministic TikOmni API task.")
73
+ parser.add_argument("--intent", default="")
74
+ parser.add_argument("--platform", default="")
75
+ parser.add_argument("--capability", default="")
76
+ parser.add_argument("--params", default="{}")
77
+ parser.add_argument("--dry-run", action="store_true")
78
+ parser.add_argument("--allow-process-env", action="store_true")
79
+ args = parser.parse_args(argv)
80
+ try:
81
+ payload = run_task(
82
+ intent=args.intent,
83
+ params=_parse_params(args.params),
84
+ platform=args.platform,
85
+ capability=args.capability,
86
+ dry_run=bool(args.dry_run),
87
+ allow_process_env=bool(args.allow_process_env),
88
+ )
89
+ except Exception as exc:
90
+ payload = {"ok": False, "code": "RUNNER_EXCEPTION", "error_reason": str(exc)}
91
+ _json_stdout(payload)
92
+ return 0 if payload.get("ok") else 2
93
+
94
+
95
+ if __name__ == "__main__":
96
+ sys.exit(main())
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import unittest
8
+ from pathlib import Path
9
+
10
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
11
+ if str(SKILL_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(SKILL_ROOT))
13
+
14
+ from scripts.core.call_tikomni_api import call_endpoint
15
+ from scripts.core.resolve_api_endpoint import resolve_endpoint
16
+
17
+
18
+ class ApiOnlyRoutingTest(unittest.TestCase):
19
+ @classmethod
20
+ def setUpClass(cls) -> None:
21
+ cls.operations = json.loads(
22
+ (SKILL_ROOT / "references" / "api-catalog" / "operations.json").read_text(encoding="utf-8")
23
+ )
24
+
25
+ def test_error_report_paths_are_not_in_catalog(self) -> None:
26
+ paths = {str(operation["path"]) for operation in self.operations}
27
+ wrong_paths = {
28
+ "/api/u1/v1/health",
29
+ "/api/u1/v1/douyin/hot_search_list",
30
+ "/api/u1/v1/Douyin/douyin_hot_search_list",
31
+ "/api/u1/v1/wechat/article",
32
+ "/api/u1/v1/wechat/fetch_article",
33
+ "/api/u1/v1/account/info",
34
+ "/api/u1/v1/quote/list",
35
+ "/api/u1/v1/demo/wechat/article",
36
+ }
37
+ self.assertTrue(wrong_paths.isdisjoint(paths))
38
+ self.assertIn("/api/u1/v1/demo/douyin/web/fetch_one_video", paths)
39
+
40
+ def test_douyin_hot_search_resolves_real_endpoint(self) -> None:
41
+ resolved = resolve_endpoint(platform="douyin", capability="hot_search")
42
+ self.assertTrue(resolved["ok"])
43
+ self.assertEqual(
44
+ resolved["recommended"]["endpoint_id"],
45
+ "douyin.hot_search.app_v3.fetch_hot_search_list",
46
+ )
47
+ self.assertEqual(
48
+ resolved["recommended"]["path"],
49
+ "/api/u1/v1/douyin/app/v3/fetch_hot_search_list",
50
+ )
51
+ self.assertTrue(resolved["alternatives"])
52
+
53
+ def test_wechat_mp_and_channels_are_split(self) -> None:
54
+ mp = resolve_endpoint(platform="wechat_mp", capability="article_detail")
55
+ self.assertTrue(mp["ok"])
56
+ self.assertEqual(mp["platform"], "wechat_mp")
57
+ self.assertEqual(mp["recommended"]["path"], "/api/u1/v1/wechat_mp/web/fetch_mp_article_detail_json")
58
+
59
+ channels = resolve_endpoint(platform="wechat_channels", capability="video_detail")
60
+ self.assertTrue(channels["ok"])
61
+ self.assertEqual(channels["platform"], "wechat_channels")
62
+ self.assertEqual(channels["recommended"]["path"], "/api/u1/v1/wechat_channels/fetch_video_detail")
63
+
64
+ def test_ambiguous_wechat_returns_suggestions_not_guess(self) -> None:
65
+ resolved = resolve_endpoint(platform="wechat", capability="article_detail")
66
+ self.assertFalse(resolved["ok"])
67
+ self.assertEqual(resolved["code"], "ENDPOINT_NOT_RESOLVED")
68
+ self.assertIn({"platform": "wechat_mp", "capability": "article_detail"}, resolved["suggestions"])
69
+
70
+ def test_caller_rejects_arbitrary_routing_params(self) -> None:
71
+ result = call_endpoint(
72
+ endpoint_id="douyin.hot_search.app_v3.fetch_hot_search_list",
73
+ params={"path": "/api/u1/v1/douyin/hot_search_list"},
74
+ dry_run=True,
75
+ )
76
+ self.assertFalse(result["ok"])
77
+ self.assertEqual(result["code"], "RESERVED_PARAM_KEY")
78
+
79
+ result = call_endpoint(
80
+ endpoint_id="douyin.hot_search.app_v3.fetch_hot_search_list",
81
+ params={"method": "POST"},
82
+ dry_run=True,
83
+ )
84
+ self.assertFalse(result["ok"])
85
+ self.assertEqual(result["code"], "RESERVED_PARAM_KEY")
86
+
87
+ def test_caller_allows_declared_path_payload_field(self) -> None:
88
+ result = call_endpoint(
89
+ endpoint_id="xiaohongshu.search.web.sign",
90
+ params={
91
+ "path": "/api/sns/web/v1/search/notes",
92
+ "cookie": "web_session=test",
93
+ "data": {"keyword": "test"},
94
+ },
95
+ dry_run=True,
96
+ )
97
+ self.assertTrue(result["ok"])
98
+ self.assertEqual(result["request"]["path"], "/api/u1/v1/xiaohongshu/web/sign")
99
+ self.assertEqual(result["request"]["body"]["path"], "/api/sns/web/v1/search/notes")
100
+
101
+ def test_caller_rejects_unknown_endpoint_id(self) -> None:
102
+ result = call_endpoint(
103
+ endpoint_id="douyin.hot_search.app_v3.not_real",
104
+ params={},
105
+ dry_run=True,
106
+ )
107
+ self.assertFalse(result["ok"])
108
+ self.assertEqual(result["code"], "UNKNOWN_ENDPOINT_ID")
109
+
110
+ def test_u2_u3_stay_in_same_catalog(self) -> None:
111
+ endpoint_ids = {str(operation["endpoint_id"]) for operation in self.operations}
112
+ self.assertIn("u2.transcription.service.transcription", endpoint_ids)
113
+ self.assertIn("u3.media_upload.service.uploads", endpoint_ids)
114
+
115
+ def test_plain_english_intent_does_not_match_single_letter_x_alias(self) -> None:
116
+ resolved = resolve_endpoint(intent="extract comments from this post")
117
+ self.assertFalse(resolved["ok"])
118
+ self.assertEqual(resolved["capability"], "comments")
119
+ self.assertEqual(resolved["platform"], "")
120
+
121
+ def test_search_operations_are_not_classified_as_comments(self) -> None:
122
+ by_id = {str(operation["endpoint_id"]): operation for operation in self.operations}
123
+ for endpoint_id in {
124
+ "xiaohongshu.search.app_v2.search_notes",
125
+ "xiaohongshu.search.app.search_notes",
126
+ "xiaohongshu.search.web.search_notes",
127
+ "xiaohongshu.search.web.search_notes_v3",
128
+ }:
129
+ self.assertIn(endpoint_id, by_id)
130
+ self.assertEqual(by_id[endpoint_id]["capability"], "search")