diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 1d89add107536..baf4cb4e86111 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -16,6 +16,13 @@ plugins: # `model.disabled_providers`. Plugin discovery runs before the /model # picker cache prewarm, so the denylist applies before any startup fetch. - apex-overlay + # ApexNodes 平台工具(P1,DESKTOP-CLOUD-CAPABILITY-PARITY-PD):社媒数据/ + # 下载转写/文生视频。默认经平台工具网关(api.apex-nodes.com /tools/v1, + # 云容器用 TOOLS_GATEWAY_BASE 指内网 base;TOOLS_GATEWAY_DISABLED=1 回退 + # 旧 master 直连)。vendor key 不落本机;鉴权用登录后的 Agent API key。 + - apexnodes-douyin-tools + - apexnodes-social-tools + - apexnodes-video-tools # ============================================================================= # Model Configuration diff --git a/plugins/apexnodes-douyin-tools/__init__.py b/plugins/apexnodes-douyin-tools/__init__.py new file mode 100644 index 0000000000000..f3bddcb554f2f --- /dev/null +++ b/plugins/apexnodes-douyin-tools/__init__.py @@ -0,0 +1,770 @@ +"""ApexNodes social-media media tools. + +平台工具网关 P1(DESKTOP-CLOUD-CAPABILITY-PARITY-PD D1/D2):本插件自 +hermes-cloud ``app/runtime_plugins/apexnodes-douyin-tools`` 迁入 fork, +桌面与云端同一份代码。默认走平台工具网关(``plugins/apexnodes_gateway.py``, +vendor key 永不出云);``TOOLS_GATEWAY_DISABLED=1`` 时回退迁移前的 master +内网端点路径(云端 P1 回退通道,P2 删)。 + +hc-254: tool names are platform-neutral (``social_download`` / +``media_transcribe``); the package directory keeps its +``apexnodes-douyin-tools`` name for plugin-registry stability. +""" + +from __future__ import annotations + +import json +import os +import re +import urllib.error +import urllib.request +import uuid +from pathlib import Path +from typing import Any + +from tools.registry import tool_error, tool_result + +try: # 网关客户端(fork 内共用);导入失败时插件退回 legacy 路径,绝不拖垮装载。 + from plugins import apexnodes_gateway as _gateway +except Exception: # pragma: no cover - 仅在裸拷贝部署等异常形态出现 + _gateway = None # type: ignore[assignment] + + +def _use_gateway() -> bool: + return _gateway is not None and _gateway.use_gateway() + + +# ── legacy master 直连路径(迁移前行为,原样保留;TOOLS_GATEWAY_DISABLED=1 或无网关配置时走) ── + +def _api_base() -> str: + base = ( + os.getenv("HERMES_PLATFORM_API_BASE") + or os.getenv("HERMES_MASTER_API_BASE") + or os.getenv("HERMES_SCHEDULER_API_BASE") + or "http://host.docker.internal:8000/api/v1" + ) + return base.rstrip("/") + + +def _agent_api_key() -> str: + return (os.getenv("API_SERVER_KEY") or os.getenv("MODEL_API_KEY") or "").strip() + + +# hc-340: media_transcribe holds the master connection for the whole ASR poll, +# whose budget scales with audio length and is capped at 2h (hc-323). The old +# shared 600s (10min) cap was far below that, so any long video timed out at the +# plugin layer and forced a wasteful re-download + re-transcribe retry (and +# scared the user with a failure mid-way). Align the transcribe timeout with the +# master budget + download/ffmpeg headroom; light calls keep the short default. +_MEDIA_TRANSCRIBE_TIMEOUT_SECONDS = 2 * 60 * 60 + 10 * 60 # 2h10m + + +def _post(path: str, payload: dict[str, Any], *, timeout: int = 600) -> dict[str, Any]: + api_key = _agent_api_key() + if not api_key: + raise RuntimeError("Agent API key is missing") + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + request = urllib.request.Request( + f"{_api_base()}{path}", + data=body, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw).get("detail", raw) + except json.JSONDecodeError: + detail = raw + if isinstance(detail, dict): + message = detail.get("message") or detail.get("code") or raw + # hc-254: surface the machine-readable honesty signal so the model + # offers the honest options instead of self-installing whisper. + if detail.get("terminal_fallback_allowed") is False: + message = ( + f"{message}\n[terminal_fallback_allowed=false] 平台转写不可用," + "请按失败选项处理(稍后重试 / 发视频文件 / 贴文字稿)," + "不要尝试自行安装或运行本地转写工具(如 whisper)。" + ) + else: + message = str(detail or raw) + raise RuntimeError(message) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"无法连接媒体服务: {exc.reason}") from exc + + +def _get(path: str, *, timeout: int = 60) -> dict[str, Any]: + api_key = _agent_api_key() + if not api_key: + raise RuntimeError("Agent API key is missing") + request = urllib.request.Request( + f"{_api_base()}{path}", + headers={"Authorization": f"Bearer {api_key}"}, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw).get("detail", raw) + except json.JSONDecodeError: + detail = raw + message = detail.get("message") if isinstance(detail, dict) else str(detail or raw) + raise RuntimeError(message or raw) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"无法连接媒体服务: {exc.reason}") from exc + + +def _legacy_check() -> bool: + api_key = _agent_api_key() + if not api_key: + return False + request = urllib.request.Request( + f"{_api_base()}/media/douyin/check", + headers={"Authorization": f"Bearer {api_key}"}, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + data = json.loads(response.read().decode("utf-8")) + return bool(data.get("tikhub_configured") and data.get("doubao_asr_configured") and data.get("ffmpeg_available")) + except Exception: + return False + + +def _check() -> bool: + # 网关模式:可用性=配置齐(base+key);额度/上游故障在调用时以显式文案暴露, + # 不在注册期做网络探测(桌面离线启动不应拖慢/误杀工具注册)。 + if _use_gateway(): + return bool(_gateway.agent_api_key()) + return _legacy_check() + + +SOCIAL_DOWNLOAD_SCHEMA = { + "name": "social_download", + "description": ( + "下载社媒视频(抖音/TikTok、小红书、快手、B站)到当前工作区的媒体空间。用户发来抖音/小红书/快手/B站分享链接/分享口令时," + "第一步必须优先使用本工具;不要用浏览器打开这些链接(反爬环境又慢又不可靠)。返回 video_path 和元数据。" + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": ( + "直接传用户发来的原始分享文本即可(整段口令,含前后噪音文字也可以);" + "平台会自动识别抖音(v.douyin.com)、小红书(xhslink.com / xiaohongshu.com)、" + "快手(v.kuaishou.com)、B站(bilibili.com / b23.tv)链接。" + ), + } + }, + "required": ["url"], + }, +} + +MEDIA_TRANSCRIBE_SCHEMA = { + "name": "media_transcribe", + "description": ( + "转写已下载的 video_path 或社媒视频链接 url(抖音/小红书/快手/B站,可直接传用户的原始分享文本,一步下载并转写)。" + "用户要视频文案/逐字稿/拆解脚本时用本工具,禁止用浏览器或其他平台替代,也不要自行安装或运行本地转写工具(如 whisper)。" + "返回完整转写和 transcript_path。" + ), + "parameters": { + "type": "object", + "properties": { + "video_path": {"type": "string", "description": "Video path returned by social_download."}, + "url": {"type": "string", "description": "Optional social video share text/URL; downloads and transcribes in one call."}, + }, + }, +} + + +IMAGE_OCR_SCHEMA = { + "name": "image_ocr", + "description": ( + "识别社媒图文/图片里的文字(抖音/TikTok 图文、小红书图文、快手图片)。" + "用户发来图文笔记/图片链接、或想「提取图片文字/读菜单价格/读截图文字」时用本工具:传社媒分享链接 url," + "或直接传图片地址 image_urls 列表。不要用浏览器打开这些链接。本工具只读字,不理解非文字画面/场景/构图。" + ), + "parameters": { + "type": "object", + "properties": { + "url": { + "type": "string", + "description": "社媒图文分享链接/分享口令(抖音图文、小红书图文、快手图片、TikTok photo),平台自动识别并提取图片。", + }, + "image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": "可选:直接给出的图片地址列表(已有图片 URL 时用,无需再传 url)。", + }, + "prompt": { + "type": "string", + "description": "可选:用户对这些图片文字的具体需求(如「提取菜单价格」「读出截图里的文案」)。", + }, + }, + }, +} + + +def _legacy_check_image() -> bool: + # hc-320: image OCR needs local RapidOCR; surfaced by /media/douyin/check. + api_key = _agent_api_key() + if not api_key: + return False + request = urllib.request.Request( + f"{_api_base()}/media/douyin/check", + headers={"Authorization": f"Bearer {api_key}"}, + method="GET", + ) + try: + with urllib.request.urlopen(request, timeout=5) as response: + data = json.loads(response.read().decode("utf-8")) + return bool(data.get("image_ocr_available")) + except Exception: + return False + + +def _check_image() -> bool: + if _use_gateway(): + return bool(_gateway.agent_api_key()) + return _legacy_check_image() + + +# ── 网关模式的媒体编排(PD §8:网关只出直链/元数据,媒体本端自取;ASR 上传仅音轨) ── + +def _gateway_resolve_download(url: str, *, timeout: float = 600) -> tuple[dict[str, Any], str]: + """经网关解析分享链接:返回 (含元数据的结果 dict, 直链 media_url——可为空)。""" + platform = _gateway.detect_platform(url) + if not platform: + raise _gateway.GatewayError( + "无法从链接识别社媒平台(支持 抖音/小红书/快手/B站/TikTok/YouTube/Instagram/Twitter/公众号/视频号)," + "请发送原始分享链接或分享口令。" + ) + response = _gateway.request_json( + "POST", f"/tools/v1/social/{platform}/download", {"url": url}, timeout=timeout + ) + result = _gateway.unwrap(response) + media_url = str( + result.get("download_url") or result.get("media_url") or result.get("video_url") or "" + ).strip() + result.setdefault("platform", platform) + return result, media_url + + +def _gateway_social_download(url: str) -> str: + try: + result, media_url = _gateway_resolve_download(url) + if media_url: + video_path = _gateway.download_media( + media_url, + headers=result.pop("download_headers", None), + filename_hint=str(result.get("title") or ""), + ) + result["video_path"] = str(video_path) + else: + result.pop("download_headers", None) + except _gateway.GatewayError as exc: + return tool_error(f"视频下载失败: {exc}") + result.setdefault("ok", True) + return tool_result(**result) + + +def _gateway_media_transcribe(video_path: str | None, url: str | None) -> str: + meta: dict[str, Any] = {} + local_path: Path | None = None + media_url = "" + try: + if video_path: + local_path = Path(video_path) + if not local_path.exists(): + return tool_error(f"视频转写失败: 找不到视频文件 {video_path},请先用 social_download 下载。") + elif url: + meta, media_url = _gateway_resolve_download(url) + if media_url: + try: + local_path = _gateway.download_media( + media_url, + headers=meta.pop("download_headers", None), + filename_hint=str(meta.get("title") or ""), + ) + except _gateway.GatewayError: + # 直链本地下载失败 → 退回契约基本形态:让网关自取媒体转写。 + local_path = None + + if local_path is not None: + upload_path = _gateway.extract_audio_for_asr(local_path) or local_path + with open(upload_path, "rb") as fh: + files = { + "file": (upload_path.name, fh, _gateway.guess_media_content_type(upload_path)), + } + response = _gateway.request_json( + "POST", + "/tools/v1/asr/transcribe", + files=files, + timeout=_MEDIA_TRANSCRIBE_TIMEOUT_SECONDS, + ) + elif media_url: + response = _gateway.request_json( + "POST", + "/tools/v1/asr/transcribe", + {"media_url": media_url}, + timeout=_MEDIA_TRANSCRIBE_TIMEOUT_SECONDS, + ) + else: + return tool_error( + "视频转写失败: 平台未能给出该内容的可下载媒体(可能是图文/直播链接)," + "请确认是视频分享链接,或先用 social_download 检查。" + ) + except _gateway.GatewayError as exc: + return tool_error(f"视频转写失败: {exc}") + + transcript = str(response.get("text") or "").strip() + title = str(meta.get("title") or "").strip() + result: dict[str, Any] = { + "ok": True, + "transcript": transcript, + "video_path": str(local_path) if local_path is not None else None, + "title": title or None, + "author": meta.get("author"), + "audio_duration_seconds": response.get("duration_seconds"), + } + if response.get("cost_cents") is not None: + result["cost_cents"] = response.get("cost_cents") + transcript_path = _write_transcript_file(transcript, title) + if transcript_path: + result["transcript_path"] = transcript_path + # hc-341: pre-render the verbatim transcript so "完整文案/逐字稿/下载文案" is delivered + # as the original speech, copied verbatim — the agent only emits the MEDIA line. + if transcript and _doc_file_delivery_available(): + doc_path = render_verbatim_transcript_docx(transcript, title) + if doc_path: + result["transcript_doc_path"] = doc_path + result["transcript_doc_media_tag"] = f"MEDIA:{doc_path}" + result["transcript_doc_instruction"] = _TRANSCRIPT_DOC_INSTRUCTION + return tool_result(**result) + + +def _write_transcript_file(transcript: str, title: str) -> str | None: + """把转写全文落成本地 txt(保持迁移前 transcript_path 的结果面)。""" + if not transcript: + return None + try: + dest = _document_cache_dir() / f"{_safe_filename(title or '视频转写')}_转写_{uuid.uuid4().hex[:8]}.txt" + dest.write_text(transcript, encoding="utf-8") + return str(dest) + except Exception: # noqa: BLE001 — 落盘失败不应吞掉转写本身 + return None + + +def _gateway_image_ocr(payload: dict[str, Any]) -> str: + # image-ocr 的 OCR 在云侧执行;platform 路径段只作路由/白名单——裸图片 URL + # 识别不出平台时回退 "douyin"(见 plugins/apexnodes_gateway.py 契约表)。 + probe_text = " ".join( + [str(payload.get("url") or "")] + [str(u) for u in (payload.get("image_urls") or [])] + ) + platform = _gateway.detect_platform(probe_text) or "douyin" + try: + response = _gateway.request_json( + "POST", f"/tools/v1/social/{platform}/image-ocr", payload, timeout=600 + ) + except _gateway.GatewayError as exc: + return tool_error(f"图片 OCR 失败: {exc}") + return tool_result(**_gateway.unwrap(response)) + + +def _handle_social_download(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("social_download expects a JSON object argument") + url = str(args.get("url") or args.get("share_url") or "").strip() + if not url: + return tool_error("请提供视频分享链接") + if _use_gateway(): + return _gateway_social_download(url) + try: + return tool_result(**_post("/media/social-download", {"url": url})) + except RuntimeError as exc: + return tool_error(f"视频下载失败: {exc}") + + +# ── hc-341: verbatim transcript → .docx (programmatic, container-side) ──────── +# A "完整文案 / 逐字稿 / 下载文案" request must deliver the ASR text copied verbatim. +# Prompt-only carve-outs didn't hold — the model kept restructuring the body when it +# round-tripped through doc_file_write. So we render the .docx here, straight from the +# transcribe result, and hand the agent a ready MEDIA tag: it only triggers the export, +# it never regenerates the text. Gated to the non-Feishu IM entries where a .docx is the +# delivery (Feishu keeps feishu_doc_write); mirrors apexnodes-doc-file-write's renderer. +_NON_FEISHU_IM_ENV_MARKERS = ("WEIXIN_TOKEN", "WECOM_BOT_ID", "DINGTALK_CLIENT_ID", "QQ_APP_ID") +# Group a one-blob ASR transcript into readable paragraphs at sentence boundaries — +# inserts paragraph breaks only, never alters a character (keeps 原话原序照搬). +_SENTENCE_BOUNDARY_RE = re.compile(r"(?<=[。!?!?…])") +_VERBATIM_PARAGRAPH_MAX_CHARS = 180 +_VERBATIM_PARAGRAPH_MAX_SENTENCES = 4 +_TRANSCRIPT_DOC_INSTRUCTION = ( + "用户要完整文案/逐字稿/转写全文/下载文案/原话时:直接在回复里单独一行输出 " + "transcript_doc_media_tag 的值,把这份逐字稿文件发给用户——正文已是转写原文逐字照搬。" + "不要再调用 doc_file_write,不要把逐字稿重排成结构化文档(加标题/小节、表格、要点都不行)。" +) + + +def _doc_file_delivery_available() -> bool: + """True only when python-docx is importable AND this is a non-Feishu IM entry — + the same gate apexnodes-doc-file-write uses, so the verbatim .docx export only + surfaces where a Feishu doc link can't be opened.""" + import importlib.util + + try: + if importlib.util.find_spec("docx") is None: + return False + except (ImportError, ValueError): + return False + return any((os.getenv(marker) or "").strip() for marker in _NON_FEISHU_IM_ENV_MARKERS) + + +def _document_cache_dir() -> Path: + """Runtime document cache dir (a media-delivery safe root), matching doc_file_write.""" + home: str | None = None + try: # match the runtime's canonical HERMES_HOME resolution when available + from hermes_constants import get_hermes_home + + home = str(get_hermes_home()) + except Exception: # noqa: BLE001 + home = os.getenv("HERMES_HOME") or str(Path.home() / ".hermes") + cache_dir = Path(home) / "cache" / "documents" + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir + + +def _safe_filename(title: str) -> str: + base = re.sub(r"[\\/\x00-\x1f]", " ", title or "").strip() + base = re.sub(r"\s+", " ", base) or "视频逐字稿" + return base[:60] + + +def _set_cjk_font(doc, font_name: str = "Microsoft YaHei") -> None: + """Request a CJK font on the Normal style so viewers render Chinese (best-effort).""" + try: + from docx.oxml.ns import qn + + style = doc.styles["Normal"] + style.font.name = font_name + rpr = style.element.get_or_add_rPr() + rfonts = rpr.get_or_add_rFonts() + rfonts.set(qn("w:eastAsia"), font_name) + except Exception: # noqa: BLE001 + pass + + +def _verbatim_paragraphs(transcript: str) -> list[str]: + """Split the transcript into readable paragraphs without changing any words. + + Respects segmentation the transcript already carries (newlines); otherwise groups + sentences from a single ASR blob. Only paragraph breaks are added — the speaker's + words and their order are preserved verbatim (the only processing the逐字稿 allows).""" + text = (transcript or "").strip() + if not text: + return [] + explicit = [seg.strip() for seg in re.split(r"\n+", text) if seg.strip()] + if len(explicit) > 1: + return explicit + paragraphs: list[str] = [] + buf: list[str] = [] + for sentence in (s for s in _SENTENCE_BOUNDARY_RE.split(text) if s.strip()): + buf.append(sentence) + if len(buf) >= _VERBATIM_PARAGRAPH_MAX_SENTENCES or sum(len(s) for s in buf) >= _VERBATIM_PARAGRAPH_MAX_CHARS: + paragraphs.append("".join(buf)) + buf = [] + if buf: + paragraphs.append("".join(buf)) + return paragraphs or [text] + + +def render_verbatim_transcript_docx(transcript: str, title: str) -> str | None: + """Render the verbatim transcript to a .docx (heading + raw paragraphs) and return + its path, or None on any failure. No markdown parsing and no headings/tables/bullets + inside the body — the document is the speaker's original words, copied verbatim.""" + try: + from docx import Document + except ImportError: + return None + paragraphs = _verbatim_paragraphs(transcript) + if not paragraphs: + return None + try: + heading = (str(title or "").strip() or "视频逐字稿")[:120] + doc = Document() + _set_cjk_font(doc) + doc.add_heading(heading, level=0) + for para in paragraphs: + doc.add_paragraph(para) + dest = _document_cache_dir() / f"{_safe_filename(heading)}_逐字稿_{uuid.uuid4().hex[:8]}.docx" + doc.save(str(dest)) + return str(dest) + except Exception: # noqa: BLE001 — a render failure must never fail the transcribe tool + return None + + +def _handle_media_transcribe(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("media_transcribe expects a JSON object argument") + video_path = str(args.get("video_path") or "").strip() or None + url = str(args.get("url") or args.get("share_url") or "").strip() or None + if not video_path and not url: + return tool_error("请提供 video_path 或视频分享链接") + if _use_gateway(): + return _gateway_media_transcribe(video_path, url) + payload = {"video_path": video_path, "url": url} + try: + result = _post("/media/transcribe", payload, timeout=_MEDIA_TRANSCRIBE_TIMEOUT_SECONDS) + except RuntimeError as exc: + return tool_error(f"视频转写失败: {exc}") + # hc-341: pre-render the verbatim transcript so "完整文案/逐字稿/下载文案" is delivered + # as the original speech, copied verbatim — the agent only emits the MEDIA line. + transcript = str(result.get("transcript") or "").strip() + if transcript and _doc_file_delivery_available(): + doc_path = render_verbatim_transcript_docx(transcript, str(result.get("title") or "")) + if doc_path: + result["transcript_doc_path"] = doc_path + result["transcript_doc_media_tag"] = f"MEDIA:{doc_path}" + result["transcript_doc_instruction"] = _TRANSCRIPT_DOC_INSTRUCTION + return tool_result(**result) + + +def _handle_image_ocr(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("image_ocr expects a JSON object argument") + raw_images = args.get("image_urls") + image_urls = [str(item).strip() for item in raw_images if str(item).strip()] if isinstance(raw_images, list) else None + payload = { + "url": str(args.get("url") or args.get("share_url") or "").strip() or None, + "image_urls": image_urls or None, + "prompt": str(args.get("prompt") or args.get("question") or "").strip() or None, + } + if not payload["url"] and not payload["image_urls"]: + return tool_error("请提供社媒图文链接 url 或图片地址 image_urls") + if _use_gateway(): + return _gateway_image_ocr(payload) + try: + return tool_result(**_post("/media/image-understand", payload)) + except RuntimeError as exc: + return tool_error(f"图片 OCR 失败: {exc}") + + +SOCIAL_BATCH_SUBMIT_SCHEMA = { + "name": "social_batch_submit", + "description": ( + "批量下载并转写一批社媒视频(抖音/快手/B站/小红书=下载+转写,YouTube=官方字幕,TikTok复用抖音)。" + "适用于「把某作者点赞超10万的作品批量转写」「把这个抖音合集的视频都转成文字/表格」这类需求。" + "传 urls(链接清单)或 creator_url(作者主页链接=枚举其全部作品,或某个抖音合集的分享链接=只枚举该合集;配 min_likes/top 自动筛选)。" + "★这是异步任务:本工具只负责提交,返回 job_id;之后用 social_batch_status 轮询进度。" + "平台会在 master 侧完成全部下载/转写/出表,你只需提交、轮询、把最终产物发给用户——" + "绝不要自己写脚本下载、起进程、删文件。单条出 Word、多条出 Excel、飞书多条且给了 bitable_url 则写入多维表格。" + ), + "parameters": { + "type": "object", + "properties": { + "urls": { + "type": "array", + "items": {"type": "string"}, + "description": "视频链接清单(每个元素一条分享链接/口令)。与 creator_url 二选一。", + }, + "creator_url": { + "type": "string", + "description": ( + "作者主页链接(枚举其全部作品),或某个抖音合集的分享链接(douyin.com/collection/… 或 mix/…," + "只枚举该合集内的作品);平台会枚举并按 min_likes/top 筛选。与 urls 二选一。" + "注意:合集请发该【合集】自己的分享链接,仅凭作者主页+合集名无法定位某个合集。" + ), + }, + "platform": {"type": "string", "description": "可选,creator_url 的平台(douyin/kuaishou/bilibili/xiaohongshu/youtube/tiktok)。"}, + "min_likes": {"type": "integer", "description": "可选,creator_url 时只保留点赞≥该值的作品(如 100000)。"}, + "top": {"type": "integer", "description": "可选,最多取前 N 条(按点赞排序)。"}, + "bitable_url": {"type": "string", "description": "可选,飞书多条交付时写入的目标多维表格 /base/ 链接。"}, + }, + }, +} + +SOCIAL_BATCH_STATUS_SCHEMA = { + "name": "social_batch_status", + "description": ( + "查询批量转写任务的进度与产物(配合 social_batch_submit 使用)。传 job_id。" + "状态为 completed/partial 时,结果里 product 含产物:file 类(Word/Excel)给 download_url," + "飞书多维表格给 bitable_url——把它发给用户即可。仍在 running 时稍后再轮询,不要重复提交。" + ), + "parameters": { + "type": "object", + "properties": {"job_id": {"type": "string", "description": "social_batch_submit 返回的任务 id。"}}, + "required": ["job_id"], + }, +} + + +def _session_target() -> dict | None: + """hc-371: capture the originating IM session so the master can push the batch + result back here instead of the agent polling. The v0.17 gateway mirrors the + turn's session onto ``HERMES_SESSION_*`` (ContextVar → os.environ → tool thread); + ``get_session_env`` reads it, with a plain ``os.getenv`` fallback if the gateway + helper isn't importable. ``None`` (no chat_id) ⇒ master can't route ⇒ poll.""" + try: + from gateway.session_context import get_session_env # type: ignore + except Exception: # pragma: no cover - exercised inside the v0.17 agent only + def get_session_env(name: str, default: str = "") -> str: + return os.getenv(name, default) + + chat_id = (get_session_env("HERMES_SESSION_CHAT_ID", "") or "").strip() + if not chat_id: + return None + target = {"chat_id": chat_id} + for key, env in (("platform", "HERMES_SESSION_PLATFORM"), ("user_id", "HERMES_SESSION_USER_ID")): + value = (get_session_env(env, "") or "").strip() + if value: + target[key] = value + return target + + +def _decorate_batch_submit(result: dict[str, Any]) -> dict[str, Any]: + if result.get("async_delivery"): + # The master will push the finished result straight to this chat — no poll. + result["_instruction"] = ( + "已提交批量任务。任务在 master 侧异步执行,完成后平台会【自动把结果推送到当前会话】," + "你无需轮询、可以直接结束本轮;完成时用户会收到总结与产物链接。" + "请勿重复提交、勿自己下载或起脚本。" + ) + else: + result["_instruction"] = ( + "已提交批量任务。请记住 job_id,稍后用 social_batch_status 轮询;任务在 master 侧异步执行," + "完成前不要重复提交、不要自己下载或起脚本。完成后把产物链接发给用户。" + ) + return result + + +def _decorate_batch_status(result: dict[str, Any]) -> dict[str, Any]: + status = result.get("status") + if status in ("completed", "partial"): + result["_instruction"] = ( + "任务已完成。result.note 是给用户的总结;product 是产物:" + "若有 download_url(Word/Excel) 就把文件发给用户,若有 bitable_url 就把多维表格链接发给用户。" + ) + elif status == "failed": + result["_instruction"] = "任务失败,把 result.note / error 里的原因如实告诉用户。" + else: + result["_instruction"] = "任务仍在进行中(still running)。稍等片刻再用 social_batch_status 轮询,不要重复提交。" + return result + + +def _handle_social_batch_submit(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("social_batch_submit expects a JSON object argument") + urls = args.get("urls") + if isinstance(urls, str): + urls = [urls] + payload = { + "urls": [str(u).strip() for u in urls if str(u).strip()] if isinstance(urls, list) else None, + "creator_url": str(args.get("creator_url") or "").strip() or None, + "platform": str(args.get("platform") or "").strip() or None, + "min_likes": int(args.get("min_likes") or 0), + "top": args.get("top"), + "bitable_url": str(args.get("bitable_url") or "").strip() or None, + # hc-371: routing snapshot; the master stores it and pushes the result on + # completion. Always captured (harmless when the feature is off — nothing + # reads it until the master's async-delivery flag is armed for this agent; + # 桌面形态没有 IM 会话 env ⇒ None ⇒ 轮询模式). + "delivery_target": _session_target(), + } + if not payload["urls"] and not payload["creator_url"]: + return tool_error("请提供 urls(链接清单) 或 creator_url(作者主页链接,或某个抖音合集的分享链接)。") + if _use_gateway(): + try: + result = _gateway.unwrap( + _gateway.request_json("POST", "/tools/v1/social/batch/submit", payload, timeout=120) + ) + except _gateway.GatewayError as exc: + return tool_error(f"批量任务提交失败: {exc}") + return tool_result(**_decorate_batch_submit(result)) + try: + result = _post("/media/batch/submit", payload, timeout=120) + except RuntimeError as exc: + return tool_error(f"批量任务提交失败: {exc}") + return tool_result(**_decorate_batch_submit(result)) + + +def _handle_social_batch_status(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("social_batch_status expects a JSON object argument") + job_id = str(args.get("job_id") or "").strip() + if not job_id: + return tool_error("请提供 job_id。") + if _use_gateway(): + try: + result = _gateway.unwrap( + _gateway.request_json("GET", f"/tools/v1/social/batch/status/{job_id}", timeout=60) + ) + except _gateway.GatewayError as exc: + return tool_error(f"查询批量任务失败: {exc}") + return tool_result(**_decorate_batch_status(result)) + try: + result = _get(f"/media/batch/status/{job_id}") + except RuntimeError as exc: + return tool_error(f"查询批量任务失败: {exc}") + return tool_result(**_decorate_batch_status(result)) + + +def register(ctx): + ctx.register_tool( + name="social_download", + toolset="skills", + schema=SOCIAL_DOWNLOAD_SCHEMA, + handler=_handle_social_download, + check_fn=_check, + requires_env=[], + description=SOCIAL_DOWNLOAD_SCHEMA["description"], + emoji="⬇️", + ) + ctx.register_tool( + name="media_transcribe", + toolset="skills", + schema=MEDIA_TRANSCRIBE_SCHEMA, + handler=_handle_media_transcribe, + check_fn=_check, + requires_env=[], + description=MEDIA_TRANSCRIBE_SCHEMA["description"], + emoji="📝", + ) + ctx.register_tool( + name="image_ocr", + toolset="skills", + schema=IMAGE_OCR_SCHEMA, + handler=_handle_image_ocr, + check_fn=_check_image, + requires_env=[], + description=IMAGE_OCR_SCHEMA["description"], + emoji="🖼️", + ) + ctx.register_tool( + name="social_batch_submit", + toolset="skills", + schema=SOCIAL_BATCH_SUBMIT_SCHEMA, + handler=_handle_social_batch_submit, + check_fn=_check, + requires_env=[], + description=SOCIAL_BATCH_SUBMIT_SCHEMA["description"], + emoji="📦", + ) + ctx.register_tool( + name="social_batch_status", + toolset="skills", + schema=SOCIAL_BATCH_STATUS_SCHEMA, + handler=_handle_social_batch_status, + check_fn=_check, + requires_env=[], + description=SOCIAL_BATCH_STATUS_SCHEMA["description"], + emoji="🔎", + ) diff --git a/plugins/apexnodes-douyin-tools/plugin.yaml b/plugins/apexnodes-douyin-tools/plugin.yaml new file mode 100644 index 0000000000000..3e2310761bf28 --- /dev/null +++ b/plugins/apexnodes-douyin-tools/plugin.yaml @@ -0,0 +1,9 @@ +name: apexnodes-douyin-tools +version: "0.5.0" +description: "Social media tools — video download/transcription, image text OCR, and async batch download+transcribe. P1 gateway mode (default): all vendor calls go through the ApexNodes tools gateway (api.apex-nodes.com /tools/v1); legacy master-endpoint path kept behind TOOLS_GATEWAY_DISABLED=1 (cloud rollback channel)." +provides_tools: + - social_download + - media_transcribe + - image_ocr + - social_batch_submit + - social_batch_status diff --git a/plugins/apexnodes-social-tools/__init__.py b/plugins/apexnodes-social-tools/__init__.py new file mode 100644 index 0000000000000..3769601cae086 --- /dev/null +++ b/plugins/apexnodes-social-tools/__init__.py @@ -0,0 +1,295 @@ +"""ApexNodes social data tools. + +平台工具网关 P1(DESKTOP-CLOUD-CAPABILITY-PARITY-PD D1/D2):本插件自 +hermes-cloud ``app/runtime_plugins/apexnodes-social-tools`` 迁入 fork, +桌面与云端同一份代码。默认走平台工具网关 +``/tools/v1/social/{platform}/{action}``(vendor key 永不出云); +``TOOLS_GATEWAY_DISABLED=1`` 时回退迁移前的 master 数据网关路径 +(云端 P1 回退通道,P2 删)。 +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any + +from tools.registry import tool_error, tool_result + +try: # 网关客户端(fork 内共用);导入失败时插件退回 legacy 路径,绝不拖垮装载。 + from plugins import apexnodes_gateway as _gateway +except Exception: # pragma: no cover - 仅在裸拷贝部署等异常形态出现 + _gateway = None # type: ignore[assignment] + + +CAPABILITIES = ("content", "search", "profile", "comments", "trending", "posts", "captions") + + +def _use_gateway() -> bool: + return _gateway is not None and _gateway.use_gateway() + + +# ── legacy master 数据网关路径(迁移前行为,原样保留) ────────────────────────── + +def _api_base() -> str: + base = ( + os.getenv("HERMES_PLATFORM_API_BASE") + or os.getenv("HERMES_MASTER_API_BASE") + or os.getenv("HERMES_SCHEDULER_API_BASE") + or "http://host.docker.internal:8000/api/v1" + ) + return base.rstrip("/") + + +def _agent_api_key() -> str: + return (os.getenv("API_SERVER_KEY") or os.getenv("MODEL_API_KEY") or "").strip() + + +def _request(method: str, path: str, payload: dict[str, Any] | None = None, timeout: int = 90) -> dict[str, Any]: + api_key = _agent_api_key() + if not api_key: + raise RuntimeError("Agent API key is missing") + data = None + headers = {"Authorization": f"Bearer {api_key}"} + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(f"{_api_base()}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw).get("detail", raw) + except json.JSONDecodeError: + detail = raw + if isinstance(detail, dict): + message = detail.get("message") or detail.get("code") or raw + else: + message = str(detail or raw) + raise RuntimeError(message) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"无法连接跨平台数据服务: {exc.reason}") from exc + + +def _legacy_check() -> bool: + try: + data = _request("GET", "/data/social/check", timeout=5) + return bool(data.get("configured")) + except Exception: + return False + + +def _check() -> bool: + # 网关模式:可用性=配置齐(base+key);额度/限流/上游故障在调用时显式报错。 + if _use_gateway(): + return bool(_gateway.agent_api_key()) + return _legacy_check() + + +def _schema(name: str, description: str, required: list[str]) -> dict[str, Any]: + return { + "name": name, + "description": description, + "parameters": { + "type": "object", + "properties": { + "platform": { + "type": "string", + "description": "One of douyin, xiaohongshu, tiktok, instagram, youtube, bilibili, kuaishou, wechat_channels, wechat_mp.", + }, + "query": {"type": "string", "description": "Search query or keyword."}, + "url": {"type": "string", "description": "Content URL when available."}, + "user_id": {"type": "string", "description": "Platform user id / sec_user_id / channel id / username."}, + "item_id": {"type": "string", "description": "Platform content id such as aweme_id, note_id, or video_id."}, + "cursor": {"type": "string", "description": "Pagination cursor."}, + "count": {"type": "integer", "description": "Result count, max 100."}, + "params": {"type": "object", "description": "Provider-specific read-only parameters when needed."}, + }, + "required": required, + }, + } + + +SCHEMAS = { + "social_content": _schema("social_content", "Fetch one public social post/video by platform and URL or item id.", ["platform"]), + "social_search": _schema("social_search", "Search public content on a whitelisted social platform.", ["platform", "query"]), + "social_profile": _schema("social_profile", "Fetch a public creator/profile record from a whitelisted social platform.", ["platform", "user_id"]), + "social_comments": _schema("social_comments", "Fetch public comments for one social content item.", ["platform", "item_id"]), + "social_trending": _schema("social_trending", "Fetch public trending/hot content for a whitelisted social platform.", ["platform"]), + "social_posts": _schema("social_posts", "List a public creator's recent posts/videos by platform and creator id (any platform's id spelling).", ["platform", "user_id"]), + "social_captions": _schema("social_captions", "Fetch a YouTube video's official subtitle/caption text by URL or video id — a transcript with no ASR cost.", ["platform"]), +} + +# hc-346: enumerate a creator's works and rank them by interaction volume — the +# batch-pipeline selector. The master pages the creator's posts feed (bounded by a +# scan cap), filters by like/collect/comment threshold + time range, and returns a +# ranked, honestly-annotated sample (it is a scanned window, not a platform-wide board). +CREATOR_TOP_POSTS_SCHEMA = { + "name": "creator_top_posts", + "description": ( + "List a creator's top works ranked by interaction volume. Give a creator homepage link " + "(url) — or a single douyin 合集/mix share link (url) to enumerate just that collection — " + "or platform+user_id, plus a like threshold (min_likes, e.g. 100000). Returns the " + "scanned posts that meet the threshold, ranked, with an honest 'scanned N, M matched' note. " + "Read-only enumeration — no download/transcription." + ), + "parameters": { + "type": "object", + "properties": { + "platform": {"type": "string", "description": "douyin/xiaohongshu/tiktok/kuaishou/bilibili/youtube/instagram. Optional if url is given."}, + "url": {"type": "string", "description": "Creator homepage share link (douyin 主页 分享链接), or a douyin 合集 share link (douyin.com/collection/… or mix/…) to enumerate only that collection."}, + "user_id": {"type": "string", "description": "Creator id (e.g. douyin sec_user_id) when you already have it."}, + "min_likes": {"type": "integer", "description": "Keep only posts with at least this many likes (e.g. 100000 for 10万)."}, + "min_collects": {"type": "integer", "description": "Optional minimum collect/favorite count."}, + "min_comments": {"type": "integer", "description": "Optional minimum comment count."}, + "since": {"type": "string", "description": "Optional earliest publish date (YYYY-MM-DD)."}, + "until": {"type": "string", "description": "Optional latest publish date (YYYY-MM-DD)."}, + "sort_by": {"type": "string", "description": "Rank by: likes (default), collects, comments, shares, plays, published."}, + "top": {"type": "integer", "description": "Return at most this many ranked posts."}, + "scan_limit": {"type": "integer", "description": "Cap how many recent posts to scan (default platform cap)."}, + }, + "required": [], + }, +} + + +def _payload(args: dict) -> dict[str, Any]: + if not isinstance(args, dict): + raise RuntimeError("tool expects a JSON object argument") + platform = str(args.get("platform") or "").strip().lower() + if not platform: + raise RuntimeError("请提供 platform") + return { + "platform": platform, + "query": args.get("query"), + "url": args.get("url"), + "keyword": args.get("keyword"), + "user_id": args.get("user_id"), + "item_id": args.get("item_id"), + "cursor": args.get("cursor"), + "count": args.get("count"), + "params": args.get("params") if isinstance(args.get("params"), dict) else {}, + } + + +def _gateway_platform_or_error(platform: str) -> str | None: + """返回错误文案(平台不在白名单),合法时返回 None。""" + if platform in _gateway.SOCIAL_PLATFORMS: + return None + supported = "、".join(_gateway.SOCIAL_PLATFORMS) + return f"不支持的平台「{platform}」(当前支持: {supported})" + + +def _handler(capability: str): + def handle(args: dict, **_kwargs) -> str: + try: + payload = _payload(args) + except RuntimeError as exc: + return tool_error(f"跨平台数据查询失败: {exc}") + if _use_gateway(): + platform = payload["platform"] + invalid = _gateway_platform_or_error(platform) + if invalid: + return tool_error(f"跨平台数据查询失败: {invalid}") + try: + response = _gateway.request_json( + "POST", f"/tools/v1/social/{platform}/{capability}", payload, timeout=90 + ) + except _gateway.GatewayError as exc: + return tool_error(f"跨平台数据查询失败: {exc}") + return tool_result(**_gateway.unwrap(response)) + try: + return tool_result(**_request("POST", f"/data/social/{capability}", payload)) + except RuntimeError as exc: + return tool_error(f"跨平台数据查询失败: {exc}") + + return handle + + +def _creator_payload(args: dict) -> dict[str, Any]: + if not isinstance(args, dict): + raise RuntimeError("tool expects a JSON object argument") + if not (args.get("url") or args.get("user_id")): + raise RuntimeError("请提供创作者主页链接 url,或 platform + user_id") + platform = args.get("platform") + payload: dict[str, Any] = { + "platform": str(platform).strip().lower() if platform else None, + "url": args.get("url"), + "user_id": args.get("user_id"), + "since": args.get("since"), + "until": args.get("until"), + "sort_by": str(args.get("sort_by") or "likes").strip().lower(), + } + for key in ("min_likes", "min_collects", "min_comments", "top", "scan_limit"): + value = args.get(key) + if value is not None: + try: + payload[key] = int(value) + except (TypeError, ValueError): + raise RuntimeError(f"{key} 必须是整数") from None + return {key: value for key, value in payload.items() if value is not None} + + +def _creator_handler(args: dict, **_kwargs) -> str: + # enumeration pages the creator feed master-side; allow a longer budget. + try: + payload = _creator_payload(args) + except RuntimeError as exc: + return tool_error(f"创作者作品枚举失败: {exc}") + if _use_gateway(): + platform = str(payload.get("platform") or "").strip() or ( + _gateway.detect_platform(str(payload.get("url") or "")) or "" + ) + if not platform: + return tool_error("创作者作品枚举失败: 无法识别平台,请提供 platform,或发送带平台域名的主页分享链接。") + invalid = _gateway_platform_or_error(platform) + if invalid: + return tool_error(f"创作者作品枚举失败: {invalid}") + payload["platform"] = platform + try: + response = _gateway.request_json( + "POST", f"/tools/v1/social/{platform}/top-posts", payload, timeout=180 + ) + except _gateway.GatewayError as exc: + return tool_error(f"创作者作品枚举失败: {exc}") + return tool_result(**_gateway.unwrap(response)) + try: + return tool_result(**_request("POST", "/data/creator/top-posts", payload, timeout=180)) + except RuntimeError as exc: + return tool_error(f"创作者作品枚举失败: {exc}") + + +def register(ctx): + for tool_name, capability in { + "social_content": "content", + "social_search": "search", + "social_profile": "profile", + "social_comments": "comments", + "social_trending": "trending", + "social_posts": "posts", + "social_captions": "captions", + }.items(): + ctx.register_tool( + name=tool_name, + toolset="skills", + schema=SCHEMAS[tool_name], + handler=_handler(capability), + check_fn=_check, + requires_env=[], + description=SCHEMAS[tool_name]["description"], + emoji="🔎", + ) + ctx.register_tool( + name="creator_top_posts", + toolset="skills", + schema=CREATOR_TOP_POSTS_SCHEMA, + handler=_creator_handler, + check_fn=_check, + requires_env=[], + description=CREATOR_TOP_POSTS_SCHEMA["description"], + emoji="🏆", + ) diff --git a/plugins/apexnodes-social-tools/plugin.yaml b/plugins/apexnodes-social-tools/plugin.yaml new file mode 100644 index 0000000000000..f97fa634f2a34 --- /dev/null +++ b/plugins/apexnodes-social-tools/plugin.yaml @@ -0,0 +1,12 @@ +name: apexnodes-social-tools +version: "0.4.0" +description: "Cross-platform social data tools (10-platform whitelist). P1 gateway mode (default) calls the ApexNodes tools gateway /tools/v1/social/{platform}/{action}; legacy master data-gateway path kept behind TOOLS_GATEWAY_DISABLED=1 (cloud rollback channel)." +provides_tools: + - social_content + - social_search + - social_profile + - social_comments + - social_trending + - social_posts + - social_captions + - creator_top_posts diff --git a/plugins/apexnodes-video-tools/__init__.py b/plugins/apexnodes-video-tools/__init__.py new file mode 100644 index 0000000000000..21790fa36de93 --- /dev/null +++ b/plugins/apexnodes-video-tools/__init__.py @@ -0,0 +1,170 @@ +"""ApexNodes text-to-video generation tool. + +平台工具网关 P1(DESKTOP-CLOUD-CAPABILITY-PARITY-PD D1/D2):本插件自 +hermes-cloud ``app/runtime_plugins/apexnodes-video-tools`` 迁入 fork, +桌面与云端同一份代码。默认走平台工具网关 ``/tools/v1/video/generate`` +(vendor key 永不出云);``TOOLS_GATEWAY_DISABLED=1`` 时回退迁移前的 +Scheduler 端点路径(云端 P1 回退通道,P2 删)。 + +Video rendering is async + heavy, so the request can take a few minutes — +the proxy waits while the platform submits, polls, and downloads. +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request +from typing import Any + +from tools.registry import tool_error, tool_result + +try: # 网关客户端(fork 内共用);导入失败时插件退回 legacy 路径,绝不拖垮装载。 + from plugins import apexnodes_gateway as _gateway +except Exception: # pragma: no cover - 仅在裸拷贝部署等异常形态出现 + _gateway = None # type: ignore[assignment] + + +def _use_gateway() -> bool: + return _gateway is not None and _gateway.use_gateway() + + +# The Scheduler holds the request open while it submits→polls→downloads the +# render, so the proxy must wait longer than the server-side poll budget. +_VIDEO_REQUEST_TIMEOUT = 660 + + +# ── legacy Scheduler 路径(迁移前行为,原样保留) ───────────────────────────── + +def _api_base() -> str: + base = ( + os.getenv("HERMES_PLATFORM_API_BASE") + or os.getenv("HERMES_MASTER_API_BASE") + or os.getenv("HERMES_SCHEDULER_API_BASE") + or "http://host.docker.internal:8000/api/v1" + ) + return base.rstrip("/") + + +def _agent_api_key() -> str: + return (os.getenv("API_SERVER_KEY") or os.getenv("MODEL_API_KEY") or "").strip() + + +def _request(method: str, path: str, payload: dict[str, Any] | None = None, timeout: int = _VIDEO_REQUEST_TIMEOUT) -> dict[str, Any]: + api_key = _agent_api_key() + if not api_key: + raise RuntimeError("Agent API key is missing") + data = None + headers = {"Authorization": f"Bearer {api_key}"} + if payload is not None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + headers["Content-Type"] = "application/json" + request = urllib.request.Request(f"{_api_base()}{path}", data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + try: + detail = json.loads(raw).get("detail", raw) + except json.JSONDecodeError: + detail = raw + if isinstance(detail, dict): + message = detail.get("message") or detail.get("code") or "视频生成服务繁忙,请稍后重试" + else: + message = str(detail or "视频生成服务繁忙,请稍后重试") + raise RuntimeError(message) from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"无法连接视频生成服务: {exc.reason}") from exc + + +def _legacy_check() -> bool: + try: + data = _request("GET", "/media/video/check", timeout=5) + return bool(data.get("ok") and data.get("api_key_configured") and data.get("model")) + except Exception: + return False + + +def _check() -> bool: + # 网关模式:可用性=配置齐(base+key);额度/限流/上游故障在调用时显式报错。 + if _use_gateway(): + return bool(_gateway.agent_api_key()) + return _legacy_check() + + +GENERATE_VIDEO_SCHEMA = { + "name": "generate_video", + "description": ( + "Generate a short video from a text prompt through the platform video service. " + "Use only when the user explicitly asks to 生成视频/做个视频/做条短视频/文生视频/把这段文案做成视频. " + "Rendering takes a while; tell the user it is being generated and wait for the result. " + "Returns video_url/video_path/media_tag. After success, share the video link; on IM " + "entries, output `MEDIA:` on its own line for native file delivery. Never claim " + "success unless the tool returns ok, and never fabricate a video or link." + ), + "parameters": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Detailed video prompt: subject, action, scene, camera/movement, style, mood.", + }, + "aspect_ratio": { + "type": "string", + "enum": ["landscape", "square", "portrait"], + "description": "landscape=1280x720, square=1024x1024, portrait=720x1280.", + "default": "landscape", + }, + "size": { + "type": "string", + "description": "Optional OpenAI-compatible literal size such as 1280x720. Leave empty unless the user requested exact dimensions.", + }, + "seconds": { + "type": "integer", + "description": "Optional clip length in seconds. Leave empty to use the platform default; longer clips take longer to render.", + }, + }, + "required": ["prompt"], + }, +} + + +def _handle_generate_video(args: dict, **_kwargs) -> str: + if not isinstance(args, dict): + return tool_error("generate_video expects a JSON object argument") + prompt = str(args.get("prompt") or "").strip() + if not prompt: + return tool_error("请提供视频描述") + payload = { + "prompt": prompt, + "aspect_ratio": str(args.get("aspect_ratio") or "landscape").strip() or "landscape", + "size": str(args.get("size") or "").strip() or None, + "seconds": args.get("seconds"), + } + if _use_gateway(): + try: + response = _gateway.request_json( + "POST", "/tools/v1/video/generate", payload, timeout=_VIDEO_REQUEST_TIMEOUT + ) + except _gateway.GatewayError as exc: + return tool_error(f"视频生成失败: {exc}") + return tool_result(**_gateway.unwrap(response)) + try: + return tool_result(**_request("POST", "/media/video-generate", payload)) + except RuntimeError as exc: + return tool_error(f"视频生成失败: {exc}") + + +def register(ctx): + ctx.register_tool( + name="generate_video", + toolset="skills", + schema=GENERATE_VIDEO_SCHEMA, + handler=_handle_generate_video, + check_fn=_check, + requires_env=[], + description=GENERATE_VIDEO_SCHEMA["description"], + emoji="🎬", + ) diff --git a/plugins/apexnodes-video-tools/plugin.yaml b/plugins/apexnodes-video-tools/plugin.yaml new file mode 100644 index 0000000000000..7e957f5aa80df --- /dev/null +++ b/plugins/apexnodes-video-tools/plugin.yaml @@ -0,0 +1,5 @@ +name: apexnodes-video-tools +version: "0.2.0" +description: "Text-to-video generation. P1 gateway mode (default) calls the ApexNodes tools gateway /tools/v1/video/generate; legacy Scheduler endpoint path kept behind TOOLS_GATEWAY_DISABLED=1 (cloud rollback channel)." +provides_tools: + - generate_video diff --git a/plugins/apexnodes_gateway.py b/plugins/apexnodes_gateway.py new file mode 100644 index 0000000000000..6a6836de95656 --- /dev/null +++ b/plugins/apexnodes_gateway.py @@ -0,0 +1,471 @@ +"""ApexNodes 平台工具网关客户端(P1)——插件共用的唯一 vendor 出口。 + +背景(DESKTOP-CLOUD-CAPABILITY-PARITY-PD §3 D1):桌面与云端跑同一份插件、 +同一个网关调用;vendor key(TikHub/豆包/…)永不出云。差异只剩一个 base_url: +云容器走内网 base(env),桌面走公网 ``https://api.apex-nodes.com``。 + +── API 契约(与 hermes-cloud 网关腿并行开发,以本表为准) ──────────────────────── +鉴权:``Authorization: Bearer ``;请求头 ``X-Capability-Version=``。 + +ASR 族: + POST {base}/tools/v1/asr/transcribe + JSON {"media_url": str} ← 基本形态(网关自取媒体) + multipart file=<音频/视频文件> ← 大媒体优化:本地 ffmpeg 抽音轨 + 压缩后上传(PD §8) + → {"text": str, "segments"?: [...], "duration_seconds": float, "cost_cents": int} + +社媒族(platform ∈ SOCIAL_PLATFORMS 10 平台白名单): + POST {base}/tools/v1/social/{platform}/{action} → {"data": {...}, "cost_cents": int} + action(从存量插件调用面整理,一一对应): + content|search|profile|comments|trending|posts|captions ← apexnodes-social-tools 七工具 + top-posts ← creator_top_posts + download ← social_download。data 返回 + 直链 download_url(+可选 + download_headers)与元数据, + 媒体由本端自取,网关只出 + 元数据/签名链接(PD §8) + image-ocr ← image_ocr({url|image_urls, + prompt};OCR 在云侧执行;仅有 + 裸图片 URL 识别不出平台时, + 路径段回退 "douyin"——该段 + 只作路由/白名单用途) + 批量任务(族级端点,不带 platform 段;master 侧编排,与存量 /media/batch/* 同语义): + POST {base}/tools/v1/social/batch/submit ← social_batch_submit + GET {base}/tools/v1/social/batch/status/{job_id} ← social_batch_status + +视频生成族: + POST {base}/tools/v1/video/generate → {"data": {...}, "cost_cents": int} + ← generate_video + +错误语义(全部显式降级文案,不静默吞): + 401 key 失效→提示重新登录;402 配额/余额;429 限流(本客户端带有限退避重试); + 503 vendor 不可用;其余透传 detail。 +────────────────────────────────────────────────────────────────────────────── + +模式解析(insight:云端 P1 回退通道 = 旧 master 内网端点,PD §8): + 1. ``TOOLS_GATEWAY_DISABLED=1`` → 关网关(插件回退旧 master 直连路径); + 2. ``TOOLS_GATEWAY_BASE`` 已设 → 网关模式,用该 base; + 3. 桌面已登录(config.yaml custom_providers 里有 Apex-nodes.com 托管条目) + → 网关模式,base=公网 ``https://api.apex-nodes.com``; + 4. 都没有 → 非网关模式(云容器零新 env 时行为与迁移前 + 完全一致,走各插件的 legacy 默认 base)。 + +Agent API key 解析(桌面与云同一把 key 的两种落点): + ``TOOLS_GATEWAY_KEY`` > ``API_SERVER_KEY`` > ``MODEL_API_KEY``(云容器 env,存量插件同源) + > ``~/.hermes/config.yaml`` 里 ``custom_providers`` 的 Apex-nodes.com 托管条目 ``api_key`` + (桌面 provision-key 写入,见 apps/desktop/electron/apex-managed.cjs)。 +""" + +from __future__ import annotations + +import logging +import mimetypes +import os +import re +import shutil +import subprocess +import time +import uuid +from pathlib import Path +from typing import Any + +import httpx + +logger = logging.getLogger(__name__) + +__all__ = [ + "SOCIAL_PLATFORMS", + "GATEWAY_PUBLIC_BASE", + "GatewayError", + "agent_api_key", + "capability_version", + "detect_platform", + "download_media", + "extract_audio_for_asr", + "gateway_base", + "gateway_disabled", + "guess_media_content_type", + "media_cache_dir", + "request_json", + "unwrap", + "use_gateway", +] + +GATEWAY_PUBLIC_BASE = "https://api.apex-nodes.com" +TOOLS_PREFIX = "/tools/v1" + +ENV_BASE = "TOOLS_GATEWAY_BASE" +ENV_KEY = "TOOLS_GATEWAY_KEY" +ENV_DISABLED = "TOOLS_GATEWAY_DISABLED" + +# 10 平台白名单(PD §2 目标 3;新平台=网关配置变更,本端零改动)。 +SOCIAL_PLATFORMS = ( + "douyin", + "xiaohongshu", + "tiktok", + "instagram", + "youtube", + "bilibili", + "kuaishou", + "wechat_mp", + "wechat_channels", + "twitter", +) + +# 分享链接 → 平台识别(顺序敏感:mp.weixin 必须先于 weixin 域族)。 +_PLATFORM_HOST_SIGNS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("wechat_mp", ("mp.weixin.qq.com",)), + ("wechat_channels", ("channels.weixin.qq.com", "finder.video.qq.com")), + ("douyin", ("v.douyin.com", "douyin.com", "iesdouyin.com")), + ("xiaohongshu", ("xhslink.com", "xiaohongshu.com")), + ("tiktok", ("tiktok.com",)), + ("kuaishou", ("v.kuaishou.com", "kuaishou.com", "gifshow.com")), + ("bilibili", ("bilibili.com", "b23.tv")), + ("youtube", ("youtube.com", "youtu.be")), + ("instagram", ("instagram.com",)), + ("twitter", ("twitter.com", "x.com", "t.co")), +) + +_TRUTHY = {"1", "true", "yes", "on"} + +# 429 有限退避:最多重试 2 次,Retry-After 上限 8s(媒体长轮询场景不宜久睡)。 +_MAX_429_RETRIES = 2 +_MAX_RETRY_AFTER_SECONDS = 8.0 + + +class GatewayError(RuntimeError): + """网关调用失败——message 即面向用户的显式降级文案(中文,不静默)。""" + + def __init__(self, message: str, *, status: int | None = None, code: str | None = None): + super().__init__(message) + self.status = status + self.code = code + + +# --------------------------------------------------------------------------- +# 配置解析:模式 / base / key / 版本 +# --------------------------------------------------------------------------- + +def gateway_disabled() -> bool: + """P1 一键回退开关(PD §8):置 1 时插件走迁移前的 master 直连路径。""" + return (os.getenv(ENV_DISABLED) or "").strip().lower() in _TRUTHY + + +def _managed_provider_entry() -> dict[str, Any] | None: + """桌面托管中转的 custom_providers 条目(provision-key 写入 config.yaml)。 + + 形如 ``{name: Apex-nodes.com, base_url: https://apex-nodes.com/relay/v1, + api_key: ...}``(apps/desktop/electron/apex-managed.cjs 的 + ``MANAGED_PROVIDER_NAME`` / ``syncCustomProviderKeyYaml``)。找不到或 + config 不可读时返回 None——绝不抛,插件在无桌面形态(云容器)也会走到这。 + """ + try: + from hermes_cli.config import load_config + + config = load_config() or {} + except Exception: # noqa: BLE001 — config 不可用等同"未登录" + return None + providers = config.get("custom_providers") + if not isinstance(providers, list): + return None + for entry in providers: + if not isinstance(entry, dict): + continue + name = str(entry.get("name") or "").strip().lower() + base = str(entry.get("base_url") or "").strip().lower() + if name == "apex-nodes.com" or "apex-nodes.com" in base: + return entry + return None + + +def gateway_base() -> str | None: + """网关 base URL;None 表示本环境未配置网关(回退 legacy 路径)。""" + env = (os.getenv(ENV_BASE) or "").strip() + if env: + return env.rstrip("/") + if _managed_provider_entry() is not None: + # 桌面已登录:relay key 与工具网关同一把,公网 base 固定。 + return GATEWAY_PUBLIC_BASE + return None + + +def use_gateway() -> bool: + """插件在本环境是否应走网关(默认走;显式 DISABLED 或无 base 时不走)。""" + if gateway_disabled(): + return False + return gateway_base() is not None + + +def agent_api_key() -> str: + for env_name in (ENV_KEY, "API_SERVER_KEY", "MODEL_API_KEY"): + value = (os.getenv(env_name) or "").strip() + if value: + return value + entry = _managed_provider_entry() + if entry is not None: + return str(entry.get("api_key") or "").strip() + return "" + + +_capability_version_cache: str | None = None + + +def capability_version() -> str: + """X-Capability-Version 的取值 = runtime 发行版本(pyproject version)。 + + 网关用它做版本协商:过旧版本返回明确「请更新引擎」错误(PD §6)。 + """ + global _capability_version_cache + if _capability_version_cache is None: + version = "" + try: + from importlib.metadata import version as _dist_version + + version = _dist_version("hermes-agent") + except Exception: # noqa: BLE001 — 未以发行包形态安装(裸源码跑) + version = (os.getenv("HERMES_RUNTIME_VERSION") or "").strip() + _capability_version_cache = version or "unknown" + return _capability_version_cache + + +def detect_platform(text: str | None) -> str | None: + """从分享文本/链接里识别平台(容忍口令前后噪音文字)。识别不了返回 None。""" + haystack = (text or "").strip().lower() + if not haystack: + return None + for platform, hosts in _PLATFORM_HOST_SIGNS: + if any(host in haystack for host in hosts): + return platform + return None + + +# --------------------------------------------------------------------------- +# HTTP:统一请求 + 显式错误映射 +# --------------------------------------------------------------------------- + +def _server_detail(response: httpx.Response) -> str: + """尽力从响应体里挖 FastAPI 风格的 detail.message / detail.code。""" + try: + body = response.json() + except Exception: # noqa: BLE001 + return (response.text or "").strip()[:300] + detail = body.get("detail", body) if isinstance(body, dict) else body + if isinstance(detail, dict): + return str(detail.get("message") or detail.get("code") or "").strip() + return str(detail or "").strip()[:300] + + +def _error_from_response(response: httpx.Response) -> GatewayError: + status = response.status_code + detail = _server_detail(response) + if status == 401: + message = ( + "平台密钥无效或已过期(401):请重新登录 ApexNodes 账号后重试" + "(桌面端:设置 → 账户 重新登录;云端:请管理员轮换 Agent Key)。" + ) + elif status == 402: + message = "平台工具额度不足(402):套餐配额已用尽或余额不足,请升级套餐/充值后再试。" + elif status == 429: + message = "平台工具请求过于频繁(429):已自动退避重试仍被限流,请稍等 1-2 分钟再试。" + elif status == 503: + message = ( + "平台能力暂不可用(503):上游服务暂时故障或维护中,请稍后重试;" + "不要尝试在本地安装替代工具,可以稍后重试或先把素材文字发给我。" + ) + else: + message = f"平台工具网关返回错误(HTTP {status})" + if detail: + message = f"{message}(详情: {detail})" + return GatewayError(message, status=status) + + +def _retry_after_seconds(response: httpx.Response, attempt: int) -> float: + raw = (response.headers.get("Retry-After") or "").strip() + try: + delay = float(raw) + except (TypeError, ValueError): + delay = float(2 ** attempt) + return max(0.5, min(delay, _MAX_RETRY_AFTER_SECONDS)) + + +def request_json( + method: str, + path: str, + payload: dict[str, Any] | None = None, + *, + timeout: float = 90, + files: dict[str, Any] | None = None, + form_data: dict[str, Any] | None = None, +) -> dict[str, Any]: + """对网关发一次 JSON(或 multipart)请求,返回解析后的 JSON dict。 + + 失败一律抛 :class:`GatewayError`,message 即给用户看的降级文案。 + """ + base = gateway_base() + if not base: + raise GatewayError( + "平台工具网关未配置(缺 TOOLS_GATEWAY_BASE,亦未检测到桌面登录凭证),无法调用平台能力。" + ) + key = agent_api_key() + if not key: + raise GatewayError( + "缺少平台密钥:请先登录 ApexNodes 账号(桌面端),或配置 TOOLS_GATEWAY_KEY/API_SERVER_KEY(云端)。" + ) + headers = { + "Authorization": f"Bearer {key}", + "X-Capability-Version": capability_version(), + } + url = f"{base}{path}" + attempt = 0 + while True: + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + response = client.request( + method, + url, + json=payload if files is None else None, + data=form_data, + files=files, + headers=headers, + ) + except httpx.HTTPError as exc: + raise GatewayError(f"无法连接平台工具网关: {exc}") from exc + if response.status_code == 429 and attempt < _MAX_429_RETRIES: + delay = _retry_after_seconds(response, attempt) + attempt += 1 + logger.info("gateway 429 on %s — backoff %.1fs (attempt %d)", path, delay, attempt) + time.sleep(delay) + continue + if response.status_code >= 400: + raise _error_from_response(response) + try: + body = response.json() + except Exception as exc: # noqa: BLE001 + raise GatewayError("平台工具网关返回了无法解析的响应,请稍后重试。") from exc + if not isinstance(body, dict): + raise GatewayError("平台工具网关返回了意外的响应格式,请稍后重试。") + return body + + +def unwrap(response_body: dict[str, Any]) -> dict[str, Any]: + """把网关 ``{data, cost_cents}`` 信封摊回工具结果面(保持 agent 无感)。""" + data = response_body.get("data") + out: dict[str, Any] = dict(data) if isinstance(data, dict) else ({} if data is None else {"data": data}) + if "cost_cents" in response_body and "cost_cents" not in out: + out["cost_cents"] = response_body.get("cost_cents") + return out + + +# --------------------------------------------------------------------------- +# 本地媒体:下载 / 抽音轨(PD §8——大媒体不过网关,网关只出直链) +# --------------------------------------------------------------------------- + +def media_cache_dir() -> Path: + """本地媒体缓存目录(media-delivery 安全根,与 cache/documents 同族)。""" + try: + from hermes_constants import get_hermes_home + + home = str(get_hermes_home()) + except Exception: # noqa: BLE001 + home = os.getenv("HERMES_HOME") or str(Path.home() / ".hermes") + cache = Path(home) / "cache" / "media" + cache.mkdir(parents=True, exist_ok=True) + return cache + + +_EXT_RE = re.compile(r"\.([A-Za-z0-9]{2,5})(?:$|[?#])") + + +def _guess_extension(url: str, content_type: str | None) -> str: + match = _EXT_RE.search(url.split("?", 1)[0].rsplit("/", 1)[-1] + "?") + if match: + return f".{match.group(1).lower()}" + if content_type: + guessed = mimetypes.guess_extension(content_type.split(";", 1)[0].strip()) + if guessed: + return guessed + return ".mp4" + + +def download_media( + url: str, + *, + headers: dict[str, str] | None = None, + timeout: float = 600, + dest_dir: Path | None = None, + filename_hint: str = "", +) -> Path: + """把网关解析出的 vendor 直链流式下载到本地媒体缓存,返回文件路径。 + + 失败抛 :class:`GatewayError`(带用户可读文案)。 + """ + target_dir = dest_dir or media_cache_dir() + request_headers = {k: str(v) for k, v in (headers or {}).items() if v is not None} + try: + with httpx.Client(timeout=timeout, follow_redirects=True) as client: + with client.stream("GET", url, headers=request_headers) as response: + if response.status_code >= 400: + raise GatewayError( + f"媒体直链下载失败(HTTP {response.status_code}),链接可能已过期,请重发分享链接再试。", + status=response.status_code, + ) + suffix = _guess_extension(url, response.headers.get("Content-Type")) + stem = re.sub(r"[^\w-]+", "_", filename_hint).strip("_")[:40] or "media" + dest = target_dir / f"{stem}_{uuid.uuid4().hex[:8]}{suffix}" + with open(dest, "wb") as fh: + for chunk in response.iter_bytes(): + fh.write(chunk) + except GatewayError: + raise + except httpx.HTTPError as exc: + raise GatewayError(f"媒体直链下载失败: {exc}") from exc + if not dest.exists() or dest.stat().st_size == 0: + raise GatewayError("媒体直链下载失败: 得到了空文件,链接可能已失效,请重发分享链接。") + return dest + + +def extract_audio_for_asr(media_path: Path | str, *, timeout: int = 1800) -> Path | None: + """用本地 ffmpeg 抽音轨压缩(16kHz 单声道 AAC),供 multipart 上传转写。 + + ffmpeg 不存在或抽取失败时返回 None(调用方回退上传原文件)—— + 抽音轨是带宽优化(PD §8),不是正确性前提。 + """ + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + return None + source = Path(media_path) + output = media_cache_dir() / f"asr_{uuid.uuid4().hex[:8]}.m4a" + command = [ + ffmpeg, + "-y", + "-i", + str(source), + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-b:a", + "48k", + str(output), + ] + try: + completed = subprocess.run( + command, + capture_output=True, + timeout=timeout, + check=False, + ) + except Exception: # noqa: BLE001 — 抽取失败回退原文件,不是致命错误 + logger.warning("ffmpeg audio extraction crashed; uploading original media", exc_info=True) + return None + if completed.returncode != 0 or not output.exists() or output.stat().st_size == 0: + logger.info("ffmpeg audio extraction failed (rc=%s); uploading original media", completed.returncode) + output.unlink(missing_ok=True) + return None + return output + + +def guess_media_content_type(path: Path | str) -> str: + """multipart 上传时的 Content-Type 猜测(猜不出用 octet-stream)。""" + return mimetypes.guess_type(Path(path).name)[0] or "application/octet-stream" diff --git a/pyproject.toml b/pyproject.toml index b974fb07bca38..8af1be41b3333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -270,6 +270,22 @@ youtube = [ # starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette # transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above. web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.27"] +# 产出类文件导出(Word/Excel/PPT/PDF)——桌面/云能力一致 PD D3:这些库从 +# hermes-cloud 云镜像的单独 bake 行(build_native_agent_image.sh)收编进 +# fork 依赖链,桌面 uv 装机自带、云镜像标准安装,两端 docx/xlsx/pptx/pdf +# 产出能力同源。云端专属的 ddgs / dingtalk-stream 不搬(仍留云镜像)。 +# 版本与原 bake 行一致;pydyf==0.10.0 是 WeasyPrint 62.3 的兼容性 pin +# (hc-330:0.12.x 会破 write_pdf)。这些都是纯 py 轮子,uv sync 全平台 +# 可装;WeasyPrint 在 mac/Windows 缺 Pango/Cairo 系统库时仅 import 失败, +# PDF 导出插件自带可用性探测(系统库/字体仍由云镜像 apt 层提供,桌面 PDF +# 见 PD P2)。 +file-export = [ + "python-docx==1.1.2", + "XlsxWriter==3.2.0", + "python-pptx==1.0.2", + "weasyprint==62.3", + "pydyf==0.10.0", +] all = [ # Policy (2026-05-12): `[all]` includes only extras that genuinely # CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every @@ -302,6 +318,10 @@ all = [ "hermes-agent[google]", "hermes-agent[web]", "hermes-agent[youtube]", + # 产出类文件导出:docx/xlsx/pptx/pdf 是货架能力面(每个会话都可能产出交付 + # 文件),且桌面装机(install.sh 走 [all])必须自带——不能等到会话中途 + # lazy-install(PD D3:两端能力一致由装机时依赖保证)。 + "hermes-agent[file-export]", ] [project.scripts] diff --git a/tests/plugins/test_apexnodes_gateway_tools.py b/tests/plugins/test_apexnodes_gateway_tools.py new file mode 100644 index 0000000000000..abcdb15d787b1 --- /dev/null +++ b/tests/plugins/test_apexnodes_gateway_tools.py @@ -0,0 +1,631 @@ +"""平台工具网关 P1——三个 ApexNodes 工具插件的「桌面形态冒烟」与契约测试。 + +覆盖(DESKTOP-CLOUD-CAPABILITY-PARITY-PD §6 验收守卫): + +* 桌面形态冒烟:vendor env 全空、仅 ``TOOLS_GATEWAY_BASE``+key 的环境里, + apexnodes-douyin-tools / apexnodes-social-tools / apexnodes-video-tools + 三插件经 ``PluginManager.discover_and_load`` 注册成功(14 个工具)。 +* 调用路径指向网关:mock httpx,断言每个工具面的 URL / Authorization / + X-Capability-Version(契约表见 ``plugins/apexnodes_gateway.py``)。 +* 显式错误降级:401(重新登录)/402(额度)/429(限流退避)/503(vendor + 不可用)都返回带解释的 tool_error,不静默吞。 +* 回退通道:``TOOLS_GATEWAY_DISABLED=1`` 时插件走迁移前的 master 内网端点 + (云端 P1 一键回退,PD §8)。 +* 桌面 key 解析:无任何 env 时,从 config.yaml 的 Apex-nodes.com 托管 + custom_providers 条目取 api_key,base 落公网网关。 +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +import types +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PLUGIN_NAMES = ( + "apexnodes-douyin-tools", + "apexnodes-social-tools", + "apexnodes-video-tools", +) +EXPECTED_TOOLS = { + "apexnodes-douyin-tools": { + "social_download", + "media_transcribe", + "image_ocr", + "social_batch_submit", + "social_batch_status", + }, + "apexnodes-social-tools": { + "social_content", + "social_search", + "social_profile", + "social_comments", + "social_trending", + "social_posts", + "social_captions", + "creator_top_posts", + }, + "apexnodes-video-tools": {"generate_video"}, +} + +# 桌面机器上不存在的 vendor/master 侧 env——冒烟前必须全空。 +_VENDOR_AND_MASTER_ENV = ( + "TIKHUB_API_KEY", + "VOLC_ACCESS_KEY", + "VOLC_SECRET_KEY", + "VOLCENGINE_ASR_APP_ID", + "DOUBAO_ASR_APP_ID", + "HERMES_PLATFORM_API_BASE", + "HERMES_MASTER_API_BASE", + "HERMES_SCHEDULER_API_BASE", + "API_SERVER_KEY", + "MODEL_API_KEY", + "TOOLS_GATEWAY_BASE", + "TOOLS_GATEWAY_KEY", + "TOOLS_GATEWAY_DISABLED", +) + +GATEWAY_BASE = "https://gw.test" +GATEWAY_KEY = "test-agent-key" + + +@pytest.fixture(autouse=True) +def _hermes_home(tmp_path, monkeypatch): + """Isolated HERMES_HOME + 干净的 vendor/master/gateway env。""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + for name in _VENDOR_AND_MASTER_ENV: + monkeypatch.delenv(name, raising=False) + yield home + + +@pytest.fixture +def desktop_env(monkeypatch): + """桌面形态:仅网关 base + Agent key,别无其它。""" + monkeypatch.setenv("TOOLS_GATEWAY_BASE", GATEWAY_BASE) + monkeypatch.setenv("TOOLS_GATEWAY_KEY", GATEWAY_KEY) + + +def _gateway_module(): + import plugins.apexnodes_gateway as gateway_module + + return gateway_module + + +def _load_plugin(name: str): + """按 PluginManager 的命名约定加载插件模块(hermes_plugins.)。""" + slug = name.replace("-", "_") + module_name = f"hermes_plugins.{slug}" + plugin_dir = REPO_ROOT / "plugins" / name + if "hermes_plugins" not in sys.modules: + namespace = types.ModuleType("hermes_plugins") + namespace.__path__ = [] # type: ignore[attr-defined] + sys.modules["hermes_plugins"] = namespace + spec = importlib.util.spec_from_file_location( + module_name, + plugin_dir / "__init__.py", + submodule_search_locations=[str(plugin_dir)], + ) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def douyin_mod(): + module = _load_plugin("apexnodes-douyin-tools") + yield module + sys.modules.pop(module.__name__, None) + + +@pytest.fixture +def social_mod(): + module = _load_plugin("apexnodes-social-tools") + yield module + sys.modules.pop(module.__name__, None) + + +@pytest.fixture +def video_mod(): + module = _load_plugin("apexnodes-video-tools") + yield module + sys.modules.pop(module.__name__, None) + + +# --------------------------------------------------------------------------- +# httpx mock(网关调用路径断言的探针) +# --------------------------------------------------------------------------- + + +class FakeResponse: + def __init__(self, status_code=200, body=None, headers=None): + self.status_code = status_code + self._body = body if body is not None else {} + self.headers = headers or {} + self.text = json.dumps(self._body, ensure_ascii=False) + + def json(self): + return self._body + + +class RecordedRequest: + def __init__(self, method, url, kwargs): + self.method = method + self.url = url + self.headers = kwargs.get("headers") or {} + self.json = kwargs.get("json") + self.data = kwargs.get("data") + self.files = kwargs.get("files") + + +@pytest.fixture +def fake_httpx(monkeypatch): + """把 gw 模块用的 httpx.Client 换成录制型 fake;队列供给响应。""" + gateway_module = _gateway_module() + state = types.SimpleNamespace(requests=[], responses=[]) + + class FakeClient: + def __init__(self, *args, **kwargs): + pass + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def request(self, method, url, **kwargs): + state.requests.append(RecordedRequest(method, url, kwargs)) + if not state.responses: + raise AssertionError("FakeClient: no queued response left") + return state.responses.pop(0) + + monkeypatch.setattr(gateway_module.httpx, "Client", FakeClient) + monkeypatch.setattr(gateway_module.time, "sleep", lambda _s: None) + return state + + +def _parse(result: str) -> dict: + return json.loads(result) + + +# --------------------------------------------------------------------------- +# 桌面形态冒烟:vendor env 全空 + 仅网关配置 → 三插件注册成功 +# --------------------------------------------------------------------------- + + +class TestDesktopSmokeRegistration: + def _write_enabled_config(self, hermes_home: Path): + import yaml + + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": list(PLUGIN_NAMES)}}), + encoding="utf-8", + ) + + def test_all_three_plugins_register_with_gateway_env_only( + self, _hermes_home, desktop_env + ): + self._write_enabled_config(_hermes_home) + from hermes_cli import plugins as plugins_module + + manager = plugins_module.PluginManager() + manager.discover_and_load() + for name in PLUGIN_NAMES: + assert name in manager._plugins, f"{name} 未被发现(bundled 扫描)" + loaded = manager._plugins[name] + assert loaded.enabled, f"{name} 未启用: {loaded.error}" + assert loaded.error is None, f"{name} 装载报错: {loaded.error}" + assert set(loaded.tools_registered) == EXPECTED_TOOLS[name] + + def test_gateway_mode_active_in_desktop_env(self, desktop_env): + gateway_module = _gateway_module() + assert gateway_module.use_gateway() is True + assert gateway_module.gateway_base() == GATEWAY_BASE + assert gateway_module.agent_api_key() == GATEWAY_KEY + + def test_check_fns_need_no_network_in_gateway_mode( + self, desktop_env, douyin_mod, social_mod, video_mod + ): + # 注册期可用性=配置齐;不发任何网络探测(桌面离线启动不受影响)。 + assert douyin_mod._check() is True + assert douyin_mod._check_image() is True + assert social_mod._check() is True + assert video_mod._check() is True + + +# --------------------------------------------------------------------------- +# 调用路径指向网关:URL / 头 +# --------------------------------------------------------------------------- + + +class TestGatewayCallSurface: + def test_social_search_url_headers_and_result_face( + self, desktop_env, fake_httpx, social_mod + ): + fake_httpx.responses.append( + FakeResponse(200, {"data": {"items": [{"id": 1}]}, "cost_cents": 2}) + ) + result = _parse( + social_mod._handler("search")({"platform": "douyin", "query": "咖啡"}) + ) + request = fake_httpx.requests[0] + assert request.method == "POST" + assert request.url == f"{GATEWAY_BASE}/tools/v1/social/douyin/search" + assert request.headers["Authorization"] == f"Bearer {GATEWAY_KEY}" + assert request.headers["X-Capability-Version"] + assert request.json["query"] == "咖啡" + assert result["items"] == [{"id": 1}] + assert result["cost_cents"] == 2 + + @pytest.mark.parametrize( + ("capability", "args"), + [ + ("content", {"platform": "bilibili", "url": "https://b23.tv/x"}), + ("profile", {"platform": "xiaohongshu", "user_id": "u1"}), + ("comments", {"platform": "kuaishou", "item_id": "i1"}), + ("trending", {"platform": "tiktok"}), + ("posts", {"platform": "instagram", "user_id": "u2"}), + ("captions", {"platform": "youtube", "url": "https://youtu.be/x"}), + ], + ) + def test_capability_actions_map_one_to_one( + self, desktop_env, fake_httpx, social_mod, capability, args + ): + fake_httpx.responses.append(FakeResponse(200, {"data": {}, "cost_cents": 1})) + social_mod._handler(capability)(args) + assert fake_httpx.requests[0].url == ( + f"{GATEWAY_BASE}/tools/v1/social/{args['platform']}/{capability}" + ) + + def test_platform_whitelist_enforced(self, desktop_env, fake_httpx, social_mod): + result = _parse( + social_mod._handler("search")({"platform": "myspace", "query": "x"}) + ) + assert "不支持的平台" in result["error"] + assert not fake_httpx.requests # 白名单外不出网 + + def test_creator_top_posts_detects_platform_from_url( + self, desktop_env, fake_httpx, social_mod + ): + fake_httpx.responses.append(FakeResponse(200, {"data": {"posts": []}})) + social_mod._creator_handler( + {"url": "https://v.douyin.com/abcd/", "min_likes": 100000} + ) + assert fake_httpx.requests[0].url == ( + f"{GATEWAY_BASE}/tools/v1/social/douyin/top-posts" + ) + assert fake_httpx.requests[0].json["min_likes"] == 100000 + + def test_social_download_resolves_then_fetches_direct_link( + self, desktop_env, fake_httpx, douyin_mod, monkeypatch, tmp_path + ): + gateway_module = _gateway_module() + fake_httpx.responses.append( + FakeResponse( + 200, + { + "data": { + "title": "示例视频", + "download_url": "https://cdn.example/v.mp4", + "download_headers": {"Referer": "https://www.douyin.com/"}, + }, + "cost_cents": 3, + }, + ) + ) + downloaded = tmp_path / "v.mp4" + downloaded.write_bytes(b"fake-video") + seen = {} + + def fake_download(url, *, headers=None, **kwargs): + seen["url"] = url + seen["headers"] = headers + return downloaded + + monkeypatch.setattr(gateway_module, "download_media", fake_download) + result = _parse( + douyin_mod._handle_social_download({"url": "https://v.douyin.com/xyz/"}) + ) + assert fake_httpx.requests[0].url == ( + f"{GATEWAY_BASE}/tools/v1/social/douyin/download" + ) + assert seen["url"] == "https://cdn.example/v.mp4" + assert seen["headers"] == {"Referer": "https://www.douyin.com/"} + assert result["video_path"] == str(downloaded) + assert result["title"] == "示例视频" + assert result["cost_cents"] == 3 + assert "download_headers" not in result # 内部细节不进结果面 + + def test_social_download_unrecognized_platform_is_explicit( + self, desktop_env, fake_httpx, douyin_mod + ): + result = _parse( + douyin_mod._handle_social_download({"url": "https://example.com/foo"}) + ) + assert "无法从链接识别社媒平台" in result["error"] + assert not fake_httpx.requests + + def test_media_transcribe_uploads_local_file_multipart( + self, desktop_env, fake_httpx, douyin_mod, monkeypatch, tmp_path + ): + gateway_module = _gateway_module() + # 桌面无 ffmpeg 的最保守形态:抽音轨失败 → 上传原文件(仍是 multipart)。 + monkeypatch.setattr(gateway_module, "extract_audio_for_asr", lambda _p: None) + video = tmp_path / "clip.mp4" + video.write_bytes(b"fake-bytes") + fake_httpx.responses.append( + FakeResponse( + 200, {"text": "大家好,今天聊三件事。", "duration_seconds": 12.5, "cost_cents": 8} + ) + ) + result = _parse( + douyin_mod._handle_media_transcribe({"video_path": str(video)}) + ) + request = fake_httpx.requests[0] + assert request.url == f"{GATEWAY_BASE}/tools/v1/asr/transcribe" + assert request.files and "file" in request.files # multipart 形态(PD §8) + assert request.json is None + assert request.headers["Authorization"] == f"Bearer {GATEWAY_KEY}" + assert result["transcript"] == "大家好,今天聊三件事。" + assert result["audio_duration_seconds"] == 12.5 + assert result["cost_cents"] == 8 + transcript_path = Path(result["transcript_path"]) + assert transcript_path.exists() + assert transcript_path.read_text(encoding="utf-8") == "大家好,今天聊三件事。" + + def test_media_transcribe_share_url_resolves_then_transcribes( + self, desktop_env, fake_httpx, douyin_mod, monkeypatch, tmp_path + ): + gateway_module = _gateway_module() + downloaded = tmp_path / "d.mp4" + downloaded.write_bytes(b"x") + monkeypatch.setattr( + gateway_module, "download_media", lambda *a, **k: downloaded + ) + monkeypatch.setattr(gateway_module, "extract_audio_for_asr", lambda _p: None) + fake_httpx.responses.extend( + [ + FakeResponse( + 200, + {"data": {"title": "标题", "download_url": "https://cdn.example/d.mp4"}}, + ), + FakeResponse(200, {"text": "转写正文", "duration_seconds": 3.0}), + ] + ) + result = _parse( + douyin_mod._handle_media_transcribe( + {"url": "看看 https://v.douyin.com/abc/ 复制此链接"} + ) + ) + assert [r.url for r in fake_httpx.requests] == [ + f"{GATEWAY_BASE}/tools/v1/social/douyin/download", + f"{GATEWAY_BASE}/tools/v1/asr/transcribe", + ] + assert result["transcript"] == "转写正文" + assert result["title"] == "标题" + assert result["video_path"] == str(downloaded) + + def test_image_ocr_platform_segment_and_fallback( + self, desktop_env, fake_httpx, douyin_mod + ): + fake_httpx.responses.append(FakeResponse(200, {"data": {"texts": ["菜单"]}})) + douyin_mod._handle_image_ocr({"url": "https://xhslink.com/abc"}) + assert fake_httpx.requests[0].url == ( + f"{GATEWAY_BASE}/tools/v1/social/xiaohongshu/image-ocr" + ) + # 裸图片 URL 识别不出平台 → 回退 douyin 路由段(契约表注明)。 + fake_httpx.responses.append(FakeResponse(200, {"data": {"texts": []}})) + douyin_mod._handle_image_ocr({"image_urls": ["https://cdn.example/a.jpg"]}) + assert fake_httpx.requests[1].url == ( + f"{GATEWAY_BASE}/tools/v1/social/douyin/image-ocr" + ) + + def test_batch_submit_and_status_endpoints( + self, desktop_env, fake_httpx, douyin_mod + ): + fake_httpx.responses.append( + FakeResponse(200, {"data": {"job_id": "j1", "status": "queued"}}) + ) + submit = _parse( + douyin_mod._handle_social_batch_submit( + {"urls": ["https://v.douyin.com/a/"]} + ) + ) + assert fake_httpx.requests[0].method == "POST" + assert fake_httpx.requests[0].url == ( + f"{GATEWAY_BASE}/tools/v1/social/batch/submit" + ) + # 桌面形态没有 IM 会话 env → delivery_target=None → 轮询模式提示。 + assert fake_httpx.requests[0].json["delivery_target"] is None + assert submit["job_id"] == "j1" + assert "social_batch_status" in submit["_instruction"] + + fake_httpx.responses.append( + FakeResponse(200, {"data": {"status": "completed", "product": {}}}) + ) + status = _parse(douyin_mod._handle_social_batch_status({"job_id": "j1"})) + assert fake_httpx.requests[1].method == "GET" + assert fake_httpx.requests[1].url == ( + f"{GATEWAY_BASE}/tools/v1/social/batch/status/j1" + ) + assert "任务已完成" in status["_instruction"] + + def test_generate_video_gateway_endpoint( + self, desktop_env, fake_httpx, video_mod + ): + fake_httpx.responses.append( + FakeResponse( + 200, + {"data": {"ok": True, "video_url": "https://cdn.example/o.mp4"}, "cost_cents": 40}, + ) + ) + result = _parse( + video_mod._handle_generate_video({"prompt": "一只猫在弹钢琴"}) + ) + request = fake_httpx.requests[0] + assert request.url == f"{GATEWAY_BASE}/tools/v1/video/generate" + assert request.json["prompt"] == "一只猫在弹钢琴" + assert request.headers["X-Capability-Version"] + assert result["video_url"] == "https://cdn.example/o.mp4" + assert result["cost_cents"] == 40 + + +# --------------------------------------------------------------------------- +# 显式错误降级(401/402/429/503) +# --------------------------------------------------------------------------- + + +class TestExplicitErrorDegradation: + @pytest.mark.parametrize( + ("status", "marker"), + [ + (401, "重新登录"), + (402, "额度不足"), + (503, "平台能力暂不可用"), + ], + ) + def test_http_errors_map_to_explicit_chinese_guidance( + self, desktop_env, fake_httpx, social_mod, status, marker + ): + fake_httpx.responses.append( + FakeResponse(status, {"detail": {"message": "server says no"}}) + ) + result = _parse( + social_mod._handler("search")({"platform": "douyin", "query": "x"}) + ) + assert str(status) in result["error"] + assert marker in result["error"] + assert "server says no" in result["error"] # 服务端 detail 透传,不吞 + + def test_429_bounded_backoff_then_explicit_message( + self, desktop_env, fake_httpx, social_mod + ): + gateway_module = _gateway_module() + fake_httpx.responses.extend( + [FakeResponse(429, {}, headers={"Retry-After": "0"})] + * (gateway_module._MAX_429_RETRIES + 1) + ) + result = _parse( + social_mod._handler("search")({"platform": "douyin", "query": "x"}) + ) + # 初次 + 2 次退避重试,仍限流 → 显式文案。 + assert len(fake_httpx.requests) == gateway_module._MAX_429_RETRIES + 1 + assert "429" in result["error"] + assert "频繁" in result["error"] + + def test_missing_key_is_explicit_not_silent(self, monkeypatch, fake_httpx, social_mod): + monkeypatch.setenv("TOOLS_GATEWAY_BASE", GATEWAY_BASE) + result = _parse( + social_mod._handler("search")({"platform": "douyin", "query": "x"}) + ) + assert "缺少平台密钥" in result["error"] + assert not fake_httpx.requests + + +# --------------------------------------------------------------------------- +# 回退通道:TOOLS_GATEWAY_DISABLED=1 → 迁移前 master 直连路径 +# --------------------------------------------------------------------------- + + +class _FakeUrlopenResponse: + def __init__(self, body: dict): + self._body = json.dumps(body).encode("utf-8") + + def read(self): + return self._body + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +class TestGatewayDisabledFallback: + @pytest.fixture + def legacy_env(self, monkeypatch, desktop_env): + monkeypatch.setenv("TOOLS_GATEWAY_DISABLED", "1") + monkeypatch.setenv("HERMES_PLATFORM_API_BASE", "http://master.test/api/v1") + monkeypatch.setenv("API_SERVER_KEY", "legacy-key") + + @pytest.fixture + def fake_urlopen(self, monkeypatch): + calls = [] + + def _fake(request, timeout=None): + calls.append(request) + return _FakeUrlopenResponse({"ok": True, "items": []}) + + monkeypatch.setattr("urllib.request.urlopen", _fake) + return calls + + def test_social_search_falls_back_to_master_endpoint( + self, legacy_env, fake_urlopen, fake_httpx, social_mod + ): + social_mod._handler("search")({"platform": "douyin", "query": "x"}) + assert fake_urlopen[0].full_url == "http://master.test/api/v1/data/social/search" + assert fake_urlopen[0].get_header("Authorization") == "Bearer legacy-key" + assert not fake_httpx.requests # 一个字节都不走网关 + + def test_social_download_falls_back_to_master_endpoint( + self, legacy_env, fake_urlopen, fake_httpx, douyin_mod + ): + douyin_mod._handle_social_download({"url": "https://v.douyin.com/abc/"}) + assert fake_urlopen[0].full_url == "http://master.test/api/v1/media/social-download" + assert not fake_httpx.requests + + def test_generate_video_falls_back_to_master_endpoint( + self, legacy_env, fake_urlopen, fake_httpx, video_mod + ): + video_mod._handle_generate_video({"prompt": "x"}) + assert fake_urlopen[0].full_url == "http://master.test/api/v1/media/video-generate" + assert not fake_httpx.requests + + def test_no_gateway_config_at_all_behaves_like_before_migration(self, social_mod): + # 云容器零新 env(base/key 都没有)时不进入网关模式——行为与迁移前一致。 + gateway_module = _gateway_module() + assert gateway_module.use_gateway() is False + + +# --------------------------------------------------------------------------- +# 桌面 key 解析:config.yaml 托管 custom_providers 条目(provision-key 写入) +# --------------------------------------------------------------------------- + + +class TestDesktopManagedKeyResolution: + def test_key_and_base_resolve_from_managed_custom_provider(self, _hermes_home): + import yaml + + (Path(_hermes_home) / "config.yaml").write_text( + yaml.safe_dump( + { + "custom_providers": [ + { + "name": "Apex-nodes.com", + "base_url": "https://apex-nodes.com/relay/v1", + "model": "deepseek-v4-pro-APEX", + "api_key": "sk-desktop-123", + } + ] + } + ), + encoding="utf-8", + ) + gateway_module = _gateway_module() + assert gateway_module.agent_api_key() == "sk-desktop-123" + assert gateway_module.gateway_base() == gateway_module.GATEWAY_PUBLIC_BASE + assert gateway_module.use_gateway() is True + + def test_env_key_wins_over_managed_config(self, _hermes_home, monkeypatch): + monkeypatch.setenv("TOOLS_GATEWAY_KEY", "env-key") + monkeypatch.setenv("TOOLS_GATEWAY_BASE", GATEWAY_BASE) + gateway_module = _gateway_module() + assert gateway_module.agent_api_key() == "env-key" + assert gateway_module.gateway_base() == GATEWAY_BASE diff --git a/uv.lock b/uv.lock index b51daeca10d3e..bae1a01c68f10 100644 --- a/uv.lock +++ b/uv.lock @@ -509,6 +509,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/f1/90a7b8eda38b7c3a65ca7ee0075bdf310b6b471cb1b95fab6e8994323a50/botocore-1.42.89-py3-none-any.whl", hash = "sha256:d9b786c8d9db6473063b4cc5be0ba7e6a381082307bd6afb69d4216f9fa95f35", size = 14887287, upload-time = "2026-04-13T19:35:56.677Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, +] + [[package]] name = "brotlicffi" version = "1.2.0.1" @@ -764,6 +802,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" }, ] +[[package]] +name = "cssselect2" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/20/92eaa6b0aec7189fa4b75c890640e076e9e793095721db69c5c81142c2e1/cssselect2-0.9.0.tar.gz", hash = "sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb", size = 35595, upload-time = "2026-02-12T17:16:39.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, +] + [[package]] name = "ctranslate2" version = "4.7.1" @@ -1241,6 +1292,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "zopfli" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1563,10 +1654,15 @@ all = [ { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "mcp" }, + { name = "pydyf" }, + { name = "python-docx" }, { name = "python-multipart" }, + { name = "python-pptx" }, { name = "simple-term-menu" }, { name = "starlette" }, { name = "uvicorn", extra = ["standard"] }, + { name = "weasyprint" }, + { name = "xlsxwriter" }, { name = "youtube-transcript-api" }, ] anthropic = [ @@ -1616,6 +1712,13 @@ feishu = [ { name = "lark-oapi" }, { name = "qrcode" }, ] +file-export = [ + { name = "pydyf" }, + { name = "python-docx" }, + { name = "python-pptx" }, + { name = "weasyprint" }, + { name = "xlsxwriter" }, +] firecrawl = [ { name = "firecrawl-py" }, ] @@ -1775,6 +1878,7 @@ requires-dist = [ { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, + { name = "hermes-agent", extras = ["file-export"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, @@ -1815,12 +1919,15 @@ requires-dist = [ { name = "psutil", specifier = "==7.2.2" }, { name = "ptyprocess", marker = "sys_platform != 'win32'", specifier = ">=0.7.0,<1" }, { name = "pydantic", specifier = "==2.13.4" }, + { name = "pydyf", marker = "extra == 'file-export'", specifier = "==0.10.0" }, { name = "pyjwt", extras = ["crypto"], specifier = "==2.13.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" }, + { name = "python-docx", marker = "extra == 'file-export'", specifier = "==1.1.2" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "python-multipart", specifier = ">=0.0.9,<1" }, { name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.27" }, + { name = "python-pptx", marker = "extra == 'file-export'", specifier = "==1.0.2" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" }, { name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" }, { name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" }, @@ -1850,10 +1957,12 @@ requires-dist = [ { name = "urllib3", specifier = ">=2.7.0,<3" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0,<1" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = "==0.41.0" }, + { name = "weasyprint", marker = "extra == 'file-export'", specifier = "==62.3" }, { name = "websockets", specifier = "==15.0.1" }, + { name = "xlsxwriter", marker = "extra == 'file-export'", specifier = "==3.2.0" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "file-export", "all"] [[package]] name = "hf-xet" @@ -1919,6 +2028,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] +[[package]] +name = "html5lib" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/b6/b55c3f49042f1df3dcd422b7f224f939892ee94f22abcf503a9b7339eaf2/html5lib-1.1.tar.gz", hash = "sha256:b2e5b40261e20f354d198eae92afc10d750afb487ed5e50f9c4eaf07c184146f", size = 272215, upload-time = "2020-06-22T23:32:38.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/dd/a834df6482147d48e225a49515aabc28974ad5a4ca3215c18a882565b028/html5lib-1.1-py2.py3-none-any.whl", hash = "sha256:0d78f8fde1c230e99fe37986a60526d7049ed4bf8a9fadbad5f00e22e58e041d", size = 112173, upload-time = "2020-06-22T23:32:36.781Z" }, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -2197,6 +2319,72 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" }, ] +[[package]] +name = "lxml" +version = "6.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/3b/aab6728cae887456f409b4d75e8a01856e4f04bd510de38052a47768b680/lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40", size = 4197430, upload-time = "2026-05-18T19:19:06.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/b0/83f481780d1548750b8ce2ec824073deef2f452d9cd1a6faff8507e3d16d/lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2", size = 8526461, upload-time = "2026-05-18T19:17:25.862Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/30fa0f808002c7329397bfbb24e306789c0b29f04aa5842c07b174b4216f/lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d", size = 4595375, upload-time = "2026-05-18T19:17:34.555Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d2/edb71cf0e561581a7c5eb2626244320eb04e9f8ce6d563184fd668b45073/lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510", size = 4923654, upload-time = "2026-05-18T19:17:42.917Z" }, + { url = "https://files.pythonhosted.org/packages/4c/77/1bc7eeb0de4577d783fb625aa092cc9357883bba35845a3666bf1259f3dc/lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a", size = 5067921, upload-time = "2026-05-18T19:17:49.175Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3c/c0690d74bd2bc17bc03b5b0d093569ead597dd0bfa088bf99eef8c24e19c/lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d", size = 5002456, upload-time = "2026-05-18T19:17:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/66/8d/d1b3271af0c0f1e27e8472a849e4d2c65bc7766884b9ad2da9e76e145c88/lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8", size = 5202776, upload-time = "2026-05-18T19:18:08.924Z" }, + { url = "https://files.pythonhosted.org/packages/7a/45/689824ffb237fd10125ad273f32b28ff04dc6203c2822c85ff65a93df65e/lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009", size = 5329945, upload-time = "2026-05-18T19:18:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/5d/c0/ef73af53767e958fd87d437c170f272e2f6e6c0f854939f133a895f1e711/lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6", size = 4659237, upload-time = "2026-05-18T19:18:18.657Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/e1158e40397585e91cb0472374a1f63d0926a1ddeaa92f13d1a1ffe306d5/lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8", size = 5265904, upload-time = "2026-05-18T19:18:24.883Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/8687e5d1400ed1c0bc41dace232ebb7553952b618ea1f2e5fb6e2cfbbe23/lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83", size = 5045225, upload-time = "2026-05-18T19:17:20.073Z" }, + { url = "https://files.pythonhosted.org/packages/ca/18/d877bd1ae2e5ffdfd4836565aba350db31feb2f2656d6ce70316ed66a05e/lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6", size = 4712721, upload-time = "2026-05-18T19:17:40.512Z" }, + { url = "https://files.pythonhosted.org/packages/44/4d/1f44fd1d770b10dacbf6b5c6e520f4d6e0708744930f719dc04e67cab981/lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c", size = 5252549, upload-time = "2026-05-18T19:17:51.236Z" }, + { url = "https://files.pythonhosted.org/packages/64/5d/1d66b84f850089254c230ef6ea6b267a5a54e2e179a5d960036a05d501d7/lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08", size = 5226877, upload-time = "2026-05-18T19:18:00.875Z" }, + { url = "https://files.pythonhosted.org/packages/ad/00/84c4b5302d42a2d0184f38d538c8a197f33b52a50bd4f7bcfe990bce3036/lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621", size = 3594072, upload-time = "2026-05-18T19:17:12.714Z" }, + { url = "https://files.pythonhosted.org/packages/61/9d/2e2f7d876349f45e0f3e29f72da311668853d59b58d473a2dea4f0160135/lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28", size = 4025469, upload-time = "2026-05-18T19:17:50.566Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/570e6390e4110331e6208b2ba83d1482cc9146808ee118b22824a34c1070/lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b", size = 3667640, upload-time = "2026-05-19T19:22:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6e/c4add832b6fc1e887125b96f880d7b9b70aae5248718e046b1704bcac4b9/lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7", size = 8570821, upload-time = "2026-05-18T19:17:42.068Z" }, + { url = "https://files.pythonhosted.org/packages/22/00/ff3009c88e65de8011630acf8ab5a09cb2becd2aaf47fba2f3449f6224e9/lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1", size = 4624252, upload-time = "2026-05-18T19:17:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/bb63f0fd62e554fe078e1fb3c8fe9083c14ddc7ad7fa178d10e57e071ac7/lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc", size = 4930746, upload-time = "2026-05-18T19:18:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/eb/99/0013e8d9b5960f4f041cf0b73e2f80c23eb5205b1f7bfb20203243651359/lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc", size = 5093723, upload-time = "2026-05-18T19:18:34.168Z" }, + { url = "https://files.pythonhosted.org/packages/29/91/317b332636bfc7bddcff828d41b3307f50043f4b237e40849c333d80fa1a/lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383", size = 5005557, upload-time = "2026-05-18T19:18:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/42/2f/cc9bf06afe70f9c9093ae60855d9759da9db601ec4080f7473319666ffd7/lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b", size = 5631036, upload-time = "2026-05-18T19:18:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/08/f6/af32e23e563971ffb0fb86be52bc5be5c2c118858ffc119bf6a9039b173d/lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818", size = 5240367, upload-time = "2026-05-18T19:18:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/78/83/8555d40948b09ce86f1bd0c68a7ac31d07b1929f92cc1b074006c97ef2d2/lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740", size = 5350171, upload-time = "2026-05-18T19:18:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/63/75/5d92da93729b7bad783689e6496049fa40927b45bec7bf183c981de3ca70/lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f", size = 4694874, upload-time = "2026-05-18T19:18:55.139Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b5/3aad415a9a25b822e783f15deeb4dffccf5113030f1afa2222dd929313d9/lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2", size = 5244492, upload-time = "2026-05-18T19:19:01.28Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a1/5fcf7eb9904b80086aa47dcf0027de07b1bb990afad2e6823144c368ae04/lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635", size = 5048232, upload-time = "2026-05-18T19:18:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/1f601b63c7a69fcdf10fa9b148c81da8442204194f6c55509cc485c786b9/lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf", size = 4777023, upload-time = "2026-05-18T19:18:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/a2/b9/7a78f51aec95b1bf780d78e12705a9f6533284f8693dc5c0e6724fa53d3f/lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc", size = 5645773, upload-time = "2026-05-18T19:18:23.223Z" }, + { url = "https://files.pythonhosted.org/packages/a5/6e/98a7b7ad54e4e74fa1f20fff776913980619d0ebe5558232d7da6580bdd8/lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955", size = 5233088, upload-time = "2026-05-18T19:18:31.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/d1/bc0ed2427bf609f2ee10da303a6a226f9c8bce94f945dc29a32ce55de6e4/lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a", size = 5260995, upload-time = "2026-05-18T19:18:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/69/8b/6772e1a4b513fc50a8d931f19edde0e13ae6918510a1e13ff67864f3e5ed/lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a", size = 3596382, upload-time = "2026-05-18T19:17:18.37Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/45198e9624762af2dfd2cb8782598477ceb29f6e59caab560388ae1f4ec1/lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77", size = 3997255, upload-time = "2026-05-18T19:17:56.781Z" }, + { url = "https://files.pythonhosted.org/packages/90/a9/7a54b6834088d9ae528a7b780584ba6a39a9457b0ac330479f20ffbc9449/lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f", size = 3659610, upload-time = "2026-05-19T19:22:50.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/7e6f37c5584ccbb2ff267f56fd0339016938c1c8684cfefab9b33ffc2f36/lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736", size = 8559780, upload-time = "2026-05-18T19:17:57.661Z" }, + { url = "https://files.pythonhosted.org/packages/a1/36/587c2521cf23a2cd6c9c22108aa7528f683a1f195ed7ccd23a4b1786ad36/lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9", size = 4618006, upload-time = "2026-05-18T19:18:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ca/ab7bfe2bf4c972af5e7878262845ead3a24a929a9b04bc11c7c1ece6c82a/lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354", size = 4924139, upload-time = "2026-05-18T19:19:04.873Z" }, + { url = "https://files.pythonhosted.org/packages/6b/55/a0c72851dfee5ecc689f949723a73dea457758912542cb955b108eaf0d8f/lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca", size = 5082329, upload-time = "2026-05-18T19:19:09.728Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b6/0608f7d61a3b96cc67e5648a3d906e31a5082093e10e7be65b3886289938/lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099", size = 4993564, upload-time = "2026-05-18T19:19:13.608Z" }, + { url = "https://files.pythonhosted.org/packages/4c/66/ae227524b066d29d55bf0b453d93d2d793c40218657d643dcbbca13b8faf/lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6", size = 5613467, upload-time = "2026-05-18T19:19:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/a6/76/dbe4a00b50385e40194231dcfe5a12c059de7cf90e89c83407d2b085b719/lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085", size = 5228304, upload-time = "2026-05-18T19:19:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/1c/01/00b1b8442ed2041793336868ba0b9ea4b13d7da7c085c6404c207a63bf79/lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e", size = 5341607, upload-time = "2026-05-18T19:19:22.297Z" }, + { url = "https://files.pythonhosted.org/packages/63/36/1ad29931e9a4638bb707869f01d423a6c815f82152138d1a40dfcfde2b95/lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f", size = 4700168, upload-time = "2026-05-18T19:19:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d1/a9536cecf9be18a0dc72d32bead283a2332d1ffebd2dd3ac70ce444686e5/lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c", size = 5232487, upload-time = "2026-05-18T19:19:28.603Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/b4fb1e03bf5d130e879214d3100092e386418807fb74dd0adc4b0a48f351/lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b", size = 5044231, upload-time = "2026-05-18T19:18:42.246Z" }, + { url = "https://files.pythonhosted.org/packages/26/4c/d00daeeb0a5530c4028a9232aa1b93db3ef4ed2158c116ea73c79a9765b3/lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2", size = 4769450, upload-time = "2026-05-18T19:18:48.013Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/715a3a8d156ce42f29cf014706f5410c2ff3b02267774110fc23266409fe/lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5", size = 5635874, upload-time = "2026-05-18T19:18:51.914Z" }, + { url = "https://files.pythonhosted.org/packages/45/37/0544bc21dde2a88f3a17b504e6fc79c0e01d25a33c2f6079724e9e72b9c7/lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785", size = 5223987, upload-time = "2026-05-18T19:18:59.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/f6a5e8185bcb28c2befae3d31f8e3df3b811cb0f47746517a81279fcafe1/lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947", size = 5250276, upload-time = "2026-05-18T19:19:03.834Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f2/1a2b9f1b7a49d45495369be7ef9ad05b262930f2eab3e3145706fca8083f/lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca", size = 3596903, upload-time = "2026-05-18T19:17:29.863Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/f4ffb024f238eec2131aaa09f3278fb6129cf892741bf68e1fc1afb8c100/lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660", size = 3995869, upload-time = "2026-05-18T19:18:02.596Z" }, + { url = "https://files.pythonhosted.org/packages/d1/53/70eb8c5c6037f27448f1e3c54ebede9545a801ae63f0a7254afca4fe8e45/lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc", size = 3658490, upload-time = "2026-05-19T19:22:53.846Z" }, + { url = "https://files.pythonhosted.org/packages/b5/32/86a3f0f724a3a402d4627937a7fc27b160e45e7012b4adf47f6e1e844511/lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e", size = 3930127, upload-time = "2026-05-18T19:19:02.27Z" }, + { url = "https://files.pythonhosted.org/packages/40/44/d832e82af08723761556d004b1d04d281c09f9a8cecd7d3148548c9941a3/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004", size = 4210769, upload-time = "2026-05-18T19:20:41.427Z" }, + { url = "https://files.pythonhosted.org/packages/6d/39/0dc5949f759ed7d951e0bb8c2f2d9d7aca1908d22352fa84a8afd2ea54af/lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e", size = 4318163, upload-time = "2026-05-18T19:20:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fb/8ab3845fe046ba4cbf74536bcf6801a774b7caf4350de1c5d37f1f0a9e90/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2", size = 4250945, upload-time = "2026-05-18T19:20:47.385Z" }, + { url = "https://files.pythonhosted.org/packages/68/1b/7553ab136894374ffae8851ec06f98f511cd8e66246e41b6be059d0a7289/lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf", size = 4401664, upload-time = "2026-05-18T19:20:50.489Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/441aee36c6f6b249823d20fd91f9be9ab89d7c5a8ae542a4a4ca6d342d56/lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84", size = 3508989, upload-time = "2026-05-18T19:18:38.158Z" }, +] + [[package]] name = "markdown" version = "3.10.2" @@ -3349,6 +3537,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] +[[package]] +name = "pydyf" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/0b/389051bbc9decfa057a617c0a676883a674c2ecc2c0cef875e5502e35b77/pydyf-0.10.0.tar.gz", hash = "sha256:357194593efaf61d7b48ab97c3d59722114934967c3df3d7878ca6dd25b04c30", size = 17615, upload-time = "2024-04-29T09:55:55.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/b3/4b87b0edaa7f2f94c131d01697f936748fc7f74c1ca9622ada372f7256e8/pydyf-0.10.0-py3-none-any.whl", hash = "sha256:ef76b6c0976a091a9e15827fb5800e5e37e7cd1a3ca4d4bd19d10a14ea8c0ae3", size = 8115, upload-time = "2024-04-29T09:55:53.036Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -3401,6 +3598,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyphen" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/56/e4d7e1bd70d997713649c5ce530b2d15a5fc2245a74ca820fc2d51d89d4d/pyphen-0.17.2.tar.gz", hash = "sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3", size = 2079470, upload-time = "2025-01-20T13:18:36.296Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/1f/c2142d2edf833a90728e5cdeb10bdbdc094dde8dbac078cee0cf33f5e11b/pyphen-0.17.2-py3-none-any.whl", hash = "sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd", size = 2079358, upload-time = "2025-01-20T13:18:29.629Z" }, +] + [[package]] name = "pypng" version = "0.20220715.0" @@ -3451,6 +3657,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-docx" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/e4/386c514c53684772885009c12b67a7edd526c15157778ac1b138bc75063e/python_docx-1.1.2.tar.gz", hash = "sha256:0cf1f22e95b9002addca7948e16f2cd7acdfd498047f1941ca5d293db7762efd", size = 5656581, upload-time = "2024-05-01T19:41:57.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/3d/330d9efbdb816d3f60bf2ad92f05e1708e4a1b9abe80461ac3444c83f749/python_docx-1.1.2-py3-none-any.whl", hash = "sha256:08c20d6058916fb19853fcf080f7f42b6270d89eac9fa5f8c15f691c0017fabe", size = 244315, upload-time = "2024-05-01T19:41:47.006Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -3486,6 +3705,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/93/f6729f10149305262194774d6c8b438c0b084740cf239f48ab97b4df02fa/python_olm-3.2.16-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a5e68a2f4b5a2bfa5fdb5dbfa22396a551730df6c4a572235acaa96e997d3f", size = 297000, upload-time = "2023-11-28T19:25:31.045Z" }, ] +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + [[package]] name = "python-socks" version = "2.8.1" @@ -4070,6 +4304,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, ] +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + [[package]] name = "tokenizers" version = "0.22.2" @@ -4395,6 +4641,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "weasyprint" +version = "62.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cssselect2" }, + { name = "fonttools", extra = ["woff"] }, + { name = "html5lib" }, + { name = "pillow" }, + { name = "pydyf" }, + { name = "pyphen" }, + { name = "tinycss2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/22/2d76310cd2ea5bbf03c691a08d48626f49853b7261a51bbdc0f834d746ca/weasyprint-62.3.tar.gz", hash = "sha256:8d8680d732f7fa0fcbc587692a5a5cb095c3525627066918d6e203cbf42b7fcd", size = 477181, upload-time = "2024-06-21T15:48:34.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/d1/10f4225bec3450e7c0aa5813697ce76eb2dfa1f3c5e530510253b6b276b2/weasyprint-62.3-py3-none-any.whl", hash = "sha256:d31048646ce15084e135b33e334a61f526aa68d2f679fcc109ed0e0f5edaed21", size = 289333, upload-time = "2024-06-21T15:48:30.932Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -4476,6 +4750,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, ] +[[package]] +name = "xlsxwriter" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/c3/b36fa44a0610a0f65d2e65ba6a262cbe2554b819f1449731971f7c16ea3c/XlsxWriter-3.2.0.tar.gz", hash = "sha256:9977d0c661a72866a61f9f7a809e25ebbb0fb7036baa3b9fe74afcfca6b3cb8c", size = 198732, upload-time = "2024-02-18T23:11:24.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/ea/53d1fe468e63e092cf16e2c18d16f50c29851242f9dd12d6a66e0d7f0d02/XlsxWriter-3.2.0-py3-none-any.whl", hash = "sha256:ecfd5405b3e0e228219bcaf24c2ca0915e012ca9464a14048021d21a995d490e", size = 159925, upload-time = "2024-02-18T23:11:21.559Z" }, +] + [[package]] name = "yarl" version = "1.22.0" @@ -4575,3 +4858,23 @@ sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50e wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, ] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" }, +]