package doubao_share
import (
"context"
"encoding/json"
"fmt"
"github.com/alist-org/alist/v3/drivers/base"
"github.com/alist-org/alist/v3/internal/model"
"github.com/go-resty/resty/v2"
log "github.com/sirupsen/logrus"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
)
const (
DirectoryType = 1
FileType = 2
LinkType = 3
ImageType = 4
PagesType = 5
VideoType = 6
AudioType = 7
MeetingMinutesType = 8
)
var FileNodeType = map[int]string{
1: "directory",
2: "file",
3: "link",
4: "image",
5: "pages",
6: "video",
7: "audio",
8: "meeting_minutes",
}
const (
BaseURL = "https://www.doubao.com"
FileDataType = "file"
ImgDataType = "image"
VideoDataType = "video"
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"
)
func (d *DoubaoShare) request(path string, method string, callback base.ReqCallback, resp interface{}) ([]byte, error) {
reqUrl := BaseURL + path
req := base.RestyClient.R()
req.SetHeaders(map[string]string{
"Cookie": d.Cookie,
"User-Agent": UserAgent,
})
req.SetQueryParams(map[string]string{
"version_code": "20800",
"device_platform": "web",
})
if callback != nil {
callback(req)
}
var commonResp CommonResp
res, err := req.Execute(method, reqUrl)
log.Debugln(res.String())
if err != nil {
return nil, err
}
body := res.Body()
if err = json.Unmarshal(body, &commonResp); err != nil {
return nil, err
}
if !commonResp.IsSuccess() {
return body, commonResp.GetError()
}
if resp != nil {
if err = json.Unmarshal(body, resp); err != nil {
return body, err
}
}
return body, nil
}
func (d *DoubaoShare) getFiles(dirId, nodeId, cursor string) (resp []File, err error) {
var r NodeInfoResp
var body = base.Json{
"share_id": dirId,
"node_id": nodeId,
}
if cursor != "" {
body["cursor"] = cursor
body["size"] = 50
} else {
body["need_full_path"] = false
}
_, err = d.request("/samantha/aispace/share/node_info", http.MethodPost, func(req *resty.Request) {
req.SetBody(body)
}, &r)
if err != nil {
return nil, err
}
if r.NodeInfoData.Children != nil {
resp = r.NodeInfoData.Children
}
if r.NodeInfoData.NextCursor != "-1" {
nextFiles, err := d.getFiles(dirId, nodeId, r.NodeInfoData.NextCursor)
if err != nil {
return nil, err
}
resp = append(r.NodeInfoData.Children, nextFiles...)
}
return resp, err
}
func (d *DoubaoShare) getShareOverview(shareId, cursor string) (resp []File, err error) {
return d.getShareOverviewWithHistory(shareId, cursor, make(map[string]bool))
}
func (d *DoubaoShare) getShareOverviewWithHistory(shareId, cursor string, cursorHistory map[string]bool) (resp []File, err error) {
var r NodeInfoResp
var body = base.Json{
"share_id": shareId,
}
if cursor != "" {
body["cursor"] = cursor
body["size"] = 50
} else {
body["need_full_path"] = false
}
_, err = d.request("/samantha/aispace/share/overview", http.MethodPost, func(req *resty.Request) {
req.SetBody(body)
}, &r)
if err != nil {
return nil, err
}
if r.NodeInfoData.NodeList != nil {
resp = r.NodeInfoData.NodeList
}
if r.NodeInfoData.NextCursor != "-1" {
if cursorHistory[r.NodeInfoData.NextCursor] {
return resp, nil
}
cursorHistory[r.NodeInfoData.NextCursor] = true
nextFiles, err := d.getShareOverviewWithHistory(shareId, r.NodeInfoData.NextCursor, cursorHistory)
if err != nil {
return nil, err
}
resp = append(resp, nextFiles...)
}
return resp, nil
}
func (d *DoubaoShare) initShareList() error {
if d.Addition.ShareIds == "" {
return fmt.Errorf("share_ids is empty")
}
shareConfigs, rootShares, err := d._parseShareConfigs()
if err != nil {
return err
}
if err := d._detectPathConflicts(shareConfigs); err != nil {
return err
}
rootMap := d._buildTreeStructure(shareConfigs, rootShares)
topLevelNodes := d._extractTopLevelNodes(rootMap, rootShares)
if len(topLevelNodes) == 0 {
return fmt.Errorf("no valid share_ids found")
}
d.RootFiles = topLevelNodes
return nil
}
func (d *DoubaoShare) _parseShareConfigs() (map[string]string, []string, error) {
shareConfigs := make(map[string]string)
rootShares := make([]string, 0)
lines := strings.Split(strings.TrimSpace(d.Addition.ShareIds), "\n")
if len(lines) == 0 {
return nil, nil, fmt.Errorf("no share_ids found")
}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "|")
var shareId, sharePath string
if len(parts) == 1 {
shareId = _extractShareId(parts[0])
if shareId != "" {
rootShares = append(rootShares, shareId)
}
continue
} else if len(parts) >= 2 {
shareId = _extractShareId(parts[0])
sharePath = strings.Trim(parts[1], "/")
}
if shareId == "" {
log.Warnf("[doubao_share] Invalid Share_id Format: %s", line)
continue
}
if sharePath == "" {
rootShares = append(rootShares, shareId)
continue
}
shareConfigs[sharePath] = shareId
}
return shareConfigs, rootShares, nil
}
func (d *DoubaoShare) _detectPathConflicts(shareConfigs map[string]string) error {
pathToShareIds := make(map[string][]string)
for sharePath, id := range shareConfigs {
pathToShareIds[sharePath] = append(pathToShareIds[sharePath], id)
}
for sharePath, ids := range pathToShareIds {
if len(ids) > 1 {
return fmt.Errorf("路径冲突: 路径 '%s' 被多个不同的分享ID使用: %s",
sharePath, strings.Join(ids, ", "))
}
}
for path1, id1 := range shareConfigs {
for path2, id2 := range shareConfigs {
if path1 == path2 || id1 == id2 {
continue
}
if strings.HasPrefix(path2, path1+"/") || strings.HasPrefix(path1, path2+"/") {
return fmt.Errorf("路径冲突: 路径 '%s' (ID: %s) 与路径 '%s' (ID: %s) 存在层次冲突",
path1, id1, path2, id2)
}
}
}
return nil
}
func (d *DoubaoShare) _buildTreeStructure(shareConfigs map[string]string, rootShares []string) map[string]*RootFileList {
rootMap := make(map[string]*RootFileList)
for sharePath, shareId := range shareConfigs {
children := make([]RootFileList, 0)
rootMap[sharePath] = &RootFileList{
ShareID: shareId,
VirtualPath: sharePath,
NodeInfo: NodeInfoData{},
Child: &children,
}
}
for sharePath, node := range rootMap {
if sharePath == "" {
continue
}
pathParts := strings.Split(sharePath, "/")
if len(pathParts) > 1 {
parentPath := strings.Join(pathParts[:len(pathParts)-1], "/")
_ensurePathExists(rootMap, parentPath)
if parent, exists := rootMap[parentPath]; exists {
*parent.Child = append(*parent.Child, *node)
}
}
}
return rootMap
}
func (d *DoubaoShare) _extractTopLevelNodes(rootMap map[string]*RootFileList, rootShares []string) []RootFileList {
var topLevelNodes []RootFileList
for _, shareId := range rootShares {
children := make([]RootFileList, 0)
topLevelNodes = append(topLevelNodes, RootFileList{
ShareID: shareId,
VirtualPath: "",
NodeInfo: NodeInfoData{},
Child: &children,
})
}
for rootPath, node := range rootMap {
if rootPath == "" {
continue
}
isTopLevel := true
pathParts := strings.Split(rootPath, "/")
if len(pathParts) > 1 {
parentPath := strings.Join(pathParts[:len(pathParts)-1], "/")
if _, exists := rootMap[parentPath]; exists {
isTopLevel = false
}
}
if isTopLevel {
topLevelNodes = append(topLevelNodes, *node)
}
}
return topLevelNodes
}
func _ensurePathExists(rootMap map[string]*RootFileList, path string) {
if path == "" {
return
}
if _, exists := rootMap[path]; exists {
return
}
children := make([]RootFileList, 0)
rootMap[path] = &RootFileList{
ShareID: "",
VirtualPath: path,
NodeInfo: NodeInfoData{},
Child: &children,
}
pathParts := strings.Split(path, "/")
if len(pathParts) > 1 {
parentPath := strings.Join(pathParts[:len(pathParts)-1], "/")
_ensurePathExists(rootMap, parentPath)
if parent, exists := rootMap[parentPath]; exists {
*parent.Child = append(*parent.Child, *rootMap[path])
}
}
}
func _extractShareId(input string) string {
input = strings.TrimSpace(input)
if strings.HasPrefix(input, "http") {
regex := regexp.MustCompile(`/drive/s/([a-zA-Z0-9]+)`)
if matches := regex.FindStringSubmatch(input); len(matches) > 1 {
return matches[1]
}
return ""
}
return input
}
func _findRootFileByShareID(rootFiles []RootFileList, shareID string) *RootFileList {
for i, rf := range rootFiles {
if rf.ShareID == shareID {
return &rootFiles[i]
}
if rf.Child != nil && len(*rf.Child) > 0 {
if found := _findRootFileByShareID(*rf.Child, shareID); found != nil {
return found
}
}
}
return nil
}
func _findNodeByPath(rootFiles []RootFileList, path string) *RootFileList {
for i, rf := range rootFiles {
if rf.VirtualPath == path {
return &rootFiles[i]
}
if rf.Child != nil && len(*rf.Child) > 0 {
if found := _findNodeByPath(*rf.Child, path); found != nil {
return found
}
}
}
return nil
}
func _findShareByPath(rootFiles []RootFileList, path string) (*RootFileList, string) {
for i, rf := range rootFiles {
if rf.VirtualPath == path {
return &rootFiles[i], ""
}
if rf.VirtualPath != "" && strings.HasPrefix(path, rf.VirtualPath+"/") {
relPath := strings.TrimPrefix(path, rf.VirtualPath+"/")
if rf.Child != nil && len(*rf.Child) > 0 {
if child, childPath := _findShareByPath(*rf.Child, path); child != nil {
return child, childPath
}
}
return &rootFiles[i], relPath
}
if rf.Child != nil && len(*rf.Child) > 0 {
if child, childPath := _findShareByPath(*rf.Child, path); child != nil {
return child, childPath
}
}
}
for i, rf := range rootFiles {
if rf.VirtualPath == "" && rf.ShareID != "" {
parts := strings.SplitN(path, "/", 2)
if len(parts) > 0 && parts[0] == rf.ShareID {
if len(parts) > 1 {
return &rootFiles[i], parts[1]
}
return &rootFiles[i], ""
}
}
}
return nil, ""
}
func (d *DoubaoShare) _findShareAndPath(dir model.Obj) (string, string, error) {
dirPath := dir.GetPath()
if dirPath == "/" || dirPath == "" {
return "", "", nil
}
if fo, ok := dir.(*FileObject); ok && fo.ShareID != "" {
relativePath := strings.TrimPrefix(dirPath, "/")
found := _findRootFileByShareID(d.RootFiles, fo.ShareID)
if found != nil {
if found.VirtualPath != "" {
if strings.HasPrefix(relativePath, found.VirtualPath) {
return fo.ShareID, strings.TrimPrefix(relativePath, found.VirtualPath+"/"), nil
}
}
return fo.ShareID, relativePath, nil
}
return fo.ShareID, relativePath, nil
}
cleanPath := strings.TrimPrefix(dirPath, "/")
for _, rootFile := range d.RootFiles {
if rootFile.VirtualPath == "" && rootFile.ShareID != "" {
parts := strings.SplitN(cleanPath, "/", 2)
if len(parts) > 0 && parts[0] == rootFile.ShareID {
if len(parts) > 1 {
return rootFile.ShareID, parts[1], nil
}
return rootFile.ShareID, "", nil
}
}
}
share, relPath := _findShareByPath(d.RootFiles, cleanPath)
if share != nil {
return share.ShareID, relPath, nil
}
log.Warnf("[doubao_share] No matching share path found: %s", dirPath)
return "", "", fmt.Errorf("no matching share path found: %s", dirPath)
}
func (d *DoubaoShare) convertToFileObject(file File, shareId string, relativePath string) *FileObject {
obj := &FileObject{
Object: model.Object{
ID: file.ID,
Name: file.Name,
Size: file.Size,
Modified: time.Unix(file.UpdateTime, 0),
Ctime: time.Unix(file.CreateTime, 0),
IsFolder: file.NodeType == DirectoryType,
Path: path.Join(relativePath, file.Name),
},
ShareID: shareId,
Key: file.Key,
NodeID: file.ID,
NodeType: file.NodeType,
}
return obj
}
func (d *DoubaoShare) getFilesInPath(ctx context.Context, shareId, nodeId, relativePath string) ([]model.Obj, error) {
var (
files []File
err error
)
if nodeId == "" {
files, err = d.getShareOverview(shareId, "")
if err != nil {
return nil, fmt.Errorf("failed to get share link information: %w", err)
}
result := make([]model.Obj, 0, len(files))
for _, file := range files {
result = append(result, d.convertToFileObject(file, shareId, "/"))
}
return result, nil
} else {
files, err = d.getFiles(shareId, nodeId, "")
if err != nil {
return nil, fmt.Errorf("failed to get share file: %w", err)
}
result := make([]model.Obj, 0, len(files))
for _, file := range files {
result = append(result, d.convertToFileObject(file, shareId, path.Join("/", relativePath)))
}
return result, nil
}
}
func (d *DoubaoShare) listRootDirectory(ctx context.Context) ([]model.Obj, error) {
objects := make([]model.Obj, 0)
var directShareIDs []string
addedDirs := make(map[string]bool)
for _, rootFile := range d.RootFiles {
if rootFile.VirtualPath == "" && rootFile.ShareID != "" {
directShareIDs = append(directShareIDs, rootFile.ShareID)
} else {
parts := strings.SplitN(rootFile.VirtualPath, "/", 2)
firstLevel := parts[0]
if _, exists := addedDirs[firstLevel]; exists {
continue
}
obj := &FileObject{
Object: model.Object{
ID: "",
Name: firstLevel,
Modified: time.Now(),
Ctime: time.Now(),
IsFolder: true,
Path: path.Join("/", firstLevel),
},
ShareID: rootFile.ShareID,
Key: "",
NodeID: "",
NodeType: DirectoryType,
}
objects = append(objects, obj)
addedDirs[firstLevel] = true
}
}
for _, shareID := range directShareIDs {
shareFiles, err := d.getFilesInPath(ctx, shareID, "", "")
if err != nil {
log.Warnf("[doubao_share] Failed to get list of files in share %s: %s", shareID, err)
continue
}
objects = append(objects, shareFiles...)
}
return objects, nil
}
func (d *DoubaoShare) listVirtualDirectoryContent(dir model.Obj) ([]model.Obj, error) {
dirPath := strings.TrimPrefix(dir.GetPath(), "/")
objects := make([]model.Obj, 0)
node := _findNodeByPath(d.RootFiles, dirPath)
if node != nil && node.Child != nil {
for _, child := range *node.Child {
displayName := child.VirtualPath
if child.VirtualPath != "" {
parts := strings.Split(child.VirtualPath, "/")
displayName = parts[len(parts)-1]
} else if child.ShareID != "" {
displayName = child.ShareID
}
obj := &FileObject{
Object: model.Object{
ID: "",
Name: displayName,
Modified: time.Now(),
Ctime: time.Now(),
IsFolder: true,
Path: path.Join("/", child.VirtualPath),
},
ShareID: child.ShareID,
Key: "",
NodeID: "",
NodeType: DirectoryType,
}
objects = append(objects, obj)
}
}
return objects, nil
}
func generateContentDisposition(filename string) string {
encodedName := urlEncode(filename)
encodedNameRFC5987 := encodeRFC5987(filename)
return fmt.Sprintf("attachment; filename=\"%s\"; filename*=utf-8''%s",
encodedName, encodedNameRFC5987)
}
func encodeRFC5987(s string) string {
var buf strings.Builder
for _, r := range []byte(s) {
if (r >= 'a' && r <= 'z') ||
(r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') ||
r == '-' || r == '.' || r == '_' || r == '~' {
buf.WriteByte(r)
} else {
fmt.Fprintf(&buf, "%%%02X", r)
}
}
return buf.String()
}
func urlEncode(s string) string {
s = url.QueryEscape(s)
s = strings.ReplaceAll(s, "+", "%20")
return s
}