@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.
- package/README.md +3 -3
- package/README.zh-CN.md +3 -3
- package/env.example +2 -2
- package/package.json +2 -2
- package/skills/social-media-crawl/SKILL.md +20 -16
- package/skills/social-media-crawl/agents/openai.yaml +2 -2
- package/skills/social-media-crawl/references/api-catalog/aliases.json +237 -0
- package/skills/social-media-crawl/references/api-catalog/capabilities.json +362 -0
- package/skills/social-media-crawl/references/api-catalog/metadata.json +11 -0
- package/skills/social-media-crawl/references/api-catalog/operations.json +100875 -0
- package/skills/social-media-crawl/references/api-catalog/overrides.json +10 -0
- package/skills/social-media-crawl/references/api-catalog/platforms.json +463 -0
- package/skills/social-media-crawl/references/api-routing-contract.md +73 -0
- package/skills/social-media-crawl/references/contracts/output-envelope.md +1 -1
- package/skills/social-media-crawl/scripts/core/api_catalog.py +104 -0
- package/skills/social-media-crawl/scripts/core/call_tikomni_api.py +269 -0
- package/skills/social-media-crawl/scripts/core/config_loader.py +0 -2
- package/skills/social-media-crawl/scripts/core/resolve_api_endpoint.py +176 -0
- package/skills/social-media-crawl/scripts/run_task.py +96 -0
- package/skills/social-media-crawl/tests/test_api_only_routing.py +130 -0
- package/skills/social-media-crawl/references/guides/generic-mcp-objects.md +0 -40
- package/skills/social-media-crawl/references/mcp-usage-contract.md +0 -40
- 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")
|