Compare commits
11 Commits
codex/genc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3873928b66 | ||
|
|
fa631100ba | ||
|
|
c382665753 | ||
|
|
629a520201 | ||
|
|
20d45b5f1d | ||
|
|
bfe3f168a2 | ||
|
|
0ce33a601f | ||
|
|
b96e333df8 | ||
|
|
f7ffdb4233 | ||
|
|
1b9982e743 | ||
|
|
5fc195f71d |
@ -12,7 +12,9 @@ import mimetypes
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import uuid
|
||||
@ -151,6 +153,17 @@ DALONGXIA_ANALYTICS_HINT_KEYWORDS = (
|
||||
"analytics",
|
||||
)
|
||||
|
||||
EXCEL_IMAGE_LINK_HINT_KEYWORDS = (
|
||||
"图片链接",
|
||||
"链接转图片",
|
||||
"转成图片",
|
||||
"回填",
|
||||
"excel",
|
||||
"xlsx",
|
||||
"表格",
|
||||
"文件",
|
||||
)
|
||||
|
||||
|
||||
URL_RE = re.compile(
|
||||
r"https?://[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]+",
|
||||
@ -296,6 +309,37 @@ 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)
|
||||
self.excel_text_prompt_cooldown_seconds = max(
|
||||
10,
|
||||
min(int(os.getenv("EXCEL_TEXT_PROMPT_COOLDOWN_SECONDS", "120")), 600),
|
||||
)
|
||||
self.excel_alert_log_enabled = bool_env("EXCEL_ALERT_LOG_ENABLED", True)
|
||||
self.excel_alert_log_path = (
|
||||
os.getenv(
|
||||
"EXCEL_ALERT_LOG_PATH",
|
||||
"/opt/openclaw-dingtalk-bridge/logs/excel_converter_alerts.log",
|
||||
).strip()
|
||||
or "/opt/openclaw-dingtalk-bridge/logs/excel_converter_alerts.log"
|
||||
)
|
||||
self.excel_alert_detail_limit = max(200, min(int(os.getenv("EXCEL_ALERT_DETAIL_LIMIT", "1600")), 10000))
|
||||
self._excel_alert_log_lock = threading.Lock()
|
||||
self._recent_excel_success_ts = {}
|
||||
|
||||
def _should_handle(self, msg: ChatbotMessage, text: str) -> bool:
|
||||
if text.startswith(self.prefix):
|
||||
@ -1401,6 +1445,154 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
||||
break
|
||||
return codes
|
||||
|
||||
def _normalize_file_name(self, raw_name: str) -> str:
|
||||
raw = str(raw_name or "").strip().replace("\\", "_").replace("/", "_")
|
||||
raw = re.sub(r"\s+", "_", raw)
|
||||
stem, ext = os.path.splitext(raw)
|
||||
safe_stem = re.sub(r"[^0-9A-Za-z._-]+", "_", stem).strip("._-")
|
||||
safe_ext = re.sub(r"[^0-9A-Za-z.]+", "", ext).lower()
|
||||
if safe_ext and not safe_ext.startswith("."):
|
||||
safe_ext = f".{safe_ext}"
|
||||
if not safe_stem:
|
||||
safe_stem = f"upload_{int(time.time())}"
|
||||
normalized = f"{safe_stem}{safe_ext}" if safe_ext else safe_stem
|
||||
return normalized[:120]
|
||||
|
||||
def _is_supported_excel_file(self, file_name: str) -> bool:
|
||||
return str(file_name or "").strip().lower().endswith(".xlsx")
|
||||
|
||||
def _is_supported_excel_payload(self, file_name: str, file_type: str) -> bool:
|
||||
if self._is_supported_excel_file(file_name):
|
||||
return True
|
||||
normalized = str(file_type or "").strip().lower()
|
||||
if not normalized:
|
||||
return False
|
||||
normalized = normalized.split(";")[0].strip()
|
||||
if normalized in {
|
||||
"xlsx",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
}:
|
||||
return True
|
||||
if "spreadsheetml.sheet" in normalized:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_excel_link_prompt(self, prompt: str) -> bool:
|
||||
raw = str(prompt or "").strip().lower()
|
||||
if not raw:
|
||||
return False
|
||||
return any(keyword in raw for keyword in EXCEL_IMAGE_LINK_HINT_KEYWORDS)
|
||||
|
||||
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()}
|
||||
code = ""
|
||||
if "downloadcode" in lowered:
|
||||
raw_code = current.get(lowered["downloadcode"])
|
||||
code = str(raw_code or "").strip()
|
||||
|
||||
download_url = ""
|
||||
for key_name in (
|
||||
"downloadurl",
|
||||
"download_url",
|
||||
"privatedownloadurl",
|
||||
"private_download_url",
|
||||
"privateurl",
|
||||
):
|
||||
if key_name in lowered:
|
||||
raw_url = current.get(lowered[key_name])
|
||||
candidate_url = str(raw_url or "").strip()
|
||||
if candidate_url.lower().startswith(("http://", "https://")):
|
||||
download_url = candidate_url
|
||||
break
|
||||
|
||||
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
|
||||
|
||||
file_type = ""
|
||||
for candidate in ("fileType", "filetype", "mimeType", "mimetype", "suffix", "ext", "extension"):
|
||||
if candidate in current and str(current.get(candidate) or "").strip():
|
||||
file_type = str(current.get(candidate)).strip()
|
||||
break
|
||||
|
||||
if code or download_url:
|
||||
return {
|
||||
"download_code": code,
|
||||
"download_url": download_url,
|
||||
"file_name": file_name,
|
||||
"file_type": file_type,
|
||||
}
|
||||
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:
|
||||
payload = self._find_download_code_payload(callback_data)
|
||||
if not payload:
|
||||
payload = self._find_download_code_payload(getattr(msg, "extensions", {}))
|
||||
if not payload:
|
||||
try:
|
||||
payload = self._find_download_code_payload(msg.to_dict())
|
||||
except Exception:
|
||||
payload = None
|
||||
if not payload:
|
||||
return {}
|
||||
|
||||
download_code = str(payload.get("download_code") or "").strip()
|
||||
download_url = str(payload.get("download_url") or "").strip()
|
||||
if download_url and not download_url.lower().startswith(("http://", "https://")):
|
||||
download_url = ""
|
||||
if not download_code and not download_url:
|
||||
return {}
|
||||
|
||||
file_name = payload.get("file_name") or ""
|
||||
file_type = payload.get("file_type") 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
|
||||
if not file_type and isinstance(callback_data, dict):
|
||||
for key in ("fileType", "filetype", "mimeType", "mimetype", "suffix", "ext", "extension"):
|
||||
raw = callback_data.get(key)
|
||||
if raw:
|
||||
file_type = str(raw).strip()
|
||||
if file_type:
|
||||
break
|
||||
normalized_file_name = self._normalize_file_name(file_name or "upload.xlsx")
|
||||
normalized_file_type = str(file_type or "").strip().lower()
|
||||
if not self._is_supported_excel_payload(normalized_file_name, normalized_file_type):
|
||||
return {}
|
||||
payload["download_code"] = download_code
|
||||
payload["download_url"] = download_url
|
||||
payload["file_name"] = normalized_file_name
|
||||
payload["file_type"] = normalized_file_type
|
||||
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 +2271,259 @@ 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, file_name: str = "") -> bool:
|
||||
if not media_id:
|
||||
return False
|
||||
safe_name = self._normalize_file_name(file_name or f"excel_{int(time.time())}.xlsx")
|
||||
file_ext = os.path.splitext(safe_name)[1].lower().lstrip(".") or "file"
|
||||
request_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "*/*",
|
||||
}
|
||||
values = {
|
||||
"msgtype": "file",
|
||||
"file": {
|
||||
"mediaId": media_id,
|
||||
"fileName": safe_name,
|
||||
"fileType": file_ext,
|
||||
},
|
||||
"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()
|
||||
payload = {}
|
||||
try:
|
||||
payload = response.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
errcode = payload.get("errcode")
|
||||
success = payload.get("success")
|
||||
if (errcode is not None and str(errcode) not in {"0", ""}) or success is False:
|
||||
self.logger.error(
|
||||
"reply file business failed, errcode=%s payload=%s",
|
||||
errcode,
|
||||
str(payload)[:500],
|
||||
)
|
||||
return False
|
||||
except Exception as exc:
|
||||
self.logger.error("reply file failed, error=%s, response.text=%s", exc, response_text)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _upload_excel_output_to_s3(self, file_path: str, filename: str) -> str:
|
||||
if boto3 is None or BotoConfig is None:
|
||||
return ""
|
||||
if not (self.s3_bucket and self.s3_access_key and self.s3_secret_key):
|
||||
return ""
|
||||
try:
|
||||
safe_name = self._normalize_file_name(filename or f"excel_{int(time.time())}.xlsx")
|
||||
object_key = f"{self.s3_key_prefix}excel-output/{safe_name}"
|
||||
self._upload_to_s3(
|
||||
file_path,
|
||||
object_key,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
return self._build_public_s3_url(object_key)
|
||||
except Exception as exc:
|
||||
self.logger.exception("upload excel output to s3 failed: %s", exc)
|
||||
return ""
|
||||
|
||||
def _excel_error_summary(self, detail: str) -> str:
|
||||
raw = str(detail or "").strip()
|
||||
if not raw:
|
||||
return "unknown_error"
|
||||
lines = [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return raw[:300]
|
||||
for line in reversed(lines):
|
||||
lower_line = line.lower()
|
||||
if lower_line.startswith("traceback"):
|
||||
continue
|
||||
if lower_line.startswith("file "):
|
||||
continue
|
||||
if lower_line in {"^^^^^^", "^^^^^"}:
|
||||
continue
|
||||
return line[:300]
|
||||
return lines[-1][:300]
|
||||
|
||||
def _record_excel_failure_alert(
|
||||
self,
|
||||
msg: ChatbotMessage,
|
||||
file_name: str,
|
||||
stage: str,
|
||||
detail: str,
|
||||
return_code: int | None = None,
|
||||
stdout_text: str = "",
|
||||
stderr_text: str = "",
|
||||
) -> None:
|
||||
normalized_detail = str(detail or "").strip() or "unknown_error"
|
||||
summary = self._excel_error_summary(normalized_detail)
|
||||
digest = hashlib.sha1(normalized_detail.encode("utf-8", errors="ignore")).hexdigest()[:12]
|
||||
log_record = {
|
||||
"ts": dt.datetime.now().isoformat(timespec="seconds"),
|
||||
"event": "excel_convert_failed",
|
||||
"stage": stage,
|
||||
"digest": digest,
|
||||
"summary": summary,
|
||||
"return_code": return_code,
|
||||
"file_name": str(file_name or ""),
|
||||
"conversation_id": str(getattr(msg, "conversation_id", "") or ""),
|
||||
"session_webhook": str(getattr(msg, "session_webhook", "") or ""),
|
||||
"sender_staff_id": str(getattr(msg, "sender_staff_id", "") or ""),
|
||||
"detail": normalized_detail[: self.excel_alert_detail_limit],
|
||||
"stderr": str(stderr_text or "")[: self.excel_alert_detail_limit],
|
||||
"stdout": str(stdout_text or "")[: self.excel_alert_detail_limit],
|
||||
}
|
||||
self.logger.warning(
|
||||
"excel failure alert stage=%s digest=%s file=%s summary=%s",
|
||||
stage,
|
||||
digest,
|
||||
file_name,
|
||||
summary,
|
||||
)
|
||||
if not self.excel_alert_log_enabled:
|
||||
return
|
||||
try:
|
||||
parent_dir = os.path.dirname(self.excel_alert_log_path) or "."
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
line = json.dumps(log_record, ensure_ascii=False)
|
||||
with self._excel_alert_log_lock:
|
||||
with open(self.excel_alert_log_path, "a", encoding="utf-8") as file_obj:
|
||||
file_obj.write(line + "\n")
|
||||
except Exception:
|
||||
self.logger.exception("write excel alert log failed")
|
||||
|
||||
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()
|
||||
download_url = str(file_payload.get("download_url") or "").strip()
|
||||
file_name = self._normalize_file_name(file_payload.get("file_name") or "upload.xlsx")
|
||||
if not download_code and not download_url:
|
||||
return "未获取到文件下载地址,请重试上传。", False
|
||||
if not self._is_supported_excel_file(file_name):
|
||||
return "当前仅支持 .xlsx 文件,请上传Excel后重试。", False
|
||||
|
||||
if not download_url:
|
||||
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")
|
||||
self._record_excel_failure_alert(
|
||||
msg,
|
||||
file_name,
|
||||
stage="download_source_excel",
|
||||
detail=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
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:
|
||||
self._record_excel_failure_alert(
|
||||
msg,
|
||||
file_name,
|
||||
stage="excel_converter_timeout",
|
||||
detail=f"timeout>{self.excel_converter_timeout}s",
|
||||
)
|
||||
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[:2500])
|
||||
self._record_excel_failure_alert(
|
||||
msg,
|
||||
file_name,
|
||||
stage="excel_converter_failed",
|
||||
detail=detail,
|
||||
return_code=proc.returncode,
|
||||
stdout_text=stdout_text,
|
||||
stderr_text=stderr_text,
|
||||
)
|
||||
return f"Excel 转换失败:{detail[:1200]}", False
|
||||
|
||||
summary_line = ""
|
||||
for line in stdout_text.splitlines():
|
||||
if line.startswith("SUMMARY:"):
|
||||
summary_line = line.replace("SUMMARY:", "").strip()
|
||||
break
|
||||
|
||||
media_id = ""
|
||||
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",
|
||||
) or ""
|
||||
except Exception as exc:
|
||||
self.logger.exception("upload converted excel failed")
|
||||
|
||||
sent = self._reply_file_by_media_id(msg, media_id, output_name) if media_id else False
|
||||
if not sent:
|
||||
s3_url = self._upload_excel_output_to_s3(output_path, output_name)
|
||||
if s3_url:
|
||||
return f"钉钉文件回传受限,已生成下载链接:{s3_url}", True
|
||||
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 +2655,37 @@ 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)
|
||||
conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default"))
|
||||
self.logger.info(
|
||||
"incoming message: type=%s has_excel_file=%s has_image=%s text_len=%s",
|
||||
str(getattr(msg, "message_type", "")),
|
||||
has_excel_file,
|
||||
bool(incoming_image_codes),
|
||||
len(text or ""),
|
||||
)
|
||||
if str(getattr(msg, "message_type", "")).lower() == "file" and not has_excel_file:
|
||||
callback_keys = []
|
||||
if isinstance(callback.data, dict):
|
||||
callback_keys = list(callback.data.keys())[:15]
|
||||
self.logger.warning(
|
||||
"file message without excel payload: callback_keys=%s extensions_keys=%s",
|
||||
callback_keys,
|
||||
list((getattr(msg, "extensions", {}) or {}).keys())[:15],
|
||||
)
|
||||
|
||||
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(
|
||||
@ -2228,6 +2694,14 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
||||
prefer_card=False,
|
||||
)
|
||||
return AckMessage.STATUS_OK, "ok"
|
||||
elif not has_excel_file and self.excel_link_feature_enabled and self._is_excel_link_prompt(prompt):
|
||||
now_ts = time.time()
|
||||
last_ts = self._recent_excel_success_ts.get(conversation_key, 0)
|
||||
if last_ts and now_ts - last_ts <= self.excel_text_prompt_cooldown_seconds:
|
||||
self._reply_message(msg, "文件刚处理完成,请查看上一条回传文件。", prefer_card=False)
|
||||
else:
|
||||
self._reply_message(msg, "请直接上传 .xlsx 文件(包含“图片链接”列),我会自动回填并回传。", prefer_card=False)
|
||||
return AckMessage.STATUS_OK, "excel_text_prompt_hint"
|
||||
|
||||
event_key = self._event_key(callback, msg, prompt)
|
||||
if self._seen_or_mark_event(event_key):
|
||||
@ -2253,7 +2727,6 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
||||
except Exception:
|
||||
self.logger.exception("failed to send processing hint")
|
||||
|
||||
conversation_key = re.sub(r"[^A-Za-z0-9_-]+", "-", str(getattr(msg, "conversation_id", "") or "default"))
|
||||
conv_lock = self._conversation_locks.setdefault(conversation_key, asyncio.Lock())
|
||||
|
||||
async with conv_lock:
|
||||
@ -2268,7 +2741,23 @@ 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._recent_excel_success_ts[conversation_key] = time.time()
|
||||
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,
|
||||
@ -2278,17 +2767,18 @@ class OpenClawDingTalkBot(ChatbotHandler):
|
||||
context_text = image_context_result.get("context_text", "")
|
||||
if context_text:
|
||||
prompt_for_exec = self._merge_prompt_with_image_context(prompt, context_text)
|
||||
if self._is_install_media_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_install_media_lookup, prompt)
|
||||
elif self._is_dalongxia_analytics_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt)
|
||||
elif self._is_video_analysis_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_video_analysis, prompt)
|
||||
elif self._is_market_prompt(prompt):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
answer = await asyncio.to_thread(self._run_market_brief, subject)
|
||||
else:
|
||||
answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec)
|
||||
if not has_excel_file:
|
||||
if self._is_install_media_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_install_media_lookup, prompt)
|
||||
elif self._is_dalongxia_analytics_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_dalongxia_analytics, prompt)
|
||||
elif self._is_video_analysis_prompt(prompt):
|
||||
answer = await asyncio.to_thread(self._run_video_analysis, prompt)
|
||||
elif self._is_market_prompt(prompt):
|
||||
subject = self._extract_market_subject(prompt)
|
||||
answer = await asyncio.to_thread(self._run_market_brief, subject)
|
||||
else:
|
||||
answer = await asyncio.to_thread(self._run_openclaw, session_id, prompt_for_exec)
|
||||
except Exception:
|
||||
self.logger.exception("worker execution failed: session=%s", session_id)
|
||||
answer = "系统执行异常,请稍后重试。"
|
||||
|
||||
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,505 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import mimetypes
|
||||
import re
|
||||
import ssl
|
||||
import tempfile
|
||||
import zipfile
|
||||
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.packaging import manifest as openpyxl_manifest
|
||||
from openpyxl.utils import column_index_from_string, get_column_letter
|
||||
try:
|
||||
from PIL import Image as PILImage
|
||||
except Exception: # noqa: BLE001
|
||||
PILImage = None
|
||||
|
||||
|
||||
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"}
|
||||
EXCEL_SAFE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".gif"}
|
||||
UNSAFE_OR_UNKNOWN_EXTENSIONS = {".webp", ".tif", ".tiff", ".heic", ".avif"}
|
||||
IMAGE_HINT_HEADERS = (
|
||||
"图片链接",
|
||||
"商品图片",
|
||||
"主图链接",
|
||||
"图片url",
|
||||
"image_url",
|
||||
"image url",
|
||||
"图片",
|
||||
)
|
||||
|
||||
|
||||
def _iter_mimetype_modules():
|
||||
yield mimetypes
|
||||
compat = getattr(openpyxl_manifest, "mimetypes", None)
|
||||
if compat is not None:
|
||||
yield compat
|
||||
|
||||
|
||||
def _resolve_type_maps(module) -> tuple[dict, dict]:
|
||||
type_maps = getattr(module, "types_map", {})
|
||||
if isinstance(type_maps, tuple) and len(type_maps) >= 2:
|
||||
loose_map, strict_map = type_maps[0], type_maps[1]
|
||||
if isinstance(strict_map, dict) and isinstance(loose_map, dict):
|
||||
return strict_map, loose_map
|
||||
if (
|
||||
isinstance(type_maps, dict)
|
||||
and isinstance(type_maps.get(True), dict)
|
||||
and isinstance(type_maps.get(False), dict)
|
||||
):
|
||||
return type_maps[True], type_maps[False]
|
||||
if isinstance(type_maps, dict):
|
||||
loose_map = getattr(module, "common_types", {})
|
||||
if not isinstance(loose_map, dict):
|
||||
loose_map = type_maps
|
||||
return type_maps, loose_map
|
||||
return {}, {}
|
||||
|
||||
|
||||
def _register_type(module, extension: str, mime_type: str) -> None:
|
||||
add_type = getattr(module, "add_type", None)
|
||||
if callable(add_type):
|
||||
try:
|
||||
add_type(mime_type, extension, strict=True)
|
||||
except TypeError:
|
||||
try:
|
||||
add_type(mime_type, extension)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
add_type(mime_type, extension, strict=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
strict_map, loose_map = _resolve_type_maps(module)
|
||||
strict_map[extension] = mime_type
|
||||
loose_map.setdefault(extension, mime_type)
|
||||
|
||||
|
||||
def ensure_mimetype_for_ext(ext: str) -> None:
|
||||
normalized_raw = str(ext or "").strip()
|
||||
if not normalized_raw:
|
||||
return
|
||||
|
||||
if not normalized_raw.startswith("."):
|
||||
normalized_raw = f".{normalized_raw}"
|
||||
normalized = normalized_raw.lower()
|
||||
|
||||
guessed = mimetypes.types_map.get(normalized)
|
||||
if not guessed:
|
||||
guessed = mimetypes.common_types.get(normalized)
|
||||
if not guessed:
|
||||
guessed, _ = mimetypes.guess_type(f"x{normalized}", strict=False)
|
||||
if not guessed and normalized in {".webp", ".WEBP"}:
|
||||
guessed = "image/webp"
|
||||
if not guessed:
|
||||
guessed = "application/octet-stream"
|
||||
|
||||
variants = {normalized}
|
||||
variants.add(normalized_raw)
|
||||
if normalized_raw != normalized:
|
||||
variants.add(normalized_raw.upper())
|
||||
|
||||
for module in _iter_mimetype_modules():
|
||||
for variant in variants:
|
||||
_register_type(module, variant, guessed)
|
||||
|
||||
|
||||
ensure_mimetype_for_ext(".webp")
|
||||
|
||||
|
||||
def register_mimetypes_from_archive(path: Path) -> None:
|
||||
try:
|
||||
with zipfile.ZipFile(path, "r") as archive:
|
||||
for name in archive.namelist():
|
||||
suffix = Path(name).suffix
|
||||
if suffix:
|
||||
ensure_mimetype_for_ext(suffix)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
@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
|
||||
|
||||
guessed = guess_link_column(sheet, header_row)
|
||||
if guessed is not None:
|
||||
return guessed
|
||||
|
||||
raise ValueError(
|
||||
f"Sheet '{sheet.title}' cannot find link column '{link_column}' in header row {header_row}."
|
||||
)
|
||||
|
||||
|
||||
def looks_like_http_url(value: str) -> bool:
|
||||
return str(value or "").lower().startswith(("http://", "https://"))
|
||||
|
||||
|
||||
def looks_like_image_url(url: str) -> bool:
|
||||
raw = str(url or "").strip().lower()
|
||||
if not looks_like_http_url(raw):
|
||||
return False
|
||||
parsed = urllib.parse.urlparse(raw)
|
||||
path = parsed.path or ""
|
||||
host = parsed.hostname or ""
|
||||
if any(path.endswith(ext) for ext in SUPPORTED_EXTENSIONS):
|
||||
return True
|
||||
if host.startswith("img.") or "image" in host:
|
||||
return True
|
||||
if any(token in path for token in ("/img", "/image", "/upload")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def guess_link_column(sheet, header_row: int) -> int | None:
|
||||
max_scan_rows = min(sheet.max_row, header_row + 30)
|
||||
best_col = None
|
||||
best_score = -1
|
||||
|
||||
for col_idx in range(1, sheet.max_column + 1):
|
||||
header_val = str(sheet.cell(row=header_row, column=col_idx).value or "").strip().lower()
|
||||
score = 0
|
||||
if header_val:
|
||||
if header_val in IMAGE_HINT_HEADERS:
|
||||
score += 40
|
||||
if "图片" in header_val:
|
||||
score += 20
|
||||
if "链接" in header_val or "url" in header_val:
|
||||
score += 15
|
||||
|
||||
http_count = 0
|
||||
image_like_count = 0
|
||||
for row in range(header_row + 1, max_scan_rows + 1):
|
||||
cell_value = sheet.cell(row=row, column=col_idx).value
|
||||
if cell_value is None:
|
||||
continue
|
||||
text = str(cell_value).strip()
|
||||
if not text:
|
||||
continue
|
||||
if looks_like_http_url(text):
|
||||
http_count += 1
|
||||
if looks_like_image_url(text):
|
||||
image_like_count += 1
|
||||
|
||||
score += http_count * 3 + image_like_count * 6
|
||||
if image_like_count == 0 and http_count == 0 and score < 20:
|
||||
continue
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_col = col_idx
|
||||
|
||||
return best_col
|
||||
|
||||
|
||||
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 to_excel_safe_image(payload: bytes, extension: str) -> tuple[bytes, str]:
|
||||
ext = (extension or "").lower()
|
||||
ensure_mimetype_for_ext(ext)
|
||||
if ext in EXCEL_SAFE_EXTENSIONS and ext not in UNSAFE_OR_UNKNOWN_EXTENSIONS:
|
||||
return payload, ext
|
||||
|
||||
if PILImage is None:
|
||||
return payload, ".png"
|
||||
|
||||
with PILImage.open(io.BytesIO(payload)) as image:
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue(), ".png"
|
||||
|
||||
|
||||
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, temp_files: list[Path]) -> 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)
|
||||
payload, extension = to_excel_safe_image(payload, extension)
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as temp_file:
|
||||
temp_file.write(payload)
|
||||
temp_path = Path(temp_file.name)
|
||||
temp_files.append(temp_path)
|
||||
|
||||
image = XLImage(str(temp_path))
|
||||
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 drop_broken_images(workbook) -> int:
|
||||
removed = 0
|
||||
for sheet in workbook.worksheets:
|
||||
existing = list(getattr(sheet, "_images", []))
|
||||
if not existing:
|
||||
continue
|
||||
kept = []
|
||||
removed_in_sheet = 0
|
||||
for image in existing:
|
||||
try:
|
||||
image._data()
|
||||
kept.append(image)
|
||||
except Exception:
|
||||
removed += 1
|
||||
removed_in_sheet += 1
|
||||
if removed_in_sheet:
|
||||
sheet._images = kept
|
||||
return removed
|
||||
|
||||
|
||||
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
|
||||
|
||||
register_mimetypes_from_archive(input_path)
|
||||
ssl_context = build_ssl_context(args.skip_tls_verify)
|
||||
|
||||
workbook = load_workbook(input_path)
|
||||
sheets = choose_sheets(workbook, args.sheet)
|
||||
temp_files: list[Path] = []
|
||||
|
||||
all_results: list[tuple[str, RowResult]] = []
|
||||
for sheet in sheets:
|
||||
sheet_results = process_sheet(sheet, args, ssl_context, temp_files)
|
||||
all_results.extend((sheet.title, item) for item in sheet_results)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
try:
|
||||
workbook.save(output_path)
|
||||
except Exception as save_error:
|
||||
removed = drop_broken_images(workbook)
|
||||
if removed <= 0:
|
||||
raise
|
||||
print(f"WARN: dropped_broken_images={removed} reason={save_error}")
|
||||
workbook.save(output_path)
|
||||
finally:
|
||||
for file_path in temp_files:
|
||||
file_path.unlink(missing_ok=True)
|
||||
|
||||
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