feat: add dingtalk excel image-link file workflow
This commit is contained in:
parent
90236eaf4c
commit
5fc195f71d
@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
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_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_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.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:
|
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||||
if text.startswith(self.prefix):
|
if text.startswith(self.prefix):
|
||||||
@ -1401,6 +1418,72 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
break
|
break
|
||||||
return codes
|
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:
|
def _guess_image_ext(self, content_type: str, source_url: str) -> str:
|
||||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||||
ext = ""
|
ext = ""
|
||||||
@ -2079,6 +2162,134 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
raw = (proc.stdout or "").strip()
|
raw = (proc.stdout or "").strip()
|
||||||
return f"结果解析失败: {exc}.\n原始输出: {raw[:500]}"
|
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:
|
def _is_image_url(self, raw_url: str) -> bool:
|
||||||
try:
|
try:
|
||||||
parsed = urllib.parse.urlparse(raw_url)
|
parsed = urllib.parse.urlparse(raw_url)
|
||||||
@ -2210,16 +2421,20 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
text = text_list[0].strip() if text_list else ""
|
text = text_list[0].strip() if text_list else ""
|
||||||
|
|
||||||
incoming_image_codes = self._extract_incoming_download_codes(msg)
|
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"
|
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"
|
return AckMessage.STATUS_OK, "ignored"
|
||||||
|
|
||||||
prompt = self._normalize_prompt(text)
|
prompt = self._normalize_prompt(text)
|
||||||
if not prompt:
|
if not prompt:
|
||||||
if incoming_image_codes:
|
if has_excel_file:
|
||||||
|
prompt = "请处理我上传的Excel文件,把图片链接转成图片并回传。"
|
||||||
|
elif incoming_image_codes:
|
||||||
prompt = "请识别并分析我上传的图片内容,输出结论和建议。"
|
prompt = "请识别并分析我上传的图片内容,输出结论和建议。"
|
||||||
else:
|
else:
|
||||||
self._reply_message(
|
self._reply_message(
|
||||||
@ -2268,7 +2483,22 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
|||||||
conversation_key,
|
conversation_key,
|
||||||
self.max_parallel_jobs,
|
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(
|
image_context_result = await asyncio.to_thread(
|
||||||
self._build_image_context_from_dingtalk,
|
self._build_image_context_from_dingtalk,
|
||||||
msg,
|
msg,
|
||||||
|
|||||||
68
remote_sync/skills/dingtalk-excel-image-return/SKILL.md
Normal file
68
remote_sync/skills/dingtalk-excel-image-return/SKILL.md
Normal file
@ -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` 链接标记为失败。
|
||||||
|
- 处理完成后始终输出结果文件;失败行通过日志返回。
|
||||||
|
- 涉及认证或证书错误时先打印明确错误,再提示运维修复证书/权限。
|
||||||
@ -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."
|
||||||
@ -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
|
||||||
@ -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())
|
||||||
Loading…
x
Reference in New Issue
Block a user