Initial commit: SAP C4C attachment downloader toolkit
- Add Python script for downloading C4C attachments via OData and web scraping - Add Java wrapper for programmatic access with typed API - Add DSM upload utility for Synology NAS integration - Add CLAUDE.md documentation for future development - Add .gitignore for Python, Java, and sensitive files
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(git init:*)",
|
||||||
|
"Bash(git add:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Java
|
||||||
|
*.class
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
target/
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Credentials and sensitive data
|
||||||
|
*.env
|
||||||
|
.env
|
||||||
|
credentials.json
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# Downloaded files
|
||||||
|
downloads/
|
||||||
|
*.log
|
||||||
507
C4CAttachmentDownloader.java
Normal file
507
C4CAttachmentDownloader.java
Normal file
@@ -0,0 +1,507 @@
|
|||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SAP C4C 附件下载工具 - Java 调用封装
|
||||||
|
*
|
||||||
|
* <p>通过 ProcessBuilder 调用 Python 脚本 sap-c4c-AttachmentFolder.py,
|
||||||
|
* 以 --json 模式获取结构化结果。</p>
|
||||||
|
*
|
||||||
|
* <h3>前置条件</h3>
|
||||||
|
* <pre>
|
||||||
|
* pip install requests scrapling[all] playwright
|
||||||
|
* python -m playwright install chromium
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* <h3>使用示例</h3>
|
||||||
|
* <pre>{@code
|
||||||
|
* C4CAttachmentDownloader downloader = new C4CAttachmentDownloader(
|
||||||
|
* "/path/to/sap-c4c-AttachmentFolder.py",
|
||||||
|
* "https://xxx.c4c.saphybriscloud.cn",
|
||||||
|
* "admin",
|
||||||
|
* "password"
|
||||||
|
* );
|
||||||
|
*
|
||||||
|
* // 仅列出附件清单
|
||||||
|
* C4CAttachmentDownloader.Result result = downloader.listAttachments("24588");
|
||||||
|
*
|
||||||
|
* // 下载全部附件到指定目录
|
||||||
|
* C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
|
||||||
|
* for (C4CAttachmentDownloader.DownloadedFile f : result.getDownloadedFiles()) {
|
||||||
|
* System.out.println(f.getSavedPath());
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // 下载附件并上传到群晖 DSM
|
||||||
|
* downloader.setDsmConfig("http://10.0.10.235:5000", "PLM", "123456", "/Newgonow/AU-SPFJ");
|
||||||
|
* C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
|
||||||
|
* for (C4CAttachmentDownloader.DsmUploadEntry u : result.getDsmUpload()) {
|
||||||
|
* System.out.println(u.getFile() + " -> " + u.getRemotePath() + " success=" + u.isSuccess());
|
||||||
|
* }
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
|
||||||
|
// [2026-03-12 11:46:10] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg> (referer: https://www.google.com/)
|
||||||
|
// [2026-03-12 11:46:24] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE> (referer: https://www.google.com/)
|
||||||
|
// [2026-03-12 11:46:40] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg> (referer: https://www.google.com/)
|
||||||
|
// [2026-03-12 11:46:56] INFO: Fetched (200) <GET https://regentrv.my.salesforce.com/sfc/p/#2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw> (referer: https://www.google.com/)
|
||||||
|
// {
|
||||||
|
// "ticketId": "24588",
|
||||||
|
// "outputDir": "/tmp/att",
|
||||||
|
// "success": true,
|
||||||
|
// "error": null,
|
||||||
|
// "srAttachments": [
|
||||||
|
// {
|
||||||
|
// "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E3F77",
|
||||||
|
// "ObjectID": "FA163E8749F01FD0A0C822793B4E3F77",
|
||||||
|
// "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77",
|
||||||
|
// "FileName": "Quotation",
|
||||||
|
// "MimeType": "application/octet-stream",
|
||||||
|
// "CategoryCode": "3",
|
||||||
|
// "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg",
|
||||||
|
// "DocumentLink": "",
|
||||||
|
// "SizeInkB": "0.00000000000000"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E5F77",
|
||||||
|
// "ObjectID": "FA163E8749F01FD0A0C822793B4E5F77",
|
||||||
|
// "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77",
|
||||||
|
// "FileName": "Proof of Purchase",
|
||||||
|
// "MimeType": "application/octet-stream",
|
||||||
|
// "CategoryCode": "3",
|
||||||
|
// "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE",
|
||||||
|
// "DocumentLink": "",
|
||||||
|
// "SizeInkB": "0.00000000000000"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "UUID": "FA163E87-49F0-1FD0-A0C8-22793B4E7F77",
|
||||||
|
// "ObjectID": "FA163E8749F01FD0A0C822793B4E7F77",
|
||||||
|
// "ParentObjectID": "FA163E8749F01FD0A0C822793B4EDF77",
|
||||||
|
// "FileName": "Chassis Number",
|
||||||
|
// "MimeType": "application/octet-stream",
|
||||||
|
// "CategoryCode": "3",
|
||||||
|
// "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg",
|
||||||
|
// "DocumentLink": "",
|
||||||
|
// "SizeInkB": "0.00000000000000"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// "issueItems": [
|
||||||
|
// {
|
||||||
|
// "objectId": "FA163E8749F01FD0A0C822793B4F1F77",
|
||||||
|
// "uuid": "FA163E87-49F0-1FD0-A0C8-228700443F77",
|
||||||
|
// "description": "Camera not paring. tried 4 times not working",
|
||||||
|
// "attachments": [
|
||||||
|
// {
|
||||||
|
// "UUID": "FA163E82-A315-1FD0-A2C0-35B6AE95B9BA",
|
||||||
|
// "ObjectID": "FA163E82A3151FD0A2C035B6AE95B9BA",
|
||||||
|
// "ParentObjectID": "FA163E8749F01FD0A0C8228700449F77",
|
||||||
|
// "FileName": "IMG_4307.mp4",
|
||||||
|
// "MimeType": "video/mp4",
|
||||||
|
// "CategoryCode": "2",
|
||||||
|
// "LinkWebURI": "",
|
||||||
|
// "DocumentLink": "https://my300375.c4c.saphybriscloud.cn/sap/c4c/odata/cust/v1/custticketapi/BO_XSRIssueItemAttachmentCollection('FA163E8749F01FD0A0C8228700449F77')/BO_XSRIssueItemAttachmentFolder('FA163E82A3151FD0A2C035B6AE95B9BA')/Binary/$value",
|
||||||
|
// "SizeInkB": "2997017.00000000000000"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "UUID": "FA163E87-49F0-1FD0-A0C8-22870048FF77",
|
||||||
|
// "ObjectID": "FA163E8749F01FD0A0C822870048FF77",
|
||||||
|
// "ParentObjectID": "FA163E8749F01FD0A0C8228700449F77",
|
||||||
|
// "FileName": "PD Camera video.docx",
|
||||||
|
// "MimeType": "application/octet-stream",
|
||||||
|
// "CategoryCode": "3",
|
||||||
|
// "LinkWebURI": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw",
|
||||||
|
// "DocumentLink": "",
|
||||||
|
// "SizeInkB": "0.00000000000000"
|
||||||
|
// }
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// "downloadedFiles": [
|
||||||
|
// {
|
||||||
|
// "source": "SR",
|
||||||
|
// "c4cName": "Quotation",
|
||||||
|
// "type": "link",
|
||||||
|
// "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ3B/4JQvOAb263K5kCW8sIAbYT.nFMeW8z7hT9QS4h0P9Mg",
|
||||||
|
// "savedPath": "/tmp/att/Warranty Quoting PD.xlsx",
|
||||||
|
// "savedName": "Warranty Quoting PD.xlsx"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "source": "SR",
|
||||||
|
// "c4cName": "Proof of Purchase",
|
||||||
|
// "type": "link",
|
||||||
|
// "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ4n/ppCBH.gcnV2K.3qCA_U9ZDtZ9r_UffyJGEuteuWMFAE",
|
||||||
|
// "savedPath": "/tmp/att/PD VIN.jpg",
|
||||||
|
// "savedName": "PD VIN.jpg"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "source": "SR",
|
||||||
|
// "c4cName": "Chassis Number",
|
||||||
|
// "type": "link",
|
||||||
|
// "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EZ6P/mDuF2NKyP96SGjWzuxXl7eejFb84ORKtcG0bPUAqrsg",
|
||||||
|
// "savedPath": "/tmp/att/PD Chassis.jpg",
|
||||||
|
// "savedName": "PD Chassis.jpg"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "source": "IssueItem-FA163E8749F0",
|
||||||
|
// "c4cName": "PD Camera video.docx",
|
||||||
|
// "type": "link",
|
||||||
|
// "linkUrl": "https://regentrv.my.salesforce.com/sfc/p/2t000000cc9V/a/W2000002EWRu/v46Grw_mnbsjKrU1LlWrN7Ysh9vT5iOydQbr0nSYsxw",
|
||||||
|
// "savedPath": "/tmp/att/PD Camera video.docx",
|
||||||
|
// "savedName": "PD Camera video.docx"
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// "source": "IssueItem-FA163E8749F0",
|
||||||
|
// "c4cName": "IMG_4307.mp4",
|
||||||
|
// "type": "file",
|
||||||
|
// "mime": "video/mp4",
|
||||||
|
// "savedPath": "/tmp/att/IMG_4307.mp4",
|
||||||
|
// "savedName": "IMG_4307.mp4"
|
||||||
|
// }
|
||||||
|
// ],
|
||||||
|
// "srObjectId": "FA163E8749F01FD0A0C822793B4EDF77"
|
||||||
|
// }
|
||||||
|
public class C4CAttachmentDownloader {
|
||||||
|
|
||||||
|
private final String pythonCmd;
|
||||||
|
private final String scriptPath;
|
||||||
|
private final String tenant;
|
||||||
|
private final String username;
|
||||||
|
private final String password;
|
||||||
|
private final long timeoutMinutes;
|
||||||
|
|
||||||
|
// 群晖 DSM 配置(可选)
|
||||||
|
private String dsmUrl;
|
||||||
|
private String dsmUser;
|
||||||
|
private String dsmPassword;
|
||||||
|
private String dsmPath;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scriptPath Python 脚本的绝对路径
|
||||||
|
* @param tenant C4C 租户地址 (如 https://xxx.c4c.saphybriscloud.cn)
|
||||||
|
* @param username C4C 用户名
|
||||||
|
* @param password C4C 密码
|
||||||
|
*/
|
||||||
|
public C4CAttachmentDownloader(String scriptPath, String tenant, String username, String password) {
|
||||||
|
this(scriptPath, tenant, username, password, "python3", 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param scriptPath Python 脚本的绝对路径
|
||||||
|
* @param tenant C4C 租户地址
|
||||||
|
* @param username C4C 用户名
|
||||||
|
* @param password C4C 密码
|
||||||
|
* @param pythonCmd Python 命令 (python3 / python)
|
||||||
|
* @param timeoutMinutes 超时时间(分钟)
|
||||||
|
*/
|
||||||
|
public C4CAttachmentDownloader(String scriptPath, String tenant, String username, String password,
|
||||||
|
String pythonCmd, long timeoutMinutes) {
|
||||||
|
this.scriptPath = scriptPath;
|
||||||
|
this.tenant = tenant;
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.pythonCmd = pythonCmd;
|
||||||
|
this.timeoutMinutes = timeoutMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置群晖 DSM 上传配置(可选)。设置后,download() 会在下载完成后自动上传到 DSM。
|
||||||
|
*
|
||||||
|
* @param dsmUrl DSM 地址 (如 http://10.0.10.235:5000)
|
||||||
|
* @param dsmUser DSM 用户名
|
||||||
|
* @param dsmPassword DSM 密码
|
||||||
|
* @param dsmPath DSM 目标路径 (如 /Newgonow/AU-SPFJ)
|
||||||
|
*/
|
||||||
|
public void setDsmConfig(String dsmUrl, String dsmUser, String dsmPassword, String dsmPath) {
|
||||||
|
this.dsmUrl = dsmUrl;
|
||||||
|
this.dsmUser = dsmUser;
|
||||||
|
this.dsmPassword = dsmPassword;
|
||||||
|
this.dsmPath = dsmPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅列出附件清单,不下载
|
||||||
|
*/
|
||||||
|
public Result listAttachments(String ticketId) throws Exception {
|
||||||
|
return execute(ticketId, null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载全部附件到默认 downloads 目录
|
||||||
|
*/
|
||||||
|
public Result download(String ticketId) throws Exception {
|
||||||
|
return execute(ticketId, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载全部附件到指定目录
|
||||||
|
*/
|
||||||
|
public Result download(String ticketId, String outputDir) throws Exception {
|
||||||
|
return execute(ticketId, outputDir, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Result execute(String ticketId, String outputDir, boolean listOnly) throws Exception {
|
||||||
|
List<String> cmd = new ArrayList<>();
|
||||||
|
cmd.add(pythonCmd);
|
||||||
|
cmd.add(scriptPath);
|
||||||
|
cmd.add("--ticket");
|
||||||
|
cmd.add(ticketId);
|
||||||
|
cmd.add("--json");
|
||||||
|
|
||||||
|
if (outputDir != null && !outputDir.isEmpty()) {
|
||||||
|
cmd.add("--output-dir");
|
||||||
|
cmd.add(outputDir);
|
||||||
|
}
|
||||||
|
if (listOnly) {
|
||||||
|
cmd.add("--list-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessBuilder pb = new ProcessBuilder(cmd);
|
||||||
|
// 凭证通过环境变量传递,比命令行参数更安全(不会出现在 ps 进程列表中)
|
||||||
|
pb.environment().put("C4C_TENANT", tenant);
|
||||||
|
pb.environment().put("C4C_USERNAME", username);
|
||||||
|
pb.environment().put("C4C_PASSWORD", password);
|
||||||
|
|
||||||
|
// DSM 配置通过环境变量传递
|
||||||
|
if (dsmUrl != null && !dsmUrl.isEmpty()) {
|
||||||
|
pb.environment().put("DSM_URL", dsmUrl);
|
||||||
|
pb.environment().put("DSM_USERNAME", dsmUser != null ? dsmUser : "");
|
||||||
|
pb.environment().put("DSM_PASSWORD", dsmPassword != null ? dsmPassword : "");
|
||||||
|
pb.environment().put("DSM_PATH", dsmPath != null ? dsmPath : "");
|
||||||
|
}
|
||||||
|
pb.redirectErrorStream(false);
|
||||||
|
Process process = pb.start();
|
||||||
|
|
||||||
|
String stdout;
|
||||||
|
String stderr;
|
||||||
|
try (
|
||||||
|
InputStream is = process.getInputStream();
|
||||||
|
InputStream es = process.getErrorStream()
|
||||||
|
) {
|
||||||
|
stdout = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
stderr = new String(es.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean finished = process.waitFor(timeoutMinutes, TimeUnit.MINUTES);
|
||||||
|
if (!finished) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new RuntimeException("Python 脚本执行超时 (" + timeoutMinutes + " 分钟)");
|
||||||
|
}
|
||||||
|
|
||||||
|
int exitCode = process.exitValue();
|
||||||
|
if (stdout.isBlank()) {
|
||||||
|
throw new RuntimeException("Python 脚本无输出, exitCode=" + exitCode + ", stderr=" + stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
JsonNode root = mapper.readTree(stdout);
|
||||||
|
|
||||||
|
Result result = new Result();
|
||||||
|
result.setTicketId(root.path("ticketId").asText());
|
||||||
|
result.setOutputDir(root.path("outputDir").asText());
|
||||||
|
result.setSuccess(root.path("success").asBoolean());
|
||||||
|
result.setError(root.path("error").asText(null));
|
||||||
|
result.setSrObjectId(root.path("srObjectId").asText(null));
|
||||||
|
|
||||||
|
// SR 附件
|
||||||
|
for (JsonNode att : root.path("srAttachments")) {
|
||||||
|
result.getSrAttachments().add(parseAttachment(att));
|
||||||
|
}
|
||||||
|
|
||||||
|
// XIssueItem 附件
|
||||||
|
for (JsonNode item : root.path("issueItems")) {
|
||||||
|
IssueItem ii = new IssueItem();
|
||||||
|
ii.setObjectId(item.path("objectId").asText());
|
||||||
|
ii.setUuid(item.path("uuid").asText());
|
||||||
|
ii.setDescription(item.path("description").asText());
|
||||||
|
for (JsonNode att : item.path("attachments")) {
|
||||||
|
ii.getAttachments().add(parseAttachment(att));
|
||||||
|
}
|
||||||
|
result.getIssueItems().add(ii);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已下载文件
|
||||||
|
for (JsonNode df : root.path("downloadedFiles")) {
|
||||||
|
DownloadedFile f = new DownloadedFile();
|
||||||
|
f.setSource(df.path("source").asText());
|
||||||
|
f.setC4cName(df.path("c4cName").asText());
|
||||||
|
f.setType(df.path("type").asText());
|
||||||
|
f.setMime(df.path("mime").asText(null));
|
||||||
|
f.setLinkUrl(df.path("linkUrl").asText(null));
|
||||||
|
f.setSavedPath(df.path("savedPath").asText(null));
|
||||||
|
f.setSavedName(df.path("savedName").asText(null));
|
||||||
|
f.setError(df.path("error").asText(null));
|
||||||
|
result.getDownloadedFiles().add(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DSM 上传结果
|
||||||
|
for (JsonNode du : root.path("dsmUpload")) {
|
||||||
|
DsmUploadEntry entry = new DsmUploadEntry();
|
||||||
|
entry.setFile(du.path("file").asText());
|
||||||
|
entry.setRemotePath(du.path("remotePath").asText());
|
||||||
|
entry.setSuccess(du.path("success").asBoolean(false));
|
||||||
|
entry.setError(du.path("error").asText(null));
|
||||||
|
result.getDsmUpload().add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Attachment parseAttachment(JsonNode node) {
|
||||||
|
Attachment a = new Attachment();
|
||||||
|
a.setUuid(node.path("UUID").asText());
|
||||||
|
a.setObjectId(node.path("ObjectID").asText());
|
||||||
|
a.setFileName(node.path("FileName").asText());
|
||||||
|
a.setMimeType(node.path("MimeType").asText());
|
||||||
|
a.setCategoryCode(node.path("CategoryCode").asText());
|
||||||
|
a.setLinkWebURI(node.path("LinkWebURI").asText(null));
|
||||||
|
a.setDocumentLink(node.path("DocumentLink").asText(null));
|
||||||
|
String size = node.path("SizeInkB").asText(null);
|
||||||
|
if (size != null && !size.isEmpty()) {
|
||||||
|
try { a.setSizeInKB(Double.parseDouble(size)); } catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 数据模型 ====================
|
||||||
|
|
||||||
|
public static class Result {
|
||||||
|
private String ticketId;
|
||||||
|
private String outputDir;
|
||||||
|
private boolean success;
|
||||||
|
private String error;
|
||||||
|
private String srObjectId;
|
||||||
|
private List<Attachment> srAttachments = new ArrayList<>();
|
||||||
|
private List<IssueItem> issueItems = new ArrayList<>();
|
||||||
|
private List<DownloadedFile> downloadedFiles = new ArrayList<>();
|
||||||
|
private List<DsmUploadEntry> dsmUpload = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getTicketId() { return ticketId; }
|
||||||
|
public void setTicketId(String ticketId) { this.ticketId = ticketId; }
|
||||||
|
public String getOutputDir() { return outputDir; }
|
||||||
|
public void setOutputDir(String outputDir) { this.outputDir = outputDir; }
|
||||||
|
public boolean isSuccess() { return success; }
|
||||||
|
public void setSuccess(boolean success) { this.success = success; }
|
||||||
|
public String getError() { return error; }
|
||||||
|
public void setError(String error) { this.error = error; }
|
||||||
|
public String getSrObjectId() { return srObjectId; }
|
||||||
|
public void setSrObjectId(String srObjectId) { this.srObjectId = srObjectId; }
|
||||||
|
public List<Attachment> getSrAttachments() { return srAttachments; }
|
||||||
|
public void setSrAttachments(List<Attachment> srAttachments) { this.srAttachments = srAttachments; }
|
||||||
|
public List<IssueItem> getIssueItems() { return issueItems; }
|
||||||
|
public void setIssueItems(List<IssueItem> issueItems) { this.issueItems = issueItems; }
|
||||||
|
public List<DownloadedFile> getDownloadedFiles() { return downloadedFiles; }
|
||||||
|
public void setDownloadedFiles(List<DownloadedFile> downloadedFiles) { this.downloadedFiles = downloadedFiles; }
|
||||||
|
public List<DsmUploadEntry> getDsmUpload() { return dsmUpload; }
|
||||||
|
public void setDsmUpload(List<DsmUploadEntry> dsmUpload) { this.dsmUpload = dsmUpload; }
|
||||||
|
|
||||||
|
/** 获取全部附件(SR + 所有 IssueItem)的合并列表 */
|
||||||
|
public List<Attachment> getAllAttachments() {
|
||||||
|
List<Attachment> all = new ArrayList<>(srAttachments);
|
||||||
|
for (IssueItem ii : issueItems) {
|
||||||
|
all.addAll(ii.getAttachments());
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Attachment {
|
||||||
|
private String uuid;
|
||||||
|
private String objectId;
|
||||||
|
private String fileName;
|
||||||
|
private String mimeType;
|
||||||
|
private String categoryCode; // "2"=文件, "3"=链接
|
||||||
|
private String linkWebURI;
|
||||||
|
private String documentLink;
|
||||||
|
private double sizeInKB;
|
||||||
|
|
||||||
|
public boolean isFile() { return "2".equals(categoryCode); }
|
||||||
|
public boolean isLink() { return "3".equals(categoryCode); }
|
||||||
|
|
||||||
|
public String getUuid() { return uuid; }
|
||||||
|
public void setUuid(String uuid) { this.uuid = uuid; }
|
||||||
|
public String getObjectId() { return objectId; }
|
||||||
|
public void setObjectId(String objectId) { this.objectId = objectId; }
|
||||||
|
public String getFileName() { return fileName; }
|
||||||
|
public void setFileName(String fileName) { this.fileName = fileName; }
|
||||||
|
public String getMimeType() { return mimeType; }
|
||||||
|
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||||
|
public String getCategoryCode() { return categoryCode; }
|
||||||
|
public void setCategoryCode(String categoryCode) { this.categoryCode = categoryCode; }
|
||||||
|
public String getLinkWebURI() { return linkWebURI; }
|
||||||
|
public void setLinkWebURI(String linkWebURI) { this.linkWebURI = linkWebURI; }
|
||||||
|
public String getDocumentLink() { return documentLink; }
|
||||||
|
public void setDocumentLink(String documentLink) { this.documentLink = documentLink; }
|
||||||
|
public double getSizeInKB() { return sizeInKB; }
|
||||||
|
public void setSizeInKB(double sizeInKB) { this.sizeInKB = sizeInKB; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class IssueItem {
|
||||||
|
private String objectId;
|
||||||
|
private String uuid;
|
||||||
|
private String description;
|
||||||
|
private List<Attachment> attachments = new ArrayList<>();
|
||||||
|
|
||||||
|
public String getObjectId() { return objectId; }
|
||||||
|
public void setObjectId(String objectId) { this.objectId = objectId; }
|
||||||
|
public String getUuid() { return uuid; }
|
||||||
|
public void setUuid(String uuid) { this.uuid = uuid; }
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
public List<Attachment> getAttachments() { return attachments; }
|
||||||
|
public void setAttachments(List<Attachment> attachments) { this.attachments = attachments; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DownloadedFile {
|
||||||
|
private String source; // "SR" / "IssueItem-xxx"
|
||||||
|
private String c4cName; // C4C 里的附件名
|
||||||
|
private String type; // "file" / "link"
|
||||||
|
private String mime;
|
||||||
|
private String linkUrl;
|
||||||
|
private String savedPath; // 下载后的绝对路径
|
||||||
|
private String savedName; // 下载后的文件名
|
||||||
|
private String error; // 下载失败的错误信息
|
||||||
|
|
||||||
|
public boolean isOk() { return savedPath != null && error == null; }
|
||||||
|
|
||||||
|
public String getSource() { return source; }
|
||||||
|
public void setSource(String source) { this.source = source; }
|
||||||
|
public String getC4cName() { return c4cName; }
|
||||||
|
public void setC4cName(String c4cName) { this.c4cName = c4cName; }
|
||||||
|
public String getType() { return type; }
|
||||||
|
public void setType(String type) { this.type = type; }
|
||||||
|
public String getMime() { return mime; }
|
||||||
|
public void setMime(String mime) { this.mime = mime; }
|
||||||
|
public String getLinkUrl() { return linkUrl; }
|
||||||
|
public void setLinkUrl(String linkUrl) { this.linkUrl = linkUrl; }
|
||||||
|
public String getSavedPath() { return savedPath; }
|
||||||
|
public void setSavedPath(String savedPath) { this.savedPath = savedPath; }
|
||||||
|
public String getSavedName() { return savedName; }
|
||||||
|
public void setSavedName(String savedName) { this.savedName = savedName; }
|
||||||
|
public String getError() { return error; }
|
||||||
|
public void setError(String error) { this.error = error; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DsmUploadEntry {
|
||||||
|
private String file; // 文件名
|
||||||
|
private String remotePath; // DSM 远程路径
|
||||||
|
private boolean success;
|
||||||
|
private String error;
|
||||||
|
|
||||||
|
public boolean isSuccess() { return success; }
|
||||||
|
|
||||||
|
public String getFile() { return file; }
|
||||||
|
public void setFile(String file) { this.file = file; }
|
||||||
|
public String getRemotePath() { return remotePath; }
|
||||||
|
public void setRemotePath(String remotePath) { this.remotePath = remotePath; }
|
||||||
|
public void setSuccess(boolean success) { this.success = success; }
|
||||||
|
public String getError() { return error; }
|
||||||
|
public void setError(String error) { this.error = error; }
|
||||||
|
}
|
||||||
|
}
|
||||||
187
CLAUDE.md
Normal file
187
CLAUDE.md
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This is a SAP C4C (Cloud for Customer) attachment downloader toolkit that retrieves attachments from ServiceRequest tickets and optionally uploads them to Synology DSM NAS. The project consists of:
|
||||||
|
|
||||||
|
- **Python script** (`sap-c4c-AttachmentFolder.py`): Core downloader using OData APIs and web scraping
|
||||||
|
- **Java wrapper** (`C4CAttachmentDownloader.java`): Java interface that calls the Python script via ProcessBuilder
|
||||||
|
- **DSM upload script** (`dsm-upload.py`): Standalone Synology NAS upload utility
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Python Script (`sap-c4c-AttachmentFolder.py`)
|
||||||
|
|
||||||
|
**Core functionality:**
|
||||||
|
1. Authenticates to SAP C4C using Basic Auth
|
||||||
|
2. Fetches ServiceRequest attachments via OData endpoints:
|
||||||
|
- `/sap/c4c/odata/v1/c4codata` - Standard C4C OData API
|
||||||
|
- `/sap/c4c/odata/cust/v1/custticketapi` - Custom ticket API
|
||||||
|
3. Downloads two types of attachments:
|
||||||
|
- **File attachments** (CategoryCode=2): Downloaded via OData `$value` endpoint
|
||||||
|
- **Link attachments** (CategoryCode=3): External Salesforce links scraped using Scrapling + Playwright
|
||||||
|
4. Handles XIssueItem-level attachments via `BO_XSRIssueItemAttachmentFolder`
|
||||||
|
5. Optionally uploads downloaded files to Synology DSM via FileStation API
|
||||||
|
|
||||||
|
**Key dependencies:**
|
||||||
|
- `requests` - HTTP client for OData/REST APIs
|
||||||
|
- `scrapling[all]` - Web scraping framework with stealth capabilities
|
||||||
|
- `playwright` - Browser automation for downloading Salesforce attachments
|
||||||
|
|
||||||
|
**Output modes:**
|
||||||
|
- Human-readable console output (default)
|
||||||
|
- JSON mode (`--json`) for programmatic consumption
|
||||||
|
|
||||||
|
### Java Wrapper (`C4CAttachmentDownloader.java`)
|
||||||
|
|
||||||
|
Provides a type-safe Java API that:
|
||||||
|
- Invokes the Python script via `ProcessBuilder`
|
||||||
|
- Passes credentials via environment variables (more secure than CLI args)
|
||||||
|
- Parses JSON output into strongly-typed Java objects
|
||||||
|
- Supports timeout configuration (default: 30 minutes)
|
||||||
|
|
||||||
|
**Key classes:**
|
||||||
|
- `Result` - Top-level response containing all attachment metadata
|
||||||
|
- `Attachment` - Individual attachment metadata (UUID, filename, MIME type, category)
|
||||||
|
- `IssueItem` - XIssueItem with nested attachments
|
||||||
|
- `DownloadedFile` - Download result with local path and error info
|
||||||
|
- `DsmUploadEntry` - DSM upload result per file
|
||||||
|
|
||||||
|
### DSM Upload (`dsm-upload.py`)
|
||||||
|
|
||||||
|
Standalone script demonstrating Synology FileStation API usage:
|
||||||
|
1. Login via `SYNO.API.Auth` to obtain SID
|
||||||
|
2. Upload files via `SYNO.FileStation.Upload` with SID cookie
|
||||||
|
|
||||||
|
## Common Commands
|
||||||
|
|
||||||
|
### Python Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
pip install requests scrapling[all] playwright
|
||||||
|
python -m playwright install chromium
|
||||||
|
|
||||||
|
# Download attachments (credentials via CLI)
|
||||||
|
python sap-c4c-AttachmentFolder.py \
|
||||||
|
--tenant https://xxx.c4c.saphybriscloud.cn \
|
||||||
|
--user admin \
|
||||||
|
--password xxx \
|
||||||
|
--ticket 24588
|
||||||
|
|
||||||
|
# Download with DSM upload
|
||||||
|
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 mode (for Java/programmatic use)
|
||||||
|
python sap-c4c-AttachmentFolder.py --ticket 24588 --json
|
||||||
|
|
||||||
|
# List attachments only (no download)
|
||||||
|
python sap-c4c-AttachmentFolder.py --ticket 24588 --list-only
|
||||||
|
|
||||||
|
# Using environment variables for credentials
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Java Wrapper
|
||||||
|
|
||||||
|
```java
|
||||||
|
// Compile (requires Jackson for JSON parsing)
|
||||||
|
javac -cp jackson-databind.jar:jackson-core.jar:jackson-annotations.jar C4CAttachmentDownloader.java
|
||||||
|
|
||||||
|
// Basic usage
|
||||||
|
C4CAttachmentDownloader downloader = new C4CAttachmentDownloader(
|
||||||
|
"/path/to/sap-c4c-AttachmentFolder.py",
|
||||||
|
"https://xxx.c4c.saphybriscloud.cn",
|
||||||
|
"admin",
|
||||||
|
"password"
|
||||||
|
);
|
||||||
|
|
||||||
|
// List attachments only
|
||||||
|
C4CAttachmentDownloader.Result result = downloader.listAttachments("24588");
|
||||||
|
|
||||||
|
// Download to default directory
|
||||||
|
C4CAttachmentDownloader.Result result = downloader.download("24588");
|
||||||
|
|
||||||
|
// Download to specific directory
|
||||||
|
C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
|
||||||
|
|
||||||
|
// Download with DSM upload
|
||||||
|
downloader.setDsmConfig("http://10.0.10.235:5000", "PLM", "123456", "/Newgonow/AU-SPFJ");
|
||||||
|
C4CAttachmentDownloader.Result result = downloader.download("24588", "/tmp/ticket_24588");
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
|
||||||
|
### Attachment Categories
|
||||||
|
|
||||||
|
SAP C4C uses `CategoryCode` to distinguish attachment types:
|
||||||
|
- **"2"** = File attachment (binary content stored in C4C, downloaded via OData `$value`)
|
||||||
|
- **"3"** = Link attachment (external URL, typically Salesforce links requiring web scraping)
|
||||||
|
|
||||||
|
### OData Navigation Paths
|
||||||
|
|
||||||
|
**ServiceRequest attachments:**
|
||||||
|
```
|
||||||
|
/ServiceRequestCollection('{ObjectID}')/ServiceRequestAttachmentFolder
|
||||||
|
```
|
||||||
|
|
||||||
|
**XIssueItem attachments (two-step navigation):**
|
||||||
|
```
|
||||||
|
1. /BO_XSRIssueItemAttachmentCollection?$filter=XIssueItemUUID eq guid'{uuid}'
|
||||||
|
2. /BO_XSRIssueItemAttachmentCollection('{ObjectID}')/BO_XSRIssueItemAttachmentFolder
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scrapling Download Strategy
|
||||||
|
|
||||||
|
For CategoryCode=3 (link attachments), the script:
|
||||||
|
1. Opens the Salesforce link in a headless Chromium browser
|
||||||
|
2. Waits for `button.downloadbutton[title='Download']` selector
|
||||||
|
3. Clicks the button and captures the download
|
||||||
|
4. Saves with original or suggested filename
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
|
||||||
|
- Java wrapper passes credentials via **environment variables** (not CLI args) to avoid exposure in process lists
|
||||||
|
- Python script supports both CLI args and environment variables
|
||||||
|
- DSM API uses session-based authentication (SID cookie)
|
||||||
|
- SSL verification disabled (`verify=False`) - consider enabling in production
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── C4CAttachmentDownloader.java # Java wrapper with typed API
|
||||||
|
├── sap-c4c-AttachmentFolder.py # Core Python downloader
|
||||||
|
├── dsm-upload.py # Standalone DSM upload example
|
||||||
|
└── downloads/ # Default output directory
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Playwright not installed:**
|
||||||
|
```bash
|
||||||
|
python -m playwright install chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
**Timeout errors:** Increase timeout in Java wrapper constructor (default 30 minutes) or adjust Scrapling timeout parameters.
|
||||||
|
|
||||||
|
**DSM upload fails:** Verify DSM URL, credentials, and that target path exists or `create_parents=true` is set.
|
||||||
|
|
||||||
|
**Link download fails:** Check that Salesforce page structure matches expected selector (`button.downloadbutton[title='Download']`). Update `download_link_via_scrapling()` if page structure changes.
|
||||||
43
dsm-upload.py
Normal file
43
dsm-upload.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
NAS = "http://10.0.10.235:5000"
|
||||||
|
USER = "PLM"
|
||||||
|
PWD = "123456"
|
||||||
|
|
||||||
|
# 1) 登录
|
||||||
|
login_url = f"{NAS}/webapi/auth.cgi"
|
||||||
|
login_params = {
|
||||||
|
"api": "SYNO.API.Auth",
|
||||||
|
"version": "3",
|
||||||
|
"method": "login",
|
||||||
|
"account": USER,
|
||||||
|
"passwd": PWD,
|
||||||
|
"session": "FileStation",
|
||||||
|
"format": "sid",
|
||||||
|
}
|
||||||
|
r = requests.get(login_url, params=login_params, verify=False)
|
||||||
|
r.raise_for_status()
|
||||||
|
data = r.json()
|
||||||
|
sid = data["data"]["sid"]
|
||||||
|
|
||||||
|
# 2) 上传(SID 通过 cookie 传递)
|
||||||
|
upload_url = f"{NAS}/webapi/entry.cgi"
|
||||||
|
form = {
|
||||||
|
"api": "SYNO.FileStation.Upload",
|
||||||
|
"version": "2",
|
||||||
|
"method": "upload",
|
||||||
|
"path": "/Newgonow/AU-SPFJ",
|
||||||
|
"create_parents": "true",
|
||||||
|
"overwrite": "true",
|
||||||
|
}
|
||||||
|
with open("downloads/IMG_4307.mp4", "rb") as f:
|
||||||
|
files = {
|
||||||
|
"file": ("IMG_4307.mp4", f, "video/mp4")
|
||||||
|
}
|
||||||
|
r = requests.post(upload_url, data=form, files=files,
|
||||||
|
cookies={"id": sid}, verify=False)
|
||||||
|
|
||||||
|
print(r.status_code)
|
||||||
|
print(r.text)
|
||||||
620
sap-c4c-AttachmentFolder.py
Normal file
620
sap-c4c-AttachmentFolder.py
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
||||||
|
url = f"{ODATA_C4C}/ServiceRequestCollection"
|
||||||
|
params = {"$format": "json", "$filter": f"ID eq '{ticket_id}'", "$select": "ObjectID,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")
|
||||||
|
return results[0]["ObjectID"]
|
||||||
|
|
||||||
|
|
||||||
|
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 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"<DocumentUUID>{u}</DocumentUUID>" for u in document_uuids
|
||||||
|
)
|
||||||
|
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soapenv:Envelope
|
||||||
|
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
|
||||||
|
xmlns:glob="http://sap.com/xi/SAPGlobal20/Global">
|
||||||
|
<soapenv:Header/>
|
||||||
|
<soapenv:Body>
|
||||||
|
<glob:AttachmentFolderDocumentsFileContentByIDQuery_sync>
|
||||||
|
<AttachmentFolderDocumentFileContentByIDQuery>
|
||||||
|
{uuid_xml}
|
||||||
|
</AttachmentFolderDocumentFileContentByIDQuery>
|
||||||
|
<ProcessingConditions>
|
||||||
|
<QueryFileSizeMaximumNumberValue>{size_limit_kb}</QueryFileSizeMaximumNumberValue>
|
||||||
|
</ProcessingConditions>
|
||||||
|
</glob:AttachmentFolderDocumentsFileContentByIDQuery_sync>
|
||||||
|
</soapenv:Body>
|
||||||
|
</soapenv:Envelope>"""
|
||||||
|
|
||||||
|
|
||||||
|
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, json_mode=False):
|
||||||
|
"""将所有已下载文件上传到群晖 DSM"""
|
||||||
|
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 []
|
||||||
|
|
||||||
|
if not json_mode:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"上传到群晖 DSM: {DSM_URL}")
|
||||||
|
print(f"目标路径: {DSM_PATH}")
|
||||||
|
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)
|
||||||
|
entry = {"file": filename, "remotePath": f"{DSM_PATH}/{filename}"}
|
||||||
|
try:
|
||||||
|
dsm_upload_file(sid, local_path, DSM_PATH)
|
||||||
|
entry["success"] = True
|
||||||
|
if not json_mode:
|
||||||
|
print(f" 上传成功: {filename} -> {DSM_PATH}/{filename}")
|
||||||
|
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
|
||||||
|
sr_object_id = find_service_request_object_id(session, ticket_id)
|
||||||
|
result["srObjectId"] = sr_object_id
|
||||||
|
if not json_mode:
|
||||||
|
print(f"ServiceRequest ID={ticket_id}, ObjectID={sr_object_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]
|
||||||
|
|
||||||
|
issue_entry = {
|
||||||
|
"objectId": item_oid,
|
||||||
|
"uuid": issue_uuid,
|
||||||
|
"description": issue_desc,
|
||||||
|
"attachments": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if not json_mode:
|
||||||
|
print(f"\n XIssueItem: {item_oid}")
|
||||||
|
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:
|
||||||
|
_do_download(
|
||||||
|
session, atts, f"IssueItem-{item_oid[:12]}",
|
||||||
|
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"]:
|
||||||
|
all_attachments.append((f"IssueItem-{ie['objectId'][:12]}", 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 _do_download(session, attachments, label, 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"]
|
||||||
|
|
||||||
|
# 链接附件 -> Scrapling
|
||||||
|
for a in link_atts:
|
||||||
|
link_url = a.get("LinkWebURI")
|
||||||
|
if not link_url:
|
||||||
|
continue
|
||||||
|
if not json_mode:
|
||||||
|
print(f" {a['FileName']}: {link_url}")
|
||||||
|
r = download_link_via_scrapling(link_url, a["FileName"])
|
||||||
|
entry = {"source": label, "c4cName": a["FileName"], "type": "link", "linkUrl": link_url}
|
||||||
|
if r["saved"]:
|
||||||
|
entry["savedPath"] = os.path.abspath(r["saved"])
|
||||||
|
entry["savedName"] = os.path.basename(r["saved"])
|
||||||
|
if not json_mode:
|
||||||
|
print(f" saved: {r['saved']}")
|
||||||
|
else:
|
||||||
|
entry["error"] = r["error"]
|
||||||
|
if not json_mode:
|
||||||
|
print(f" 下载失败: {r['error']}")
|
||||||
|
result["downloadedFiles"].append(entry)
|
||||||
|
|
||||||
|
# 文件附件 -> OData
|
||||||
|
for att in file_atts:
|
||||||
|
entry = {"source": label, "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:
|
||||||
|
print(f" saved: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
entry["error"] = str(e)
|
||||||
|
if not json_mode:
|
||||||
|
print(f" OData 下载失败 ({att['FileName']}): {e}")
|
||||||
|
result["downloadedFiles"].append(entry)
|
||||||
|
|
||||||
|
|
||||||
|
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="仅列出附件清单,不下载")
|
||||||
|
|
||||||
|
# 群晖 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
|
||||||
|
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"
|
||||||
|
|
||||||
|
# 初始化 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
|
||||||
|
|
||||||
|
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"]:
|
||||||
|
upload_results = dsm_upload_downloaded_files(result["downloadedFiles"], args.json_mode)
|
||||||
|
result["dsmUpload"] = upload_results
|
||||||
|
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user