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