diff --git a/README.md b/README.md index 4d348be..bf5f12c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ - 文件移动: `cloud189 cp {云盘路径...} {目标路径}` - WebDAV(待优化): `cloud189 webdav :{端口}` 启动 webdav服务, 上传不支持10M以上的文件秒传 - 文件共享: `cloud189 share :{端口} {云盘路径}` 指定http端口对外提供文件直链分享 +- 分享保存: `cloud189 save {分享链接} {云盘路径}` 将天翼云盘分享链接中的文件保存到个人云盘,例 + - 无访问码 `cloud189 save https://cloud.189.cn/t/reiQruJJnyEv /我的资源` + - 有访问码 `cloud189 save "https://cloud.189.cn/t/reiQruJJnyEv(访问码:v5oy)" /我的资源` - cli终端模式:`cloud189` 无参启动终端模式,`Ctrl + C`退出,该模式下无需输入`cloud189`即可支持以上所有命令,支持`Tab键`参数补全,并新增目录命令 - `cd {云盘路径}` 进入指定目录 - `pwd` 查看当前目录 diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 31e5e2f..8e7df40 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -46,6 +46,7 @@ func init() { RootCmd.AddCommand(duCmd) RootCmd.AddCommand(webdavCmd) RootCmd.AddCommand(shareCmd) + RootCmd.AddCommand(saveCmd) } var singleton pkg.Drive @@ -60,4 +61,4 @@ func App() pkg.Drive { singleton = drive.New(api) }) return singleton -} \ No newline at end of file +} diff --git a/internal/cmd/save.go b/internal/cmd/save.go new file mode 100644 index 0000000..8287344 --- /dev/null +++ b/internal/cmd/save.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var saveCmd = &cobra.Command{ + Use: "save", + Short: "save share files to cloud", + Long: `Save files from a Tianyi Cloud share link to your cloud drive. + +Examples: + cloud189 save https://cloud.189.cn/t/reiQruJJnyEv /target/path + cloud189 save "https://cloud.189.cn/t/reiQruJJnyEv(访问码:v5oy)" /target/path`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + shareURL := args[0] + target := args[1] + if err := App().SaveShare(shareURL, target); err != nil { + fmt.Println(err) + } + }, +} diff --git a/pkg/app/req_share.go b/pkg/app/req_share.go new file mode 100644 index 0000000..8fbbd44 --- /dev/null +++ b/pkg/app/req_share.go @@ -0,0 +1,316 @@ +package app + +import ( + "context" + "fmt" + "io/fs" + "net/url" + "os" + "sort" + "strings" + "time" + + "github.com/gowsp/cloud189/pkg" + "github.com/gowsp/cloud189/pkg/invoker" +) + +type shareInfo struct { + *invoker.NumCodeRsp + AccessCode string `json:"accessCode"` + Creator Creator `json:"creator"` + ExpireTime int `json:"expireTime"` + ExpireType int `json:"expireType"` + FileCreateDate string `json:"fileCreateDate"` + FileId string `json:"fileId"` + FileLastOpTime Time `json:"fileLastOpTime"` + FileName string `json:"fileName"` + FileSize int64 `json:"fileSize"` + FileType string `json:"fileType"` + IsFolder bool `json:"isFolder"` + NeedAccessCode int `json:"needAccessCode"` + ReviewStatus int `json:"reviewStatus"` + ShareDate int64 `json:"shareDate"` + ShareId int64 `json:"shareId"` + ShareMode int `json:"shareMode"` + ShareType int `json:"shareType"` +} + +type Creator struct { + IconURL string `json:"iconURL"` + NickName string `json:"nickName"` + Oper bool `json:"oper"` + OwnerAccount string `json:"ownerAccount"` + SuperVip int `json:"superVip"` + Vip int `json:"vip"` +} + +func (f *shareInfo) Info() (fs.FileInfo, error) { return f, nil } + +func (f *shareInfo) Id() string { return f.FileId } +func (f *shareInfo) PId() string { return fmt.Sprintf("%d", f.ShareId) } +func (f *shareInfo) Name() string { return f.FileName } +func (f *shareInfo) Size() int64 { return f.FileSize } +func (f *shareInfo) Mode() fs.FileMode { + if f.IsFolder { + return fs.ModeDir + } else { + return fs.ModeType + } +} +func (f *shareInfo) Type() fs.FileMode { return f.Mode() } +func (f *shareInfo) ModTime() time.Time { return time.Time(f.FileLastOpTime) } +func (f *shareInfo) IsDir() bool { + if f.IsFolder { + return true + } else { + return false + } +} +func (f *shareInfo) Sys() any { return nil } + +func (c *api) GetShareInfo(shareCode string) (result pkg.File, accessCode string, shareMode int, err error) { + ret, err := c.getShareInfo(shareCode) + if err != nil { + return nil, "", 0, err + } + return ret, ret.AccessCode, ret.ShareMode, nil +} + +func (c *api) getShareInfo(shareCode string) (result *shareInfo, err error) { + response := new(shareInfo) + err = c.invoker.Get("/open/share/getShareInfoByCodeV2.action", url.Values{ + "shareCode": {shareCode}, + }, response) + if rsp, ok := err.(invoker.BadRsp); ok { + if rsp.IsError(invoker.ErrFileNotFound) { + return nil, os.ErrNotExist + } + } + if !response.IsSuccess() { + return nil, fmt.Errorf("getShareInfo error: %s", response.Error()) + } + + return response, err +} + +type ShareDirInfo struct { + *invoker.NumCodeRsp + ExpireTime int `json:"expireTime"` + ExpireType int `json:"expireType"` + List *ShareFileList `json:"fileListAO"` + LastRev int `json:"lastRev"` +} + +type ShareFileList struct { + Count int `json:"count"` + FileList []*shareFile `json:"fileList"` + FileListSize int `json:"fileListSize"` + FolderList []*shareFolder `json:"folderList"` +} +type shareFile struct { + fileInfo + Icon struct { + LargeUrl string `json:"largeUrl"` + SmallUrl string `json:"smallUrl"` + } `json:"icon"` +} + +type shareFolder struct { + folder + *ShareFileList //Recursive +} + +func (list *ShareFileList) Files() []pkg.File { + if list == nil { + return []pkg.File{} + } + files := make([]pkg.File, 0, len(list.FileList)+len(list.FolderList)) + for _, folder := range list.FolderList { + files = append(files, folder) + } + for _, file := range list.FileList { + files = append(files, file) + } + return files +} +func (list *ShareFileList) Tree() string { + if list == nil { + return "" + } + + var sb strings.Builder + list.writeTree("", &sb) + return sb.String() +} + +func (list *ShareFileList) writeTree(prefix string, sb *strings.Builder) { + folders := list.FolderList + files := list.FileList + + // Sort both lists individually + sort.Slice(folders, func(i, j int) bool { + return folders[i].DirName < folders[j].DirName + }) + sort.Slice(files, func(i, j int) bool { + return files[i].FileName < files[j].FileName + }) + + folderIdx, fileIdx := 0, 0 + total := len(folders) + len(files) + + for i := 0; i < total; i++ { + var isFolder bool + var name string + var nextFolder *shareFolder + + // Decide whether to pick from folders or files + if folderIdx < len(folders) && fileIdx < len(files) { + if folders[folderIdx].DirName < files[fileIdx].FileName { + isFolder = true + } else { + isFolder = false + } + } else if folderIdx < len(folders) { + isFolder = true + } else { + isFolder = false + } + + if isFolder { + nextFolder = folders[folderIdx] + name = nextFolder.DirName + folderIdx++ + } else { + name = files[fileIdx].FileName + fileIdx++ + } + + isLast := i == total-1 + connector := "├── " + childPrefix := "│ " + if isLast { + connector = "└── " + childPrefix = " " + } + + sb.WriteString(prefix) + sb.WriteString(connector) + sb.WriteString(name) + sb.WriteString("\n") + + if isFolder && nextFolder.FileList != nil { + nextFolder.ShareFileList.writeTree(prefix+childPrefix, sb) + } + } +} + +type ShareTaskOption struct { + Context context.Context + Sync bool + DealWay DealWay +} + +var ( + defaultShareTaskOption = &ShareTaskOption{ + Context: context.Background(), + Sync: true, + DealWay: DealWayIgnore, + } +) + +func (c *api) ListShareDir(fileId, shareId, accessCode string, shareMode int, isFolder, recursive bool) ([]pkg.File, error) { + result, err := c.listShareDirWithRecursive(fileId, shareId, accessCode, shareMode, isFolder, recursive) + if err != nil { + return nil, err + } + return result.Files(), nil +} + +func (c *api) listShareDir(fileId, shareId, accessCode string, shareMode int, isFolder bool) (*ShareDirInfo, error) { + response := new(ShareDirInfo) + err := c.invoker.Get("/open/share/listShareDir.action", url.Values{ + "pageNum": {"1"}, + "pageSize": {"200"}, + "fileId": {fileId}, + "shareDirFileId": {fileId}, + "isFolder": {fmt.Sprintf("%v", isFolder)}, + "shareId": {shareId}, + "shareMode": {fmt.Sprintf("%d", shareMode)}, + "iconOption": {"5"}, + "orderBy": {"lastOpTime"}, + "descending": {"true"}, + "accessCode": {accessCode}, + }, response) + if rsp, ok := err.(invoker.BadRsp); ok { + if rsp.IsError(invoker.ErrFileNotFound) { + return nil, os.ErrNotExist + } + } + if !response.IsSuccess() { + return nil, fmt.Errorf("listShareDir error: %s", response.Error()) + } + + return response, err +} + +func (c *api) listShareDirWithRecursive(fileId, shareId, accessCode string, shareMode int, isFolder, recursive bool) (*ShareFileList, error) { + sdi, err := c.listShareDir(fileId, shareId, accessCode, shareMode, isFolder) + if err != nil { + return nil, err + } + + if !recursive { + return sdi.List, err + } + + if len(sdi.List.FolderList) > 0 { + for _, folder := range sdi.List.FolderList { + folder.ShareFileList, err = c.listShareDirWithRecursive(folder.ID.String(), shareId, accessCode, shareMode, isFolder, recursive) + if err != nil { + return nil, fmt.Errorf("listShareDirRecursively \"%s\" error: %w", folder.DirName, err) + } + } + } + + return sdi.List, nil +} + +func (c *api) ShareSave(targetFolderId, shareId string, files ...pkg.File) (taskId string, err error) { + return c.createBatchTask(shareSave, targetFolderId, shareId, files...) +} +func (c *api) ShareSaveSync(ctx context.Context, dealWay int, targetFolderId, shareId string, files ...pkg.File) (sucCount int, err error) { + if DealWay(dealWay) == DealWayCancel { + _, sucCount, err = c.createBatchTaskSync(context.Background(), shareSave, targetFolderId, shareId, files...) + } else { + _, sucCount, err = c.shareSaveSyncWithDealWay(ctx, DealWay(dealWay), targetFolderId, shareId, files...) + } + return +} + +// dealWay(1:忽略 2:保留两者 3:替换) +func (c *api) shareSaveSyncWithDealWay(ctx context.Context, dealWay DealWay, targetFolderId, shareId string, files ...pkg.File) (taskId string, sucCount int, err error) { + taskId, sucCount, err = c.createBatchTaskSync(ctx, shareSave, targetFolderId, shareId, files...) + if err == nil { + return + } + + // 冲突时获取冲突信息并修改任务 + if err == taskConflictError { + resp, err := c.getConflictTaskInfo(shareSave, taskId) + if err != nil { + return taskId, sucCount, err + } + + err = c.manageBatchTask(shareSave, taskId, targetFolderId, resp.manageTaskInfos(dealWay)) + if err != nil { + return taskId, sucCount, err + } + + taskResp, err := c.waitBatchTask(ctx, shareSave, taskId) + if err != nil { + return taskId, sucCount, err + } + return taskId, taskResp.SuccessedCount, nil + } + return +} diff --git a/pkg/app/req_share_test.go b/pkg/app/req_share_test.go new file mode 100644 index 0000000..911cd18 --- /dev/null +++ b/pkg/app/req_share_test.go @@ -0,0 +1,106 @@ +package app + +import ( + "context" + "fmt" + "testing" + + "github.com/gowsp/cloud189/pkg" + "github.com/gowsp/cloud189/pkg/invoker" +) + +func TestShareSaveSync(t *testing.T) { + var api pkg.DriveApi = New(invoker.DefaultPath()) + //api := New(invoker.DefaultPath()) + f, accessCode, shareMode, err := api.GetShareInfo("YZfQ3ujUN77b(访问码:v5oy)") // QF7R7vBnQVR3(访问码:8qo9) Nz2iaqqMnUBn + if err != nil { + t.Logf("share info: %v, access code: %s", f, accessCode) + t.Error(err) + } + + shareId := f.PId() + ret, err := api.ListShareDir(f.Id(), shareId, accessCode, shareMode, f.IsDir(), true) + if err != nil { + t.Log(ret) + t.Error(err) + } + //t.Error(ret[0]) + + _, err = api.ShareSaveSync( + context.Background(), int(DealWayIgnore), + "-11", shareId, + ret[0], + ) + if err != nil { + t.Log(ret) + t.Error(err) + } +} + +func TestShareDirInfo_Tree(t *testing.T) { + // Construct a dummy tree + // root + // ├── file1.txt + // ├── folder1 + // │ ├── subfile1.txt + // │ └── subfolder1 + // │ └── deepfile.txt + // └── folder2 + // └── file2.txt + + deepFile := &shareFile{ + fileInfo: fileInfo{FileName: "deepfile.txt"}, + } + subFolder1 := &shareFolder{ + folder: folder{DirName: "subfolder1"}, + ShareFileList: &ShareFileList{ + FileList: []*shareFile{deepFile}, + }, + } + + subFile1 := &shareFile{ + fileInfo: fileInfo{FileName: "subfile1.txt"}, + } + folder1 := &shareFolder{ + folder: folder{DirName: "folder1"}, + ShareFileList: &ShareFileList{ + FileList: []*shareFile{subFile1}, + FolderList: []*shareFolder{subFolder1}, + }, + } + + file2 := &shareFile{ + fileInfo: fileInfo{FileName: "file2.txt"}, + } + folder2 := &shareFolder{ + folder: folder{DirName: "folder2"}, + ShareFileList: &ShareFileList{ + FileList: []*shareFile{file2}, + }, + } + + file1 := &shareFile{ + fileInfo: fileInfo{FileName: "file1.txt"}, + } + root := &ShareFileList{ + FileList: []*shareFile{file1}, + FolderList: []*shareFolder{folder1, folder2}, + } + + expected := `├── file1.txt +├── folder1 +│ ├── subfile1.txt +│ └── subfolder1 +│ └── deepfile.txt +└── folder2 + └── file2.txt +` + + got := root.Tree() + if got != expected { + t.Errorf("Tree() mismatch:\nGot:\n%s\nExpected:\n%s", got, expected) + } else { + fmt.Println("Tree() output matches expected:") + fmt.Print(got) + } +} diff --git a/pkg/app/req_task.go b/pkg/app/req_task.go new file mode 100644 index 0000000..1f40475 --- /dev/null +++ b/pkg/app/req_task.go @@ -0,0 +1,251 @@ +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "github.com/gowsp/cloud189/pkg/invoker" + "net/url" + "time" + + "github.com/gowsp/cloud189/pkg" + "github.com/gowsp/cloud189/pkg/cache" +) + +type taskType string + +const ( + shareSave taskType = "SHARE_SAVE" +) + +type taskInfo struct { + Id string + Name string + IsDir bool +} + +func (t *taskInfo) MarshalJSON() ([]byte, error) { + dir := 0 + if t.IsDir { + dir = 1 + } + json := fmt.Sprintf(`{"fileId":"%s","fileName":"%s","isFolder":%d}`, t.Id, t.Name, dir) + return []byte(json), nil +} + +type createTaskResponse struct { + *invoker.NumCodeRsp + TaskId string `json:"taskId"` +} + +func (c *api) createBatchTaskSync(ctx context.Context, taskType taskType, targetFolderId, shareId string, files ...pkg.File) (taskId string, sucCount int, err error) { + taskId, err = c.createBatchTask(taskType, targetFolderId, shareId, files...) + if err != nil { + return + } + + resp, err := c.waitBatchTask(ctx, taskType, taskId) + if err != nil { + return + } + + return taskId, resp.SuccessedCount, nil +} + +func (c *api) createBatchTask(taskType taskType, targetFolderId, shareId string, files ...pkg.File) (taskId string, err error) { + length := len(files) + if length == 0 { + return "", nil + } + rm := make([]taskInfo, length) + for i, v := range files { + rm[i] = taskInfo{ + Id: v.Id(), + Name: v.Name(), + IsDir: v.IsDir(), + } + } + data, err := json.Marshal(rm) + if err != nil { + return "", err + } + params := make(url.Values) + params.Set("type", string(taskType)) + params.Set("taskInfos", string(data)) + params.Set("targetFolderId", targetFolderId) + if shareId != "" { + params.Set("shareId", shareId) + } + + response := new(createTaskResponse) + err = c.invoker.Post("/batch/createBatchTask.action", params, response) + if err != nil { + return "", err + } + if !response.IsSuccess() { + return "", fmt.Errorf("createBatchTask %s error: %s", taskType, response.Error()) + } + + switch taskType { + case shareSave: + cache.Invalid(files...) + cache.InvalidId(targetFolderId) + } + return response.TaskId, nil +} + +func (c *api) waitBatchTask(ctx context.Context, taskType taskType, taskId string) (response *CheckTaskResponse, err error) { + for { + response, err = c.checkBatchTask(taskType, taskId) + if err != nil { + return nil, err + } + switch response.TaskStatus { + case 4: + return response, nil + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Second): + // 继续下一次循环 + } + } +} + +type CheckTaskResponse struct { + *invoker.NumCodeRsp + ErrorCode string `json:"errorCode"` + TaskId string `json:"taskId"` + TaskStatus int `json:"taskStatus"` + Process int `json:"process"` + FailedCount int `json:"failedCount"` + SkipCount int `json:"skipCount"` + SubTaskCount int `json:"subTaskCount"` + SuccessedCount int `json:"successedCount"` +} + +var taskConflictError = errors.New("there is a conflict with the target object") + +func (c *api) checkBatchTask(taskType taskType, taskId string) (response *CheckTaskResponse, err error) { + response = new(CheckTaskResponse) + err = c.invoker.Post("/batch/checkBatchTask.action", url.Values{ + "type": []string{string(taskType)}, + "taskId": []string{taskId}, + }, response) + if err != nil { + return response, err + } + if !response.IsSuccess() { + return response, fmt.Errorf("checkBatchTask %s error: %s", taskType, response.Error()) + } + + if response.ErrorCode != "" { + return response, fmt.Errorf("%s", response.ErrorCode) // eg: InsufficientStorageSpace + } + + switch response.TaskStatus { + case 2: + return response, taskConflictError + case 3, 4: // 3(进行中), 4(已完成) + return response, nil + } + + return response, fmt.Errorf("task failed %d", response.TaskStatus) +} + +type getConflictTaskInfoResponse struct { + *invoker.NumCodeRsp + SessionKey string `json:"sessionKey"` + TargetFolderId int64 `json:"targetFolderId"` + TaskId string `json:"taskId"` + TaskInfos []*TaskInfo `json:"taskInfos"` + TaskType int `json:"taskType"` +} + +type TaskInfo struct { + FileId int64 `json:"fileId"` + FileName string `json:"fileName"` + IsConflict int `json:"isConflict"` + IsFolder int `json:"isFolder"` +} + +type ManageTaskInfo struct { + *TaskInfo + DealWay DealWay `json:"dealWay"` +} + +type DealWay int // 1:忽略 2:保留两者 3:替换 + +const ( + DealWayCancel DealWay = iota + DealWayIgnore + DealWayKeepBoth + DealWayReplace +) + +func (ctr *getConflictTaskInfoResponse) manageTaskInfos(dealWay DealWay) []*ManageTaskInfo { + ret := make([]*ManageTaskInfo, 0, len(ctr.TaskInfos)) + for _, v := range ctr.TaskInfos { + if v.IsConflict == 1 { + ret = append(ret, &ManageTaskInfo{ + TaskInfo: v, + DealWay: dealWay, + }) + } + } + + return ret +} + +func (c *api) getConflictTaskInfo(taskType taskType, taskId string) (response *getConflictTaskInfoResponse, err error) { + response = new(getConflictTaskInfoResponse) + err = c.invoker.Post("/batch/getConflictTaskInfo.action", url.Values{ + "type": []string{string(taskType)}, + "taskId": []string{taskId}, + }, response) + if err != nil { + return + } + if !response.IsSuccess() { + return response, fmt.Errorf("getConflictTaskInfo %s error: %s", taskType, response.Error()) + } + + return response, nil +} + +type manageTaskResponse struct { + *invoker.NumCodeRsp + Success bool `json:"success"` +} + +// 暂只处理冲突文件 +func (c *api) manageBatchTask(taskType taskType, taskId, targetFolderId string, manageTaskInfos []*ManageTaskInfo) (err error) { + + data, err := json.Marshal(manageTaskInfos) + if err != nil { + return err + } + + response := new(manageTaskResponse) + err = c.invoker.Post("/batch/manageBatchTask.action", url.Values{ + "type": []string{string(taskType)}, + "taskId": []string{taskId}, + "targetFolderId": []string{targetFolderId}, + "taskInfos": []string{string(data)}, + }, response) + if err != nil { + return err + } + if !response.IsSuccess() { + return fmt.Errorf("manageBatchTask %s error: %s", taskType, response.Error()) + } + + if !response.IsSuccess() { + return errors.New("manageBatchTask failed") + } + + return nil +} diff --git a/pkg/drive.go b/pkg/drive.go index 22c6e1b..d8c91df 100644 --- a/pkg/drive.go +++ b/pkg/drive.go @@ -1,6 +1,7 @@ package pkg import ( + "context" "io/fs" "net/http" ) @@ -21,6 +22,7 @@ type Drive interface { Download(local string, cloud ...string) error Share(prifix, cloud string) (func(http.ResponseWriter, *http.Request), error) GetDownloadUrl(cloud string) (string, error) + SaveShare(shareURL string, target string) error } type FileType uint16 @@ -91,4 +93,14 @@ type DriveApi interface { // delete file Delete(file ...File) error + + // share info + GetShareInfo(shareCode string) (result File, accessCode string, shareMode int, err error) + + // list share dir + ListShareDir(fileId, shareId, accessCode string, shareMode int, isFolder, recursive bool) ([]File, error) + + // share save + ShareSave(targetFolderId, shareId string, files ...File) (taskId string, err error) + ShareSaveSync(ctx context.Context, dealWay int, targetFolderId, shareId string, files ...File) (sucCount int, err error) } diff --git a/pkg/drive/drive_save.go b/pkg/drive/drive_save.go new file mode 100644 index 0000000..9d1bc8a --- /dev/null +++ b/pkg/drive/drive_save.go @@ -0,0 +1,52 @@ +package drive + +import ( + "context" + "fmt" + + "github.com/gowsp/cloud189/pkg/file" +) + +func (f *FS) SaveShare(shareURL string, target string) error { + // 1. 解析分享 URL + shareCode, _, err := ParseShareURL(shareURL) + if err != nil { + return fmt.Errorf("parse share URL: %w", err) + } + + // 2. 获取分享信息 + shareFile, accessCode, shareMode, err := f.api.GetShareInfo(shareCode) + if err != nil { + return fmt.Errorf("get share info: %w", err) + } + + shareId := shareFile.PId() + + // 3. 列出分享内容 + files, err := f.api.ListShareDir(shareFile.Id(), shareId, accessCode, shareMode, shareFile.IsDir(), true) + if err != nil { + return fmt.Errorf("list share dir: %w", err) + } + + if len(files) == 0 { + return fmt.Errorf("share is empty") + } + + // 4. 解析目标路径 + dest, err := f.stat(target) + if err != nil { + return fmt.Errorf("stat target %s: %w", target, err) + } + if !dest.IsDir() { + return file.ErrFileIsDir + } + + // 5. 同步保存(DealWay=1 忽略冲突) + sucCount, err := f.api.ShareSaveSync(context.Background(), 1, dest.Id(), shareId, files...) + if err != nil { + return fmt.Errorf("share save: %w", err) + } + + fmt.Printf("save success, %d files saved to %s\n", sucCount, target) + return nil +} diff --git a/pkg/drive/share_url.go b/pkg/drive/share_url.go new file mode 100644 index 0000000..9edef0e --- /dev/null +++ b/pkg/drive/share_url.go @@ -0,0 +1,54 @@ +package drive + +import ( + "fmt" + "net/url" + "regexp" + "strings" +) + +// ParseShareURL 从天翼云盘分享链接中解析出 shareCode 和 accessCode。 +// 支持的格式: +// - https://cloud.189.cn/t/reiQruJJnyEv +// - https://cloud.189.cn/t/reiQruJJnyEv(访问码:v5oy) +// - reiQruJJnyEv +// - reiQruJJnyEv(访问码:v5oy) +func ParseShareURL(raw string) (shareCode, accessCode string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", fmt.Errorf("empty share URL") + } + + // 提取访问码(中文括号格式) + accessCodeRe := regexp.MustCompile(`(访问码:([^)]+))`) + if matches := accessCodeRe.FindStringSubmatch(raw); len(matches) == 2 { + accessCode = matches[1] + raw = strings.TrimSpace(accessCodeRe.ReplaceAllString(raw, "")) + } + + // 如果是完整 URL,提取路径最后一段 + if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") { + u, parseErr := url.Parse(raw) + if parseErr != nil { + return "", "", fmt.Errorf("invalid share URL: %w", parseErr) + } + parts := strings.Split(strings.TrimRight(u.Path, "/"), "/") + if len(parts) == 0 { + return "", "", fmt.Errorf("cannot extract share code from URL: %s", raw) + } + shareCode = parts[len(parts)-1] + } else { + shareCode = raw + } + + if shareCode == "" { + return "", "", fmt.Errorf("cannot extract share code from: %s", raw) + } + + // 如果有访问码,拼接为 GetShareInfo 期望的格式 + if accessCode != "" { + shareCode = shareCode + "(访问码:" + accessCode + ")" + } + + return shareCode, accessCode, nil +} diff --git a/pkg/drive/share_url_test.go b/pkg/drive/share_url_test.go new file mode 100644 index 0000000..d2a8f41 --- /dev/null +++ b/pkg/drive/share_url_test.go @@ -0,0 +1,73 @@ +package drive + +import ( + "testing" +) + +func TestParseShareURL(t *testing.T) { + tests := []struct { + name string + input string + wantCode string + wantAccess string + wantErr bool + }{ + { + name: "完整URL无访问码", + input: "https://cloud.189.cn/t/reiQruJJnyEv", + wantCode: "reiQruJJnyEv", + wantAccess: "", + }, + { + name: "完整URL有访问码", + input: "https://cloud.189.cn/t/reiQruJJnyEv(访问码:v5oy)", + wantCode: "reiQruJJnyEv(访问码:v5oy)", + wantAccess: "v5oy", + }, + { + name: "纯shareCode", + input: "reiQruJJnyEv", + wantCode: "reiQruJJnyEv", + wantAccess: "", + }, + { + name: "纯shareCode有访问码", + input: "reiQruJJnyEv(访问码:v5oy)", + wantCode: "reiQruJJnyEv(访问码:v5oy)", + wantAccess: "v5oy", + }, + { + name: "空字符串", + input: "", + wantErr: true, + }, + { + name: "URL带尾部斜杠", + input: "https://cloud.189.cn/t/reiQruJJnyEv/", + wantCode: "reiQruJJnyEv", + wantAccess: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + code, access, err := ParseShareURL(tt.input) + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got nil") + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + if code != tt.wantCode { + t.Errorf("shareCode = %q, want %q", code, tt.wantCode) + } + if access != tt.wantAccess { + t.Errorf("accessCode = %q, want %q", access, tt.wantAccess) + } + }) + } +}