"""
SAP C4C 附件下载工具
功能:
1. 下载 ServiceRequest 级别附件
2. 下载 XIssueItem 级别附件 (BO_XSRIssueItemAttachmentFolder)
3. 通过 Scrapling 爬虫下载 Salesforce 外部链接附件
4. 可选:将下载的附件上传到群晖 DSM
环境要求:
Python >= 3.8
安装依赖:
pip install requests scrapling[all] playwright
python -m playwright install chromium
用法:
# 下载附件
python sap-c4c-AttachmentFolder.py --tenant https://xxx.c4c.saphybriscloud.cn --user admin --password xxx --ticket 24588
# 下载附件并上传到群晖
python sap-c4c-AttachmentFolder.py --tenant https://xxx.c4c.saphybriscloud.cn --user admin --password xxx --ticket 24588 \
--dsm-url http://10.0.10.235:5000 --dsm-user PLM --dsm-password 123456 --dsm-path /Newgonow/AU-SPFJ
# JSON 模式(供 Java/其他程序调用)
python sap-c4c-AttachmentFolder.py --ticket 24588 --dsm-url http://10.0.10.235:5000 --dsm-user PLM --dsm-password 123456 --dsm-path /Newgonow/AU-SPFJ --json
# 也可通过环境变量传入凭证
export C4C_TENANT=https://xxx.c4c.saphybriscloud.cn
export C4C_USERNAME=admin
export C4C_PASSWORD=xxx
export DSM_URL=http://10.0.10.235:5000
export DSM_USERNAME=PLM
export DSM_PASSWORD=123456
export DSM_PATH=/Newgonow/AU-SPFJ
python sap-c4c-AttachmentFolder.py --ticket 24588 --json
"""
import os
import sys
import json
import argparse
import base64
import mimetypes
import requests
import urllib3
import xml.etree.ElementTree as ET
from scrapling.fetchers import StealthyFetcher
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
TENANT = ""
USERNAME = ""
PASSWORD = ""
# OData 路径(在 main 中根据 TENANT 初始化)
ODATA_C4C = ""
ODATA_CUST = ""
# SOAP endpoint(如需通过 SOAP 下载文件内容,请替换为实际地址)
SOAP_URL = f"{TENANT}/sap/bc/srt/scs/sap/manageattachmentfolderin"
# 默认值,可通过命令行参数覆盖
OUTPUT_DIR = "downloads"
# 群晖 DSM 配置(可选,在 main 中初始化)
DSM_URL = ""
DSM_USER = ""
DSM_PASSWORD = ""
DSM_PATH = ""
# 多线程配置
MAX_WORKERS = 5 # 默认并发数
print_lock = Lock() # 用于线程安全的打印输出
def get_session():
s = requests.Session()
s.auth = (USERNAME, PASSWORD)
s.headers.update({"Accept": "application/json"})
return s
def find_service_request_object_id(session, ticket_id):
"""通过人类可读的 ticket ID 查找 OData ObjectID 和 SerialID"""
url = f"{ODATA_C4C}/ServiceRequestCollection"
params = {"$format": "json", "$filter": f"ID eq '{ticket_id}'"}
resp = session.get(url, params=params, timeout=60)
resp.raise_for_status()
results = resp.json().get("d", {}).get("results", [])
if not results:
raise ValueError(f"未找到 ID={ticket_id} 的 ServiceRequest")
sr = results[0]
serial_id = sr.get("SerialID", "")
return sr["ObjectID"], serial_id
def _parse_attachments(results):
"""将 OData 返回的附件列表解析为统一格式"""
attachments = []
for row in results:
uuid = row.get("UUID")
file_name = row.get("Name") or f"{uuid}.bin"
mime_type = row.get("MimeType") or "application/octet-stream"
category = row.get("CategoryCode") # 2=File, 3=Link
link_uri = row.get("LinkWebURI")
if uuid:
attachments.append({
"UUID": uuid,
"ObjectID": row.get("ObjectID"),
"ParentObjectID": row.get("ParentObjectID"),
"FileName": file_name,
"MimeType": mime_type,
"CategoryCode": category,
"LinkWebURI": link_uri,
"DocumentLink": row.get("DocumentLink"),
"SizeInkB": row.get("SizeInkB"),
})
return attachments
def list_sr_attachments(session, sr_object_id):
"""获取 ServiceRequest 级别的附件(通过 c4codata 导航)"""
url = f"{ODATA_C4C}/ServiceRequestCollection('{sr_object_id}')/ServiceRequestAttachmentFolder"
params = {"$format": "json"}
resp = session.get(url, params=params, timeout=60)
resp.raise_for_status()
results = resp.json().get("d", {}).get("results", [])
return _parse_attachments(results)
def list_issue_items(session, ticket_id):
"""获取 ServiceRequest 下的 XIssueItem 列表(通过 custticketapi)"""
url = f"{ODATA_CUST}/ServiceRequest_XIssueItem_SDKCollection"
params = {"$format": "json", "$filter": f"TicketID eq '{ticket_id}'"}
resp = session.get(url, params=params, timeout=60)
resp.raise_for_status()
return resp.json().get("d", {}).get("results", [])
def get_issue_item_detail(session, object_id):
"""通过 ObjectID 获取 XIssueItem 详细信息,包括真实的 IssueID_SDK"""
url = f"{ODATA_CUST}/ServiceRequest_XIssueItem_SDKCollection('{object_id}')"
params = {"$format": "json"}
resp = session.get(url, params=params, timeout=60)
resp.raise_for_status()
return resp.json().get("d", {}).get("results", {})
def list_issue_item_attachments(session, issue_item_uuid):
"""
获取 XIssueItem 级别的附件。
路径: BO_XSRIssueItemAttachmentCollection (按 XIssueItemUUID 过滤)
-> BO_XSRIssueItemAttachmentFolder (实际附件文件)
"""
# Step 1: 通过 XIssueItemUUID 找到 BO_XSRIssueItemAttachment
url = f"{ODATA_CUST}/BO_XSRIssueItemAttachmentCollection"
params = {"$format": "json", "$filter": f"XIssueItemUUID eq guid'{issue_item_uuid}'"}
resp = session.get(url, params=params, timeout=60)
resp.raise_for_status()
att_results = resp.json().get("d", {}).get("results", [])
if not att_results:
return []
# Step 2: 对每个 Attachment 导航到 AttachmentFolder
all_attachments = []
for att in att_results:
att_oid = att["ObjectID"]
folder_url = (
f"{ODATA_CUST}/BO_XSRIssueItemAttachmentCollection('{att_oid}')"
f"/BO_XSRIssueItemAttachmentFolder"
)
resp2 = session.get(folder_url, params={"$format": "json"}, timeout=60)
resp2.raise_for_status()
folders = resp2.json().get("d", {}).get("results", [])
all_attachments.extend(_parse_attachments(folders))
return all_attachments
def download_file_via_odata(session, attachment, base_url=None):
"""通过 OData $value 直接下载文件内容(适用于 CategoryCode=2 的文件附件)"""
# 优先使用 DocumentLink(custticketapi 返回的完整 $value URL)
doc_link = attachment.get("DocumentLink")
if doc_link:
url = doc_link
else:
obj_id = attachment["ObjectID"]
if base_url is None:
base_url = f"{ODATA_C4C}/ServiceRequestAttachmentFolderCollection"
url = f"{base_url}('{obj_id}')/$value"
resp = session.get(url, timeout=300, stream=True)
resp.raise_for_status()
return resp.content
def build_read_documents_payload(document_uuids, size_limit_kb=10240):
uuid_xml = "\n".join(
f"{u}" for u in document_uuids
)
return f"""
{uuid_xml}
{size_limit_kb}
"""
def read_documents_file_content(session, document_uuids):
payload = build_read_documents_payload(document_uuids)
headers = {"Content-Type": "text/xml; charset=utf-8"}
resp = session.post(SOAP_URL, data=payload.encode("utf-8"), headers=headers, timeout=120)
resp.raise_for_status()
return resp.text
def parse_soap_response(xml_text):
root = ET.fromstring(xml_text)
items = []
for elem in root.iter():
if elem.tag.endswith("AttachmentFolderDocumentFileContent"):
item = {"DocumentUUID": None, "BinaryObject": None}
for child in elem:
if child.tag.endswith("DocumentUUID"):
item["DocumentUUID"] = child.text
elif child.tag.endswith("BinaryObject"):
item["BinaryObject"] = child.text
if item["DocumentUUID"] and item["BinaryObject"]:
items.append(item)
more_hits = False
for elem in root.iter():
if elem.tag.endswith("MoreHitsAvailableIndicator") and (elem.text or "").lower() == "true":
more_hits = True
break
return items, more_hits
def save_files(content_items, attachment_index):
for item in content_items:
doc_uuid = item["DocumentUUID"]
binary_b64 = item["BinaryObject"]
meta = attachment_index.get(doc_uuid, {})
file_name = meta.get("FileName", f"{doc_uuid}.bin")
file_path = os.path.join(OUTPUT_DIR, file_name)
with open(file_path, "wb") as f:
f.write(base64.b64decode(binary_b64))
print(f" saved: {file_path}")
def chunked(seq, size):
for i in range(0, len(seq), size):
yield seq[i:i + size]
def download_link_via_scrapling(link_url, save_name):
"""通过 Scrapling 打开外部链接页面,点击 .downloadbutton 按钮下载文件"""
result = {"saved": None, "error": None}
def click_download(page):
page.wait_for_selector("button.downloadbutton[title='Download']", timeout=15000)
with page.expect_download(timeout=120000) as download_info:
page.click("button.downloadbutton[title='Download']")
download = download_info.value
filename = download.suggested_filename or save_name
save_path = os.path.join(OUTPUT_DIR, filename)
download.save_as(save_path)
result["saved"] = save_path
try:
StealthyFetcher.fetch(
link_url,
headless=True,
network_idle=True,
timeout=60000,
page_action=click_download,
)
except Exception as e:
result["error"] = str(e)
return result
# ==================== 群晖 DSM 上传 ====================
def dsm_login():
"""登录群晖 DSM,返回 SID"""
resp = requests.get(f"{DSM_URL}/webapi/auth.cgi", params={
"api": "SYNO.API.Auth", "version": "3", "method": "login",
"account": DSM_USER, "passwd": DSM_PASSWORD,
"session": "FileStation", "format": "sid",
}, verify=False, timeout=30)
resp.raise_for_status()
data = resp.json()
if not data.get("success"):
raise RuntimeError(f"DSM 登录失败: {data}")
return data["data"]["sid"]
def dsm_upload_file(sid, local_path, remote_path):
"""上传单个文件到群晖 DSM"""
filename = os.path.basename(local_path)
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
form = {
"api": "SYNO.FileStation.Upload",
"version": "2",
"method": "upload",
"path": remote_path,
"create_parents": "true",
"overwrite": "true",
}
with open(local_path, "rb") as f:
resp = requests.post(
f"{DSM_URL}/webapi/entry.cgi",
data=form,
files={"file": (filename, f, mime)},
cookies={"id": sid},
verify=False,
timeout=600,
)
resp.raise_for_status()
data = resp.json()
if not data.get("success"):
raise RuntimeError(f"DSM 上传失败: {data}")
return data
def dsm_upload_downloaded_files(downloaded_files, ticket_id, serial_id, json_mode=False):
"""将所有已下载文件上传到群晖 DSM,按 ticket 和 issue 组织目录结构"""
if not DSM_URL or not DSM_USER or not DSM_PASSWORD or not DSM_PATH:
return []
files_to_upload = [f for f in downloaded_files if f.get("savedPath") and not f.get("error")]
if not files_to_upload:
return []
# 目录名: ticketID_serialID
folder_name = f"{ticket_id}_{serial_id}" if serial_id else ticket_id
if not json_mode:
print(f"\n{'='*60}")
print(f"上传到群晖 DSM: {DSM_URL}")
print(f"目标路径: {DSM_PATH}/{folder_name}")
print('='*60)
upload_results = []
try:
sid = dsm_login()
if not json_mode:
print(f" DSM 登录成功")
except Exception as e:
if not json_mode:
print(f" DSM 登录失败: {e}", file=sys.stderr)
return [{"error": f"DSM 登录失败: {e}"}]
for f in files_to_upload:
local_path = f["savedPath"]
filename = os.path.basename(local_path)
issue_id = f.get("issueId", "")
# 根据 issueId 判断目录结构
# SR 附件: {DSM_PATH}/{ticketID_serialID}/{filename}
# IssueItem 附件: {DSM_PATH}/{ticketID_serialID}/{issueID}/{filename}
if issue_id:
remote_path = f"{DSM_PATH}/{folder_name}/{issue_id}"
else:
remote_path = f"{DSM_PATH}/{folder_name}"
full_remote_path = f"{remote_path}/{filename}"
entry = {
"file": filename,
"ticketId": ticket_id,
"serialId": serial_id,
"issueId": issue_id,
"remotePath": full_remote_path,
}
try:
dsm_upload_file(sid, local_path, remote_path)
entry["success"] = True
if not json_mode:
print(f" 上传成功: {filename} -> {full_remote_path}")
except Exception as e:
entry["success"] = False
entry["error"] = str(e)
if not json_mode:
print(f" 上传失败: {filename}: {e}")
upload_results.append(entry)
if not json_mode:
ok = sum(1 for r in upload_results if r.get("success"))
fail = len(upload_results) - ok
print(f"\n 上传完成: {ok} 成功, {fail} 失败")
return upload_results
def print_attachment_summary(all_attachments):
"""打印附件清单汇总"""
print(f"\n{'='*60}")
print("附件清单汇总")
print('='*60)
if not all_attachments:
print(" 无附件")
return
total_files = 0
total_links = 0
for level, atts in all_attachments:
for a in atts:
if a["CategoryCode"] == "2":
total_files += 1
elif a["CategoryCode"] == "3":
total_links += 1
print(f" 合计: {total_files} 个文件附件, {total_links} 个链接附件\n")
idx = 0
for level, atts in all_attachments:
if not atts:
continue
print(f" [{level}]")
for a in atts:
idx += 1
cat = "文件" if a["CategoryCode"] == "2" else "链接"
size = a.get("SizeInkB", "")
size_str = ""
if size and cat == "文件":
try:
kb = float(size)
if kb > 1024:
size_str = f" ({kb/1024:.1f} MB)"
else:
size_str = f" ({kb:.0f} KB)"
except ValueError:
pass
mime = a.get("MimeType") or ""
link = a.get("LinkWebURI") or ""
print(f" {idx}. [{cat}] {a['FileName']}{size_str}")
if mime:
print(f" MIME: {mime}")
if link:
print(f" 链接: {link}")
print()
def run(ticket_id, output_dir, list_only=False, json_mode=False):
"""核心逻辑,返回结构化结果"""
global OUTPUT_DIR
OUTPUT_DIR = output_dir
os.makedirs(OUTPUT_DIR, exist_ok=True)
session = get_session()
result = {
"ticketId": ticket_id,
"outputDir": os.path.abspath(OUTPUT_DIR),
"success": True,
"error": None,
"srAttachments": [],
"issueItems": [],
"downloadedFiles": [],
}
try:
# 1) 通过 ticket ID 找到 ObjectID 和 SerialID
sr_object_id, serial_id = find_service_request_object_id(session, ticket_id)
result["srObjectId"] = sr_object_id
result["serialId"] = serial_id
if not json_mode:
print(f"ServiceRequest ID={ticket_id}, ObjectID={sr_object_id}, SerialID={serial_id}")
# 2) ServiceRequest 级别附件
if not json_mode:
print(f"\n{'='*60}")
print("ServiceRequest 级别附件")
print('='*60)
sr_attachments = list_sr_attachments(session, sr_object_id)
result["srAttachments"] = sr_attachments
if not json_mode:
print(f"找到 {len(sr_attachments)} 个附件")
if not list_only:
_do_download(session, sr_attachments, "SR", "", None, result, json_mode)
# 3) XIssueItem 级别附件
if not json_mode:
print(f"\n{'='*60}")
print("XIssueItem 级别附件 (BO_XSRIssueItemAttachmentFolder)")
print('='*60)
issue_items = list_issue_items(session, ticket_id)
if not json_mode:
print(f"找到 {len(issue_items)} 个 XIssueItem")
for item in issue_items:
item_oid = item["ObjectID"]
issue_uuid = item.get("XIssueItemUUIDcontent_SDK", "")
issue_desc = (item.get("IssuesDescriptionX_SDK") or "")[:80]
# 通过 ObjectID 查询详细信息,获取真实的 IssueID_SDK
issue_id = ""
try:
item_detail = get_issue_item_detail(session, item_oid)
issue_id = item_detail.get("IssueID_SDK", "")
except Exception as e:
print(f" ⚠ 获取 IssueID 失败: {e}", file=sys.stderr)
issue_entry = {
"objectId": item_oid,
"issueId": issue_id,
"uuid": issue_uuid,
"description": issue_desc,
"attachments": [],
}
if not json_mode:
print(f"\n XIssueItem: {item_oid}")
if issue_id:
print(f" IssueID: {issue_id}")
print(f" UUID: {issue_uuid}")
print(f" 描述: {issue_desc}")
if not issue_uuid:
if not json_mode:
print(" ⚠ 无 XIssueItemUUID,跳过")
result["issueItems"].append(issue_entry)
continue
atts = list_issue_item_attachments(session, issue_uuid)
issue_entry["attachments"] = atts
if not json_mode:
print(f" 找到 {len(atts)} 个附件")
if not list_only:
label = f"IssueItem-{issue_id}" if issue_id else f"IssueItem-{item_oid[:12]}"
_do_download(
session, atts, label, issue_id,
f"{ODATA_CUST}/BO_XSRIssueItemAttachmentFolderCollection",
result, json_mode,
)
result["issueItems"].append(issue_entry)
# 4) 汇总清单
if not json_mode:
all_attachments = [("SR", sr_attachments)]
for ie in result["issueItems"]:
ie_label = f"IssueItem-{ie['issueId']}" if ie.get("issueId") else f"IssueItem-{ie['objectId'][:12]}"
all_attachments.append((ie_label, ie["attachments"]))
print_attachment_summary(all_attachments)
except Exception as e:
result["success"] = False
result["error"] = str(e)
if not json_mode:
print(f"\n错误: {e}", file=sys.stderr)
return result
def _download_single_file(session, att, label, issue_id, odata_url, json_mode):
"""下载单个文件附件(用于多线程)"""
entry = {"source": label, "issueId": issue_id, "c4cName": att["FileName"], "type": "file", "mime": att.get("MimeType")}
try:
content = download_file_via_odata(session, att, odata_url)
file_path = os.path.join(OUTPUT_DIR, att["FileName"])
with open(file_path, "wb") as f:
f.write(content)
entry["savedPath"] = os.path.abspath(file_path)
entry["savedName"] = att["FileName"]
if not json_mode:
with print_lock:
print(f" ✓ saved: {file_path}")
except Exception as e:
entry["error"] = str(e)
if not json_mode:
with print_lock:
print(f" ✗ OData 下载失败 ({att['FileName']}): {e}")
return entry
def _download_single_link(link_att, label, issue_id, json_mode):
"""下载单个链接附件(用于多线程)"""
link_url = link_att.get("LinkWebURI")
entry = {"source": label, "issueId": issue_id, "c4cName": link_att["FileName"], "type": "link", "linkUrl": link_url}
if not link_url:
entry["error"] = "无链接地址"
return entry
if not json_mode:
with print_lock:
print(f" {link_att['FileName']}: {link_url}")
r = download_link_via_scrapling(link_url, link_att["FileName"])
if r["saved"]:
entry["savedPath"] = os.path.abspath(r["saved"])
entry["savedName"] = os.path.basename(r["saved"])
if not json_mode:
with print_lock:
print(f" ✓ saved: {r['saved']}")
else:
entry["error"] = r["error"]
if not json_mode:
with print_lock:
print(f" ✗ 下载失败: {r['error']}")
return entry
def _do_download(session, attachments, label, issue_id, odata_url, result, json_mode):
"""执行下载并将结果追加到 result['downloadedFiles'](多线程版本)"""
file_atts = [a for a in attachments if a["CategoryCode"] == "2"]
link_atts = [a for a in attachments if a["CategoryCode"] == "3"]
downloaded_entries = []
# 使用线程池并发下载
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
futures = []
# 提交文件附件下载任务
for att in file_atts:
future = executor.submit(_download_single_file, session, att, label, issue_id, odata_url, json_mode)
futures.append(future)
# 提交链接附件下载任务
for att in link_atts:
future = executor.submit(_download_single_link, att, label, issue_id, json_mode)
futures.append(future)
# 收集结果
for future in as_completed(futures):
try:
entry = future.result()
downloaded_entries.append(entry)
except Exception as e:
if not json_mode:
with print_lock:
print(f" ✗ 下载任务异常: {e}")
# 将结果追加到总结果中
result["downloadedFiles"].extend(downloaded_entries)
def main():
parser = argparse.ArgumentParser(description="SAP C4C 附件下载工具")
parser.add_argument("--tenant", default=os.environ.get("C4C_TENANT", ""),
help="C4C 租户地址 (如 https://xxx.c4c.saphybriscloud.cn),也可设 C4C_TENANT 环境变量")
parser.add_argument("--user", default=os.environ.get("C4C_USERNAME", ""),
help="C4C 用户名,也可设 C4C_USERNAME 环境变量")
parser.add_argument("--password", default=os.environ.get("C4C_PASSWORD", ""),
help="C4C 密码,也可设 C4C_PASSWORD 环境变量")
parser.add_argument("--ticket", required=True, help="ServiceRequest ticket ID (如 24588)")
parser.add_argument("--output-dir", default="downloads", help="附件保存目录 (默认: downloads)")
parser.add_argument("--json", action="store_true", dest="json_mode", help="JSON 输出模式(供程序调用)")
parser.add_argument("--list-only", action="store_true", help="仅列出附件清单,不下载")
parser.add_argument("--max-workers", type=int, default=5, help="并发下载线程数 (默认: 5)")
# 群晖 DSM 上传参数
parser.add_argument("--dsm-url", default=os.environ.get("DSM_URL", ""),
help="群晖 DSM 地址 (如 http://10.0.10.235:5000),也可设 DSM_URL 环境变量")
parser.add_argument("--dsm-user", default=os.environ.get("DSM_USERNAME", ""),
help="群晖 DSM 用户名,也可设 DSM_USERNAME 环境变量")
parser.add_argument("--dsm-password", default=os.environ.get("DSM_PASSWORD", ""),
help="群晖 DSM 密码,也可设 DSM_PASSWORD 环境变量")
parser.add_argument("--dsm-path", default=os.environ.get("DSM_PATH", ""),
help="群晖 DSM 目标路径 (如 /Newgonow/AU-SPFJ),也可设 DSM_PATH 环境变量")
args = parser.parse_args()
if not args.tenant or not args.user or not args.password:
parser.error("必须提供 --tenant, --user, --password 参数,或设置 C4C_TENANT, C4C_USERNAME, C4C_PASSWORD 环境变量")
# 初始化全局配置
global TENANT, USERNAME, PASSWORD, ODATA_C4C, ODATA_CUST, SOAP_URL, MAX_WORKERS
TENANT = args.tenant.rstrip("/")
USERNAME = args.user
PASSWORD = args.password
ODATA_C4C = f"{TENANT}/sap/c4c/odata/v1/c4codata"
ODATA_CUST = f"{TENANT}/sap/c4c/odata/cust/v1/custticketapi"
SOAP_URL = f"{TENANT}/sap/bc/srt/scs/sap/manageattachmentfolderin"
MAX_WORKERS = args.max_workers
# 初始化 DSM 配置
global DSM_URL, DSM_USER, DSM_PASSWORD, DSM_PATH
DSM_URL = args.dsm_url.rstrip("/") if args.dsm_url else ""
DSM_USER = args.dsm_user
DSM_PASSWORD = args.dsm_password
DSM_PATH = args.dsm_path
if not args.json_mode and not args.list_only:
print(f"并发下载线程数: {MAX_WORKERS}")
result = run(args.ticket, args.output_dir, args.list_only, args.json_mode)
# 下载完成后上传到群晖 DSM
if DSM_URL and not args.list_only and result["success"]:
serial_id = result.get("serialId", "")
upload_results = dsm_upload_downloaded_files(result["downloadedFiles"], args.ticket, serial_id, args.json_mode)
result["dsmUpload"] = upload_results
# 上传完成后清理本地下载文件
for f in result["downloadedFiles"]:
local_path = f.get("savedPath")
if local_path and os.path.exists(local_path):
try:
os.remove(local_path)
if not args.json_mode:
print(f" 已删除本地文件: {local_path}")
except OSError as e:
if not args.json_mode:
print(f" 删除失败: {local_path}: {e}")
if args.json_mode:
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if result["success"] else 1)
if __name__ == "__main__":
main()