From 5fc195f71d2282034e2f1db795eb5541f698dacb Mon Sep 17 00:00:00 2001 From: Mayorz Date: Wed, 4 Mar 2026 14:55:14 +0800 Subject: [PATCH] feat: add dingtalk excel image-link file workflow --- remote_sync/bot.py | 238 ++++++++++++++- .../dingtalk-excel-image-return/SKILL.md | 68 +++++ .../agents/openai.yaml | 4 + .../references/dingtalk-workflow.md | 49 ++++ .../scripts/convert_excel_image_links.py | 275 ++++++++++++++++++ 5 files changed, 630 insertions(+), 4 deletions(-) create mode 100644 remote_sync/skills/dingtalk-excel-image-return/SKILL.md create mode 100644 remote_sync/skills/dingtalk-excel-image-return/agents/openai.yaml create mode 100644 remote_sync/skills/dingtalk-excel-image-return/references/dingtalk-workflow.md create mode 100755 remote_sync/skills/dingtalk-excel-image-return/scripts/convert_excel_image_links.py diff --git a/remote_sync/bot.py b/remote_sync/bot.py index 5684e97..b69f35d 100755 --- a/remote_sync/bot.py +++ b/remote_sync/bot.py @@ -12,6 +12,7 @@ import mimetypes import os import re import subprocess +import sys import tempfile import time import urllib.parse @@ -296,6 +297,22 @@ class OpenClawDingTalkBot(ChatbotHandler): self.video_analysis_timeout = max(15, min(int(os.getenv("VIDEO_ANALYSIS_TIMEOUT", "360")), 900)) self.video_analysis_max_tokens = max(256, min(int(os.getenv("VIDEO_ANALYSIS_MAX_TOKENS", "1800")), 8192)) self.video_analysis_max_urls = max(1, min(int(os.getenv("VIDEO_ANALYSIS_MAX_URLS", "2")), 3)) + self.excel_link_feature_enabled = bool_env("EXCEL_IMAGE_LINK_FEATURE_ENABLED", True) + self.excel_converter_script = ( + os.getenv( + "EXCEL_IMAGE_CONVERTER_SCRIPT", + "/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py", + ).strip() + or "/opt/openclaw-dingtalk-bridge/skills/dingtalk-excel-image-return/convert_excel_image_links.py" + ) + self.excel_converter_timeout = max(60, min(int(os.getenv("EXCEL_IMAGE_CONVERTER_TIMEOUT", "480")), 3600)) + self.excel_link_column = os.getenv("EXCEL_IMAGE_LINK_COLUMN", "图片链接").strip() or "图片链接" + self.excel_image_column_header = os.getenv("EXCEL_IMAGE_COLUMN_HEADER", "图片").strip() or "图片" + self.excel_image_max_width = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_WIDTH", "140")), 1200)) + self.excel_image_max_height = max(80, min(int(os.getenv("EXCEL_IMAGE_MAX_HEIGHT", "140")), 1200)) + self.excel_image_download_timeout = max(3, min(int(os.getenv("EXCEL_IMAGE_DOWNLOAD_TIMEOUT", "20")), 120)) + self.excel_file_download_timeout = max(5, min(int(os.getenv("EXCEL_FILE_DOWNLOAD_TIMEOUT", "60")), 300)) + self.excel_skip_tls_verify = bool_env("EXCEL_IMAGE_SKIP_TLS_VERIFY", False) def _should_handle(self, msg: ChatbotMessage, text: str) -> bool: if text.startswith(self.prefix): @@ -1401,6 +1418,72 @@ class OpenClawDingTalkBot(ChatbotHandler): break return codes + def _normalize_file_name(self, raw_name: str) -> str: + name = str(raw_name or "").strip().replace("\\", "_").replace("/", "_") + name = re.sub(r"\s+", "_", name) + name = re.sub(r"[^0-9A-Za-z._-]+", "_", name).strip("._") + if not name: + return f"upload_{int(time.time())}.xlsx" + return name[:120] + + def _is_supported_excel_file(self, file_name: str) -> bool: + return str(file_name or "").strip().lower().endswith(".xlsx") + + def _find_download_code_payload(self, value): + stack = [value] + while stack: + current = stack.pop() + if current is None: + continue + if isinstance(current, str): + content = current.strip() + if not content: + continue + if content.startswith("{") or content.startswith("["): + try: + stack.append(json.loads(content)) + continue + except Exception: + continue + if isinstance(current, dict): + lowered = {str(k).lower(): k for k in current.keys()} + if "downloadcode" in lowered: + raw_code = current.get(lowered["downloadcode"]) + code = str(raw_code or "").strip() + if code: + file_name = "" + for candidate in ("fileName", "filename", "file_name", "name", "title"): + if candidate in current and str(current.get(candidate) or "").strip(): + file_name = str(current.get(candidate)).strip() + break + return {"download_code": code, "file_name": file_name} + for child in current.values(): + stack.append(child) + continue + if isinstance(current, list): + for child in current: + stack.append(child) + return None + + def _extract_incoming_file_payload(self, callback_data: dict, msg: ChatbotMessage) -> dict: + if str(getattr(msg, "message_type", "")).lower() != "file": + return {} + payload = self._find_download_code_payload(callback_data) + if not payload: + payload = self._find_download_code_payload(getattr(msg, "extensions", {})) + if not payload: + return {} + file_name = payload.get("file_name") or "" + if not file_name and isinstance(callback_data, dict): + for key in ("fileName", "filename", "name", "title"): + raw = callback_data.get(key) + if raw: + file_name = str(raw).strip() + if file_name: + break + payload["file_name"] = self._normalize_file_name(file_name or "upload.xlsx") + return payload + def _guess_image_ext(self, content_type: str, source_url: str) -> str: ctype = (content_type or "").split(";")[0].strip().lower() ext = "" @@ -2079,6 +2162,134 @@ class OpenClawDingTalkBot(ChatbotHandler): raw = (proc.stdout or "").strip() return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}" + def _reply_file_by_media_id(self, msg: ChatbotMessage, media_id: str) -> bool: + if not media_id: + return False + request_headers = { + "Content-Type": "application/json", + "Accept": "*/*", + } + values = { + "msgtype": "file", + "file": { + "media_id": media_id, + }, + "at": { + "atUserIds": [msg.sender_staff_id] if msg.sender_staff_id else [], + }, + } + try: + response_text = "" + response = requests.post( + msg.session_webhook, + headers=request_headers, + data=json.dumps(values), + timeout=20, + ) + response_text = response.text + response.raise_for_status() + except Exception as exc: + self.logger.error("reply file failed, error=%s, response.text=%s", exc, response_text) + return False + return True + + def _run_excel_image_link_job(self, msg: ChatbotMessage, file_payload: dict) -> tuple[str, bool]: + if not self.excel_link_feature_enabled: + return "Excel 图片链接转图片功能未启用。", False + script_path = self.excel_converter_script + if not os.path.exists(script_path): + return f"转换脚本不存在:{script_path}", False + + download_code = str(file_payload.get("download_code") or "").strip() + file_name = self._normalize_file_name(file_payload.get("file_name") or "upload.xlsx") + if not download_code: + return "未获取到文件下载码,请重试上传。", False + if not self._is_supported_excel_file(file_name): + return "当前仅支持 .xlsx 文件,请上传Excel后重试。", False + + download_url = self.get_image_download_url(download_code) + if not download_url: + return "文件下载地址获取失败,请重试。", False + + with tempfile.TemporaryDirectory(prefix="dingtalk_excel_link_") as tmpdir: + input_path = os.path.join(tmpdir, file_name) + output_name = f"{os.path.splitext(file_name)[0]}_图片回填.xlsx" + output_path = os.path.join(tmpdir, output_name) + try: + response = requests.get(download_url, timeout=self.excel_file_download_timeout) + response.raise_for_status() + with open(input_path, "wb") as fh: + fh.write(response.content) + except Exception as exc: + self.logger.exception("download incoming excel failed") + return f"下载源文件失败:{exc}", False + + cmd = [ + sys.executable, + script_path, + "--input", + input_path, + "--output", + output_path, + "--link-column", + self.excel_link_column, + "--image-column-header", + self.excel_image_column_header, + "--max-width", + str(self.excel_image_max_width), + "--max-height", + str(self.excel_image_max_height), + "--timeout", + str(self.excel_image_download_timeout), + ] + if self.excel_skip_tls_verify: + cmd.append("--skip-tls-verify") + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=self.excel_converter_timeout, + ) + except subprocess.TimeoutExpired: + return f"Excel 转换超时(>{self.excel_converter_timeout}秒),请稍后重试。", False + + stdout_text = (proc.stdout or "").strip() + stderr_text = (proc.stderr or "").strip() + if proc.returncode != 0 or not os.path.exists(output_path): + detail = stderr_text or stdout_text or "未知错误" + self.logger.error("excel converter failed code=%s detail=%s", proc.returncode, detail[:800]) + return f"Excel 转换失败:{detail[:500]}", False + + summary_line = "" + for line in stdout_text.splitlines(): + if line.startswith("SUMMARY:"): + summary_line = line.replace("SUMMARY:", "").strip() + break + + try: + with open(output_path, "rb") as fh: + media_content = fh.read() + media_id = self.dingtalk_client.upload_to_dingtalk( + media_content, + filetype="file", + filename=output_name, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + except Exception as exc: + self.logger.exception("upload converted excel failed") + return f"处理完成但上传失败:{exc}", False + + if not media_id: + return "处理完成但上传媒体失败,请稍后重试。", False + + sent = self._reply_file_by_media_id(msg, media_id) + if not sent: + return "处理完成但回传文件失败,请稍后重试。", False + + summary_text = summary_line or "已完成图片回填。" + return f"文件已回传:{output_name}\n处理结果:{summary_text}", True + def _is_image_url(self, raw_url: str) -> bool: try: parsed = urllib.parse.urlparse(raw_url) @@ -2210,16 +2421,20 @@ class OpenClawDingTalkBot(ChatbotHandler): text = text_list[0].strip() if text_list else "" incoming_image_codes = self._extract_incoming_download_codes(msg) + incoming_file_payload = self._extract_incoming_file_payload(callback.data, msg) + has_excel_file = bool(incoming_file_payload) - if not text and not incoming_image_codes: + if not text and not incoming_image_codes and not has_excel_file: return AckMessage.STATUS_OK, "ignored" - if not self._should_handle(msg, text): + if not has_excel_file and not self._should_handle(msg, text): return AckMessage.STATUS_OK, "ignored" prompt = self._normalize_prompt(text) if not prompt: - if incoming_image_codes: + if has_excel_file: + prompt = "请处理我上传的Excel文件,把图片链接转成图片并回传。" + elif incoming_image_codes: prompt = "请识别并分析我上传的图片内容,输出结论和建议。" else: self._reply_message( @@ -2268,7 +2483,22 @@ class OpenClawDingTalkBot(ChatbotHandler): conversation_key, self.max_parallel_jobs, ) - if incoming_image_codes and not self._is_install_media_prompt(prompt): + if has_excel_file: + file_answer, file_sent = await asyncio.to_thread( + self._run_excel_image_link_job, + msg, + incoming_file_payload, + ) + if file_sent: + self._reply_message(msg, file_answer, prefer_card=False) + self.logger.info( + "excel file processed: session=%s file=%s", + session_id, + incoming_file_payload.get("file_name", ""), + ) + return AckMessage.STATUS_OK, "excel_file_ok" + answer = file_answer + elif incoming_image_codes and not self._is_install_media_prompt(prompt): image_context_result = await asyncio.to_thread( self._build_image_context_from_dingtalk, msg, diff --git a/remote_sync/skills/dingtalk-excel-image-return/SKILL.md b/remote_sync/skills/dingtalk-excel-image-return/SKILL.md new file mode 100644 index 0000000..ea65041 --- /dev/null +++ b/remote_sync/skills/dingtalk-excel-image-return/SKILL.md @@ -0,0 +1,68 @@ +--- +name: dingtalk-excel-image-return +description: 在钉钉应用机器人(推荐 Stream 模式)场景处理用户上传的 Excel(.xlsx)文件:识别图片链接列,下载链接图片并插入新增图片列,再上传并回传结果文件。用户提到“钉钉机器人”“上传表格”“图片链接转图片”“新增图片列”“回传文件”“批量插图”“Excel 图片列”时使用此技能;涉及钉钉文件下载、Excel 图片写入、文件回传时优先使用此技能。 +--- + +# dingtalk-excel-image-return + +## 触发关键词 + +优先匹配以下关键词或等价表达: + +- `钉钉机器人` +- `上传表格` +- `Excel 图片链接` +- `图片链接转图片` +- `新增图片列` +- `回传文件` +- `批量插图` + +## 执行流程(最稳方案) + +1. 使用钉钉应用机器人接收消息,优先使用 Stream 模式。 +2. 在用户与机器人单聊中接收 `file` 消息,提取 `downloadCode`。 +3. 调用钉钉文件下载接口获取原始 `.xlsx`。 +4. 运行 `scripts/convert_excel_image_links.py` 处理表格。 +5. 上传结果文件到钉钉文件存储。 +6. 调用会话文件发送接口回传结果。 + +如果用户要求“群里 @ 机器人并直接传文件”,先提示群聊文件接收限制,再引导改为单聊上传,或提供上传入口卡片。 + +## Excel 处理命令 + +执行: + +```bash +python3 scripts/convert_excel_image_links.py \ + --input /tmp/input.xlsx \ + --output /tmp/output.xlsx \ + --link-column 图片链接 \ + --image-column-header 图片 +``` + +常用参数: + +- `--link-column`:链接列(表头名或列字母,如 `C`) +- `--header-row`:表头行号(默认 `1`) +- `--sheet`:只处理指定工作表(可重复) +- `--max-width` / `--max-height`:限制图片尺寸 +- `--skip-tls-verify`:图片链接为自签名证书时临时使用 + +## 钉钉接口编排 + +按顺序调用: + +1. 获取用户上传文件(通过 `downloadCode`)。 +2. 调用下载接口拉取原表格。 +3. 本地转换并生成新文件。 +4. 上传新文件。 +5. 发送文件消息回会话。 + +接口详情、字段和最小实现建议见 `references/dingtalk-workflow.md`。 + +## 失败兜底 + +- 下载图片失败时不中断整表,记录失败行并继续。 +- 非 `http/https` 链接标记为失败。 +- 处理完成后始终输出结果文件;失败行通过日志返回。 +- 涉及认证或证书错误时先打印明确错误,再提示运维修复证书/权限。 diff --git a/remote_sync/skills/dingtalk-excel-image-return/agents/openai.yaml b/remote_sync/skills/dingtalk-excel-image-return/agents/openai.yaml new file mode 100644 index 0000000..63fcd9f --- /dev/null +++ b/remote_sync/skills/dingtalk-excel-image-return/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "钉钉表格图片回填" + short_description: "把钉钉Excel里的图片链接批量转成嵌入图片并自动回传" + default_prompt: "Use $dingtalk-excel-image-return to convert image URLs in an uploaded .xlsx file into embedded images and return the processed file." diff --git a/remote_sync/skills/dingtalk-excel-image-return/references/dingtalk-workflow.md b/remote_sync/skills/dingtalk-excel-image-return/references/dingtalk-workflow.md new file mode 100644 index 0000000..c78f896 --- /dev/null +++ b/remote_sync/skills/dingtalk-excel-image-return/references/dingtalk-workflow.md @@ -0,0 +1,49 @@ +# DingTalk workflow for Excel image-link conversion + +## 1) Recommended architecture + +- Use an **app bot** (not a group custom bot). +- Use **Stream mode** to receive messages. +- Prefer **1:1 chat** for file uploads. + +## 2) End-to-end API sequence + +1. Receive message event (`msgtype=file`) and parse: + - `conversationId` + - `senderStaffId` + - `downloadCode` + - original `fileName` +2. Download source file: + - `POST /v1.0/robot/messageFiles/download` + - Body includes `downloadCode`. +3. Run local conversion script: + - `scripts/convert_excel_image_links.py` +4. Upload output file to DingTalk file service (get file storage identifiers). +5. Send output file back: + - `POST /v1.0/convFile/conversations/files/send` + - Include `conversationId` and uploaded file info. + +## 3) Message handling policy + +- Reject non-`.xlsx` files with clear error text. +- If file size exceeds service limit, return size-limit guidance. +- Keep idempotency key (`conversationId + messageId`) to avoid duplicate processing. +- Return progress checkpoints in long jobs: received -> processing -> done/failed. + +## 4) Security and reliability + +- Keep app secrets in env vars; never hardcode credentials. +- Restrict outbound image downloads with timeout and max size. +- Allow partial success for broken image URLs. +- Record each failed row with reason for troubleshooting. + +## 5) Suggested runtime contract + +Input contract: +- `.xlsx` file +- URL column header default: `图片链接` + +Output contract: +- New `.xlsx` with inserted column header default: `图片` +- Same sheet names and original data preserved +- Failed URL rows logged diff --git a/remote_sync/skills/dingtalk-excel-image-return/scripts/convert_excel_image_links.py b/remote_sync/skills/dingtalk-excel-image-return/scripts/convert_excel_image_links.py new file mode 100755 index 0000000..9194ded --- /dev/null +++ b/remote_sync/skills/dingtalk-excel-image-return/scripts/convert_excel_image_links.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import io +import re +import ssl +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from openpyxl import load_workbook +from openpyxl.drawing.image import Image as XLImage +from openpyxl.utils import column_index_from_string, get_column_letter + + +CONTENT_TYPE_TO_EXT = { + "image/png": ".png", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/webp": ".webp", + "image/bmp": ".bmp", + "image/gif": ".gif", +} + +SUPPORTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".gif"} + + +@dataclass +class RowResult: + row: int + url: str + status: str + detail: str + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Download image URLs from an Excel column and insert images into a new column." + ) + parser.add_argument("--input", required=True, help="Input .xlsx path") + parser.add_argument("--output", required=True, help="Output .xlsx path") + parser.add_argument( + "--link-column", + default="图片链接", + help="URL column by header text or column letter (default: 图片链接)", + ) + parser.add_argument( + "--image-column-header", + default="图片", + help="Header value for inserted image column", + ) + parser.add_argument( + "--header-row", + type=int, + default=1, + help="Header row number (default: 1)", + ) + parser.add_argument( + "--start-row", + type=int, + default=0, + help="Start processing row (default: header_row + 1)", + ) + parser.add_argument( + "--sheet", + action="append", + default=[], + help="Sheet name to process (repeatable). Default: process all sheets", + ) + parser.add_argument( + "--max-width", + type=int, + default=140, + help="Max image width in pixels (default: 140)", + ) + parser.add_argument( + "--max-height", + type=int, + default=140, + help="Max image height in pixels (default: 140)", + ) + parser.add_argument( + "--timeout", + type=int, + default=15, + help="Image download timeout in seconds (default: 15)", + ) + parser.add_argument( + "--skip-tls-verify", + action="store_true", + help="Disable TLS certificate verification for image URLs", + ) + return parser.parse_args() + + +def resolve_link_column(sheet, header_row: int, link_column: str) -> int: + column_spec = link_column.strip() + if re.fullmatch(r"[A-Za-z]{1,3}", column_spec): + return column_index_from_string(column_spec.upper()) + + for col_idx in range(1, sheet.max_column + 1): + value = sheet.cell(row=header_row, column=col_idx).value + if value is None: + continue + if str(value).strip() == column_spec: + return col_idx + + raise ValueError( + f"Sheet '{sheet.title}' cannot find link column '{link_column}' in header row {header_row}." + ) + + +def choose_sheets(workbook, names: Iterable[str]): + if not names: + return list(workbook.worksheets) + + chosen = [] + existing = {ws.title: ws for ws in workbook.worksheets} + for name in names: + if name not in existing: + raise ValueError(f"Sheet '{name}' does not exist.") + chosen.append(existing[name]) + return chosen + + +def build_ssl_context(skip_verify: bool): + if not skip_verify: + return None + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + return context + + +def guess_extension(url: str, content_type: str | None) -> str: + parsed = urllib.parse.urlparse(url) + suffix = Path(parsed.path).suffix.lower() + if suffix in SUPPORTED_EXTENSIONS: + return suffix + + if content_type: + clean_content_type = content_type.split(";")[0].strip().lower() + if clean_content_type in CONTENT_TYPE_TO_EXT: + return CONTENT_TYPE_TO_EXT[clean_content_type] + + return ".png" + + +def download_image(url: str, timeout: int, ssl_context): + request = urllib.request.Request( + url, + headers={"User-Agent": "Mozilla/5.0 CodexSkill/1.0"}, + ) + with urllib.request.urlopen(request, timeout=timeout, context=ssl_context) as response: + content_type = response.headers.get("Content-Type") + data = response.read() + return data, guess_extension(url, content_type) + + +def pixels_to_excel_width(px: int) -> float: + return max(12.0, round((px + 12) / 7, 2)) + + +def pixels_to_points(px: int) -> float: + return round(px * 0.75, 2) + + +def resize_image(image: XLImage, max_width: int, max_height: int) -> None: + width = image.width or max_width + height = image.height or max_height + scale = min(max_width / width, max_height / height, 1.0) + image.width = int(width * scale) + image.height = int(height * scale) + + +def process_sheet(sheet, args: argparse.Namespace, ssl_context) -> list[RowResult]: + results: list[RowResult] = [] + + header_row = args.header_row + start_row = args.start_row if args.start_row > 0 else header_row + 1 + + link_column_idx = resolve_link_column(sheet, header_row, args.link_column) + image_column_idx = link_column_idx + 1 + sheet.insert_cols(image_column_idx) + sheet.cell(row=header_row, column=image_column_idx, value=args.image_column_header) + + image_column_letter = get_column_letter(image_column_idx) + column_width = pixels_to_excel_width(args.max_width) + existing_width = sheet.column_dimensions[image_column_letter].width + if existing_width is None or existing_width < column_width: + sheet.column_dimensions[image_column_letter].width = column_width + + for row in range(start_row, sheet.max_row + 1): + source = sheet.cell(row=row, column=link_column_idx).value + if source is None: + continue + + url = str(source).strip() + if not url: + continue + if not url.lower().startswith(("http://", "https://")): + results.append(RowResult(row=row, url=url, status="failed", detail="invalid_url")) + continue + + try: + payload, extension = download_image(url, args.timeout, ssl_context) + image_buffer = io.BytesIO(payload) + image_buffer.name = f"row_{row}{extension}" + image = XLImage(image_buffer) + resize_image(image, args.max_width, args.max_height) + + anchor = f"{image_column_letter}{row}" + sheet.add_image(image, anchor) + + needed_height = pixels_to_points(image.height + 8) + current_height = sheet.row_dimensions[row].height + if current_height is None or current_height < needed_height: + sheet.row_dimensions[row].height = needed_height + + results.append(RowResult(row=row, url=url, status="ok", detail="inserted")) + except urllib.error.URLError as error: + results.append(RowResult(row=row, url=url, status="failed", detail=f"download_error:{error.reason}")) + except Exception as error: # noqa: BLE001 + results.append(RowResult(row=row, url=url, status="failed", detail=f"image_error:{error}")) + + return results + + +def main() -> int: + args = parse_args() + + input_path = Path(args.input) + output_path = Path(args.output) + + if input_path.suffix.lower() != ".xlsx": + print("ERROR: input must be .xlsx") + return 1 + if not input_path.exists(): + print(f"ERROR: input does not exist: {input_path}") + return 1 + + ssl_context = build_ssl_context(args.skip_tls_verify) + + workbook = load_workbook(input_path) + sheets = choose_sheets(workbook, args.sheet) + + all_results: list[tuple[str, RowResult]] = [] + for sheet in sheets: + sheet_results = process_sheet(sheet, args, ssl_context) + all_results.extend((sheet.title, item) for item in sheet_results) + + output_path.parent.mkdir(parents=True, exist_ok=True) + workbook.save(output_path) + + success_count = sum(1 for _, item in all_results if item.status == "ok") + fail_count = sum(1 for _, item in all_results if item.status == "failed") + + print(f"DONE: output={output_path}") + print(f"SUMMARY: success={success_count} failed={fail_count}") + + for sheet_name, result in all_results: + if result.status == "failed": + print( + f"FAILED: sheet={sheet_name} row={result.row} url={result.url} reason={result.detail}" + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())