tools.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. """
  2. 内容工具族 —— 统一入口
  3. 4 个 @tool 注册给 LLM:
  4. - content_platforms: 列出/查询平台及其参数
  5. - content_search: 跨平台搜索
  6. - content_detail: 查看详情
  7. - content_suggest: 搜索建议词
  8. 所有平台的具体实现在 platforms/ 子目录,按模块自注册到 registry。
  9. """
  10. import json
  11. import os
  12. import uuid
  13. from typing import Any, Dict, Optional
  14. from cyber_agent.tools import tool, ToolResult, ToolContext
  15. from cyber_agent.tools.builtin.content.registry import (
  16. all_platforms, get_platform, match_platforms,
  17. )
  18. from cyber_agent.tools.builtin.content import cache as _cache
  19. # 导入平台模块以触发自注册(副作用导入)
  20. import cyber_agent.tools.builtin.content.platforms.aigc_channel # noqa: F401
  21. import cyber_agent.tools.builtin.content.platforms.youtube # noqa: F401
  22. import cyber_agent.tools.builtin.content.platforms.x # noqa: F401
  23. def _get_trace_id(context: Optional[Dict[str, Any]]) -> str:
  24. """从 context 取 trace_id,回退到环境变量或自动生成"""
  25. if isinstance(context, dict) and context.get("trace_id"):
  26. return context["trace_id"]
  27. if context and hasattr(context, "trace_id") and context.trace_id:
  28. return context.trace_id
  29. return os.getenv("TRACE_ID") or f"anon-{uuid.uuid4().hex[:8]}"
  30. def _convert_post_timestamps(post: dict) -> None:
  31. """将 post 中的时间戳字段替换为可读格式"""
  32. from datetime import datetime
  33. for field in ("publish_timestamp", "modify_timestamp", "update_timestamp"):
  34. ts = post.get(field)
  35. if not ts or ts == 0:
  36. continue
  37. try:
  38. ts = int(ts)
  39. if ts > 1000000000000:
  40. ts = ts / 1000
  41. post[field] = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
  42. except Exception:
  43. pass
  44. # ── content_platforms ──
  45. @tool(hidden_params=["context"], groups=["content"])
  46. async def content_platforms(
  47. platform: str = "",
  48. context: Optional[ToolContext] = None,
  49. ) -> ToolResult:
  50. """
  51. 列出支持的内容平台及其搜索参数。
  52. 不传 platform 时返回所有平台的概要列表(仅名称和 ID)。
  53. 传入 platform 时模糊匹配并返回匹配平台的详细参数说明(支持 ID、中文名、别名)。
  54. 建议在不熟悉平台参数时先调用此工具查看,再构造 content_search / content_detail 的参数。
  55. Args:
  56. platform: 可选,平台名称或关键词。支持模糊匹配(如 "xhs"、"小红书"、"youtube")。
  57. 留空返回全部平台概要。
  58. context: 工具上下文(自动注入)
  59. """
  60. hits = match_platforms(platform)
  61. if not hits:
  62. all_ids = [p.id for p in all_platforms()]
  63. return ToolResult(
  64. title="未找到匹配平台",
  65. output=f"没有匹配 '{platform}' 的平台。可用平台: {', '.join(all_ids)}",
  66. )
  67. if platform:
  68. # 有 query:返回匹配平台的详细参数
  69. result = [p.detail() for p in hits]
  70. else:
  71. # 无 query:返回概要列表
  72. result = [p.summary() for p in hits]
  73. return ToolResult(
  74. title=f"内容平台" + (f" ({platform})" if platform else ""),
  75. output=json.dumps(result, ensure_ascii=False, indent=2),
  76. )
  77. # ── content_search ──
  78. @tool(hidden_params=["context"], groups=["content"])
  79. async def content_search(
  80. platform: str,
  81. keyword: str,
  82. max_count: int = 20,
  83. cursor: str = "",
  84. extras: Optional[Dict[str, Any]] = None,
  85. context: Optional[ToolContext] = None,
  86. ) -> ToolResult:
  87. """
  88. 跨平台内容搜索,返回带索引编号的封面拼图 + 概览列表。
  89. 返回的是摘要信息(标题 + 正文截断 + 互动数据),不含完整正文和所有图片。
  90. 如需查看某条内容的完整信息,请使用 content_detail。
  91. Args:
  92. platform: 平台标识,如 'xhs'、'youtube'、'x'。完整列表见 content_platforms。
  93. keyword: 搜索关键词。
  94. max_count: 返回条数上限,默认 20。
  95. cursor: 分页游标,首次搜索留空,翻页时传入上次返回值。
  96. extras: 平台专用参数(dict)。不同平台支持不同参数,
  97. 如 xhs 支持 sort_type / publish_time / content_type / filter_note_range。
  98. 不清楚可先调 content_platforms(platform) 查看。
  99. context: 工具上下文(自动注入)
  100. """
  101. pdef = get_platform(platform)
  102. if not pdef:
  103. # 尝试模糊匹配
  104. hits = match_platforms(platform)
  105. if hits:
  106. suggestions = ", ".join(f"{p.id}({p.name})" for p in hits[:3])
  107. return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。你是否想要: {suggestions}")
  108. all_ids = [p.id for p in all_platforms()]
  109. return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'。可用: {', '.join(all_ids)}")
  110. if not pdef.search_impl:
  111. return ToolResult(title="不支持搜索", output=f"平台 {pdef.name} 暂不支持搜索")
  112. try:
  113. max_count = int(max_count)
  114. except (ValueError, TypeError):
  115. max_count = 20
  116. result = await pdef.search_impl(
  117. platform_id=pdef.id,
  118. keyword=keyword,
  119. max_count=max_count,
  120. cursor=cursor,
  121. extras=extras,
  122. )
  123. # 持久化搜索结果到磁盘缓存
  124. if not result.error:
  125. posts = result.metadata.pop("posts", [])
  126. trace_id = _get_trace_id(context)
  127. _cache.save_search_results(trace_id, pdef.id, keyword, posts)
  128. return result
  129. # ── content_detail ──
  130. @tool(hidden_params=["context"], groups=["content"])
  131. async def content_detail(
  132. platform: str,
  133. index: int,
  134. extras: Optional[Dict[str, Any]] = None,
  135. context: Optional[ToolContext] = None,
  136. ) -> ToolResult:
  137. """
  138. 查看内容详情。从最近一次 content_search 的结果中按索引取完整记录。
  139. Args:
  140. platform: 平台标识(必须和之前 content_search 用的一致)。
  141. index: 内容序号(1-based),来自 content_search 返回的 index 字段。
  142. extras: 平台专用详情参数。YouTube 支持 include_captions / download_video。
  143. 其他平台通常不需要。
  144. context: 工具上下文(自动注入)
  145. """
  146. pdef = get_platform(platform)
  147. if not pdef:
  148. return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
  149. try:
  150. index = int(index)
  151. except (ValueError, TypeError):
  152. return ToolResult(title="参数错误", output="参数 index 必须是整数", error="Invalid index parameter")
  153. trace_id = _get_trace_id(context)
  154. post = _cache.get_cached_post(trace_id, pdef.id, index)
  155. if not post:
  156. info = _cache.get_cached_search_info(trace_id, pdef.id)
  157. if info:
  158. return ToolResult(
  159. title="索引无效",
  160. output=f"平台 {pdef.name} 上次搜索 '{info['keyword']}' 共 {info['total']} 条,"
  161. f"有效索引 1-{info['total']},你传入了 {index}。",
  162. error="Invalid index",
  163. )
  164. return ToolResult(
  165. title="缓存未命中",
  166. output=f"没有 {pdef.name} 的搜索缓存。请先调用 content_search(platform='{pdef.id}', keyword=...) 搜索。",
  167. error="No cache",
  168. )
  169. # 将时间戳转换为可读格式
  170. _convert_post_timestamps(post)
  171. if pdef.detail_impl:
  172. # 透传 trace_id 给 detail_impl,便于其异步补充数据后回写 cache
  173. # (之前依赖 os.getenv("TRACE_ID"),只在 CLI 路径有效,agent 路径下 silently 失效)
  174. extras = dict(extras or {})
  175. extras["__trace_id__"] = trace_id
  176. return await pdef.detail_impl(post, extras)
  177. # fallback:直接返回缓存的完整数据
  178. return ToolResult(
  179. title=f"详情 #{index}",
  180. output=json.dumps(post, ensure_ascii=False, indent=2),
  181. )
  182. # ── content_suggest ──
  183. @tool(hidden_params=["context"], groups=["content"])
  184. async def content_suggest(
  185. platform: str,
  186. keyword: str,
  187. context: Optional[ToolContext] = None,
  188. ) -> ToolResult:
  189. """
  190. 获取搜索关键词补全建议。
  191. 仅部分平台支持(xhs、toutiao、douyin、bili、zhihu)。
  192. 用于辅助用户发现更精准的搜索词。
  193. Args:
  194. platform: 平台标识。
  195. keyword: 搜索关键词(输入中的部分词即可)。
  196. context: 工具上下文(自动注入)
  197. """
  198. pdef = get_platform(platform)
  199. if not pdef:
  200. return ToolResult(title="平台不存在", output=f"未找到平台 '{platform}'")
  201. if not pdef.suggest_impl:
  202. supported = [p.id for p in all_platforms() if p.supports_suggest]
  203. return ToolResult(
  204. title="不支持建议词",
  205. output=f"平台 {pdef.name} 不支持建议词。支持的平台: {', '.join(supported)}",
  206. )
  207. channel = (pdef.suggest_channels or [pdef.id])[0]
  208. return await pdef.suggest_impl(channel, keyword)
  209. # ── CLI 入口 ──
  210. def _parse_args(argv: list) -> dict:
  211. """解析 --key=value 格式的 CLI 参数"""
  212. kwargs = {}
  213. for arg in argv:
  214. if arg.startswith("--") and "=" in arg:
  215. key, val = arg[2:].split("=", 1)
  216. # 尝试 JSON 解析(dict / int / bool)
  217. try:
  218. val = json.loads(val)
  219. except (json.JSONDecodeError, ValueError):
  220. pass
  221. kwargs[key] = val
  222. return kwargs
  223. def cli_main(argv: Optional[list] = None) -> None:
  224. """CLI 入口:通过 `python -m cyber_agent.tools.builtin.content` 调用(见 __main__.py)"""
  225. import sys
  226. import asyncio
  227. argv = sys.argv if argv is None else argv
  228. commands = {
  229. "platforms": content_platforms,
  230. "search": content_search,
  231. "detail": content_detail,
  232. "suggest": content_suggest,
  233. }
  234. if len(argv) < 2 or argv[1] not in commands:
  235. print(f"Usage: python -m cyber_agent.tools.builtin.content <{'|'.join(commands)}> [--key=value ...]")
  236. sys.exit(1)
  237. cmd = argv[1]
  238. kwargs = _parse_args(argv[2:])
  239. # trace_id:CLI 参数 > 环境变量 > 自动生成
  240. trace_id = kwargs.pop("trace_id", None) or os.getenv("TRACE_ID") or f"cli-{uuid.uuid4().hex[:8]}"
  241. os.environ["TRACE_ID"] = trace_id
  242. result = asyncio.run(commands[cmd](**kwargs))
  243. out = {"trace_id": trace_id, "output": result.output, "error": result.error}
  244. if result.metadata:
  245. out["metadata"] = result.metadata
  246. print(json.dumps(out, ensure_ascii=False, indent=2))