├── admin ├── static │ ├── js │ │ ├── main.js │ │ └── video.js │ ├── avatar │ │ └── default.jpg │ ├── images │ │ └── default.jpg │ ├── video │ │ └── default.mp4 │ ├── favicons │ │ └── favicon.ico │ ├── css │ │ ├── navbar.css │ │ ├── video.css │ │ ├── main.css │ │ └── video-index.css │ └── bootstrap │ │ └── v4.6.0 │ │ └── brand │ │ ├── bootstrap-solid.svg │ │ └── bootstrap-outline.svg ├── conf │ ├── map.conf │ ├── robots.txt │ └── app.example.conf ├── structs │ └── result.go ├── controllers │ ├── login.go │ ├── tag.go │ ├── video.go │ ├── baidu.go │ ├── home.go │ ├── index.go │ ├── content.go │ └── weixin.go ├── service │ ├── util.go │ ├── handler.go │ ├── download_cover.go │ ├── baidu.go │ ├── crontab.go │ └── service.go ├── views │ ├── layout │ │ └── footer.gohtml │ ├── index │ │ ├── video.gohtml │ │ ├── script.gohtml │ │ ├── index.gohtml │ │ ├── main.gohtml │ │ ├── list.gohtml │ │ ├── content_index.gohtml │ │ └── content.gohtml │ └── home │ │ └── index.gohtml ├── models │ ├── model.go │ ├── douyin_user.go │ ├── baidu.go │ ├── user.go │ ├── douyin_cover.go │ ├── douyin_tag.go │ └── douyin.go ├── routers │ └── routers.go ├── filters │ └── auth.go └── web.go ├── internal └── utils │ ├── os.go │ ├── utils_test.go │ ├── utils.go │ ├── beego.go │ ├── download.go │ └── webp.go ├── .idea └── .gitignore ├── baidu ├── util.go ├── netdisk_test.go └── structs.go ├── douyin ├── util.go ├── douyin_test.go ├── video_test.go ├── douyin.go ├── video.go └── result.go ├── .gitignore ├── azure-pipelines.yml ├── storage ├── storage.go ├── disk.go ├── option.go └── cloudflare.go ├── wechat ├── utils.go ├── structs.go └── wechat.go ├── LICENSE ├── Dockerfile ├── main.go ├── README.md ├── .github └── workflows │ ├── release.yml │ └── docker-image.yml ├── qiniu └── qiuniu.go └── go.mod /admin/static/js/main.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /admin/conf/map.conf: -------------------------------------------------------------------------------- 1 | [nickname] 2 | #昵称映射 3 | #昵称id=昵称名称 -------------------------------------------------------------------------------- /admin/conf/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /content/next/ 3 | Disallow: /wechat 4 | Disallow: /baidu 5 | -------------------------------------------------------------------------------- /admin/static/avatar/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lifei6671/DouYinBot/HEAD/admin/static/avatar/default.jpg -------------------------------------------------------------------------------- /admin/static/images/default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lifei6671/DouYinBot/HEAD/admin/static/images/default.jpg -------------------------------------------------------------------------------- /admin/static/video/default.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lifei6671/DouYinBot/HEAD/admin/static/video/default.mp4 -------------------------------------------------------------------------------- /admin/static/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lifei6671/DouYinBot/HEAD/admin/static/favicons/favicon.ico -------------------------------------------------------------------------------- /internal/utils/os.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import "os" 4 | 5 | func FileExists(path string) bool { 6 | _, err := os.Stat(path) 7 | return !os.IsNotExist(err) 8 | } 9 | -------------------------------------------------------------------------------- /admin/structs/result.go: -------------------------------------------------------------------------------- 1 | package structs 2 | 3 | type JsonResult[T any] struct { 4 | ErrCode int `json:"errcode"` 5 | Message string `json:"message"` 6 | Data T `json:"data,omitempty"` 7 | } 8 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | # Editor-based HTTP Client requests 8 | /httpRequests/ 9 | -------------------------------------------------------------------------------- /admin/controllers/login.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import "github.com/beego/beego/v2/server/web" 4 | 5 | type LoginController struct { 6 | web.Controller 7 | } 8 | 9 | func (c *LoginController) Login() { 10 | if c.Ctx.Input.IsGet() { 11 | 12 | } else { 13 | 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /baidu/util.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import "unicode/utf8" 4 | 5 | // FilterEmoji 过滤 emoji 表情 6 | func FilterEmoji(content string) string { 7 | newContent := "" 8 | for _, value := range content { 9 | _, size := utf8.DecodeRuneInString(string(value)) 10 | if size <= 3 { 11 | newContent += string(value) 12 | } 13 | } 14 | return newContent 15 | } 16 | -------------------------------------------------------------------------------- /douyin/util.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | import "unicode/utf8" 4 | 5 | // FilterEmoji 过滤 emoji 表情 6 | func FilterEmoji(content string) string { 7 | newContent := "" 8 | for _, value := range content { 9 | _, size := utf8.DecodeRuneInString(string(value)) 10 | if size <= 3 { 11 | newContent += string(value) 12 | } 13 | } 14 | return newContent 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | data 8 | douyin 9 | *.gohtml~ 10 | *.go~ 11 | *.*~ 12 | # Test binary, built with `go test -c` 13 | *.test 14 | 15 | # Output of the go coverage tool, specifically when used with LiteIDE 16 | *.out 17 | 18 | # Dependency directories (remove the comment below to include it) 19 | # vendor/ 20 | .idea -------------------------------------------------------------------------------- /admin/static/css/navbar.css: -------------------------------------------------------------------------------- 1 | /* Custom page CSS 2 | -------------------------------------------------- */ 3 | /* Not required for template or sticky footer method. */ 4 | main > .container { 5 | padding: 60px 15px 0; 6 | } 7 | .footer { 8 | background-color: #f5f5f5; 9 | } 10 | .footer > .container { 11 | padding-right: 15px; 12 | padding-left: 15px; 13 | } 14 | code { 15 | font-size: 80%; 16 | } -------------------------------------------------------------------------------- /admin/service/util.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "github.com/beego/beego/v2/core/logs" 5 | "regexp" 6 | ) 7 | 8 | func IsMobile(userAgent string) bool { 9 | reg := "(iphone|MicroMessenger|ios|android|mini|mobile|mobi|Nokia|Symbian|iPod|iPad|Windows\\s+Phone|MQQBrowser|wp7|wp8|UCBrowser7|UCWEB|360\\s+Aphone\\s+Browser)" 10 | isMobile, err := regexp.Match(reg, []byte(userAgent)) 11 | if err != nil { 12 | logs.Error("匹配User-Agent失败 -> %+v", err) 13 | } 14 | return isMobile 15 | } 16 | -------------------------------------------------------------------------------- /internal/utils/utils_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/smartystreets/goconvey/convey" 5 | "testing" 6 | ) 7 | 8 | func TestParseExpireUnix(t *testing.T) { 9 | convey.Convey("ParseExpireUnix", t, func() { 10 | s := "https://p3-sign.douyinpic.com/obj/tos-cn-p-0015/08ad41080a594352930d76032b60cd9c_1641094018?x-expires=1642388400&x-signature=HZxZ2GJrHt58xxzWg%2Fk%2BEovCDaU%3D&from=4257465056_large" 11 | 12 | n, err := ParseExpireUnix(s) 13 | 14 | convey.So(err, convey.ShouldBeNil) 15 | convey.So(n, convey.ShouldEqual, 1642388400) 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | # Docker 2 | # Build a Docker image 3 | # https://docs.microsoft.com/azure/devops/pipelines/languages/docker 4 | 5 | trigger: 6 | - main 7 | 8 | resources: 9 | - repo: self 10 | 11 | variables: 12 | tag: '$(Build.BuildId)' 13 | 14 | stages: 15 | - stage: Build 16 | displayName: Build image 17 | jobs: 18 | - job: Build 19 | displayName: Build 20 | pool: 21 | vmImage: ubuntu-latest 22 | steps: 23 | - task: Docker@2 24 | displayName: Build an image 25 | inputs: 26 | command: build 27 | dockerfile: '$(Build.SourcesDirectory)/Dockerfile' 28 | tags: | 29 | $(tag) 30 | -------------------------------------------------------------------------------- /admin/static/bootstrap/v4.6.0/brand/bootstrap-solid.svg: -------------------------------------------------------------------------------- 1 | 2 | Bootstrap 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | ) 8 | 9 | // File 一个文件 10 | type File struct { 11 | ContentLength *int64 12 | ContentType *string 13 | Body io.ReadCloser 14 | } 15 | 16 | type Storage interface { 17 | // OpenFile 打开文件 18 | OpenFile(ctx context.Context, filename string) (*File, error) 19 | 20 | // Delete 删除一个文件 21 | Delete(ctx context.Context, filename string) error 22 | 23 | // WriteFile 写入一个文件 24 | WriteFile(ctx context.Context, r io.Reader, filename string) (string, error) 25 | } 26 | 27 | func Factory(name string, opts ...OptionsFunc) (Storage, error) { 28 | switch name { 29 | case "local": 30 | return NewDiskStorage(), nil 31 | case "cloudflare": 32 | return NewCloudflare(opts...) 33 | } 34 | return nil, fmt.Errorf("unknown storage: %s", name) 35 | } 36 | -------------------------------------------------------------------------------- /wechat/utils.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "errors" 8 | "io" 9 | ) 10 | 11 | func aesDecrypt(cipherData []byte, aesKey []byte) ([]byte, error) { 12 | k := len(aesKey) //PKCS#7 13 | if len(cipherData)%k != 0 { 14 | return nil, errors.New("crypto/cipher: ciphertext size is not multiple of aes key length") 15 | } 16 | 17 | block, err := aes.NewCipher(aesKey) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | iv := make([]byte, aes.BlockSize) 23 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 24 | return nil, err 25 | } 26 | 27 | blockMode := cipher.NewCBCDecrypter(block, iv) 28 | plainData := make([]byte, len(cipherData)) 29 | blockMode.CryptBlocks(plainData, cipherData) 30 | return plainData, nil 31 | } 32 | 33 | func Value(val string) CDATA { 34 | return CDATA{val} 35 | } 36 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/url" 7 | "strconv" 8 | ) 9 | 10 | // ParseExpireUnix 从URL中解析过期时间 11 | func ParseExpireUnix(s string) (int, error) { 12 | uri, err := url.ParseRequestURI(s) 13 | if err != nil { 14 | return 0, err 15 | } 16 | if v := uri.Query().Get("x-expires"); v != "" { 17 | expire, err := strconv.Atoi(v) 18 | if err != nil { 19 | return 0, err 20 | } 21 | return expire, nil 22 | } 23 | return 0, errors.New("url is empty") 24 | } 25 | 26 | func SafeClose(closer io.Closer) { 27 | if closer != nil { 28 | _ = closer.Close() 29 | } 30 | } 31 | 32 | func First[T any](v []T) T { 33 | var zero T 34 | if len(v) > 0 { 35 | return v[0] 36 | } 37 | return zero 38 | } 39 | 40 | // Ternary 泛型三元表达式 41 | func Ternary[T any](condition bool, trueVal, falseVal T) T { 42 | if condition { 43 | return trueVal 44 | } 45 | return falseVal 46 | } 47 | -------------------------------------------------------------------------------- /admin/views/layout/footer.gohtml: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /admin/static/bootstrap/v4.6.0/brand/bootstrap-outline.svg: -------------------------------------------------------------------------------- 1 | 2 | Bootstrap 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /admin/models/model.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/beego/beego/v2/client/orm" 10 | _ "github.com/mattn/go-sqlite3" 11 | ) 12 | 13 | var ( 14 | ErrUserAccountExist = errors.New("用户账号已存在") 15 | ErrUsrEmailExist = errors.New("用户邮箱已存在") 16 | ErrUserWechatIdExist = errors.New("微信已绑定其他账号") 17 | ) 18 | 19 | func Init(dataSource string) error { 20 | filename, err := filepath.Abs(dataSource) 21 | if err != nil { 22 | return err 23 | } 24 | dir := filepath.Dir(filename) 25 | if _, err := os.Stat(dir); os.IsNotExist(err) { 26 | if err := os.MkdirAll(dir, 0755); err != nil { 27 | return err 28 | } 29 | } 30 | 31 | // 参数1 数据库的别名,用来在 ORM 中切换数据库使用 32 | // 参数2 driverName 33 | // 参数3 对应的链接字符串 34 | filename = filename + "?_loc=" + time.Local.String() 35 | if err := orm.RegisterDataBase("default", "sqlite3", filename); err != nil { 36 | return err 37 | } 38 | err = orm.RunSyncdb("default", false, true) 39 | return err 40 | } 41 | -------------------------------------------------------------------------------- /admin/views/index/video.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /internal/utils/beego.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/beego/beego/v2/server/web/context" 10 | ) 11 | 12 | // CacheHeader 设置缓存响应头 13 | func CacheHeader(ctx *context.BeegoOutput, t time.Time, minAge, maxAge int) { 14 | lastTime := time.Date(t.Year(), t.Month(), t.Day(), 1, 0, 0, 0, t.Location()).UTC().Format(http.TimeFormat) 15 | ctx.Header("Cache-Control", fmt.Sprintf("max-age=%d, s-maxage=%d", minAge, maxAge)) 16 | ctx.Header("Cloudflare-CDN-Cache-Control", fmt.Sprintf("max-age=%d", maxAge)) 17 | ctx.Header("Date", lastTime) 18 | ctx.Header("Last-Modified", lastTime) 19 | } 20 | 21 | // IfLastModified 判断是否和当前时间匹配 22 | func IfLastModified(ctx *context.BeegoInput, t time.Time) error { 23 | lastTime := time.Date(t.Year(), t.Month(), t.Day(), 1, 0, 0, 0, t.Location()).UTC().Format(http.TimeFormat) 24 | modified := ctx.Header("If-Modified-Since") 25 | if modified != "" && lastTime == modified { 26 | return nil 27 | } 28 | return errors.New("Last-Modified not supported") 29 | } 30 | -------------------------------------------------------------------------------- /douyin/douyin_test.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | import ( 4 | "github.com/beego/beego/v2/server/web" 5 | "github.com/smartystreets/goconvey/convey" 6 | "log" 7 | "testing" 8 | ) 9 | 10 | func TestDouYin_Get(t *testing.T) { 11 | convey.Convey("DouYin_Get", t, func() { 12 | content := "5.87 WZZ:/ 再见少年拉弓满、不惧岁月不惧风! https://v.douyin.com/85MyVfe/ 复制此链接,达开Douyin搜索,矗接观看视pin! oxBCQt9rsUybLpUJ0BqHYk1SWZR4" 13 | 14 | content = "{9.25 Xzg:/ 复制打开抖音,看看【第十八年冬.的作品】“我从来不信什么天道,只信我自己”# 台词 # 好... https://v.douyin.com/BeSveAc/ oxBCQt9rsUybLpUJ0BqHYk1SWZR4}" 15 | dy := NewDouYin( 16 | web.AppConfig.DefaultString("douyinproxy", "https://api.disign.me/api"), 17 | web.AppConfig.DefaultString("douyinproxyusername", ""), 18 | web.AppConfig.DefaultString("douyinproxypassword", ""), 19 | ) 20 | 21 | convey.Convey("DouYin_Get_OK", func() { 22 | video, err := dy.Get(content) 23 | convey.So(err, convey.ShouldBeNil) 24 | convey.So(video, convey.ShouldNotBeNil) 25 | }) 26 | }) 27 | } 28 | 29 | func TestMain(m *testing.M) { 30 | log.SetFlags(log.LstdFlags | log.Lshortfile) 31 | m.Run() 32 | } 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Minho 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25-alpine3.23 as build 2 | 3 | LABEL maintainer="longfei6671@163.com" 4 | 5 | RUN apk add --update-cache libc-dev git gcc musl-dev sqlite-dev sqlite-static libwebp libwebp-dev libwebp-static 6 | 7 | WORKDIR /go/src/app/ 8 | 9 | COPY . . 10 | 11 | ENV GOPROXY=https://goproxy.cn,direct 12 | ENV CGO_ENABLED=1 13 | ENV CC="gcc" 14 | ENV CGO_LDFLAGS="-static" 15 | ENV CGO_CFLAGS="-I/usr/include" 16 | ENV CGO_LDFLAGS="-L/usr/lib -lwebp -static" 17 | 18 | RUN go mod download &&\ 19 | go build -ldflags='-s -w -extldflags "-static"' -tags "libsqlite3 linux" -o douyinbot main.go 20 | 21 | FROM alpine:3.23 22 | 23 | LABEL maintainer="longfei6671@163.com" 24 | 25 | COPY --from=build /go/src/app/douyinbot /var/www/douyinbot/ 26 | 27 | RUN apk add --no-cache sqlite-libs gcc 28 | RUN mkdir /lib64 && ln -s /lib/libc.musl-x86_64.so.1 /lib64/ld-linux-x86-64.so.2 29 | RUN chmod +x /var/www/douyinbot/douyinbot 30 | 31 | WORKDIR /var/www/douyinbot/ 32 | 33 | EXPOSE 9080 34 | 35 | CMD ["/var/www/douyinbot/douyinbot","--config-file","/var/www/douyinbot/conf/app.conf","--data-file","/var/www/douyinbot/data/douyinbot.db"] 36 | 37 | -------------------------------------------------------------------------------- /storage/disk.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | 9 | "github.com/aws/aws-sdk-go-v2/aws" 10 | ) 11 | 12 | type DiskStorage struct{} 13 | 14 | func (d *DiskStorage) OpenFile(_ context.Context, filename string) (*File, error) { 15 | f, err := os.Open(filename) 16 | if err != nil { 17 | return nil, fmt.Errorf("failed to open file: %w", err) 18 | } 19 | stat, sErr := f.Stat() 20 | if sErr != nil { 21 | return nil, fmt.Errorf("failed to stat file: %w", err) 22 | } 23 | return &File{ 24 | ContentLength: aws.Int64(stat.Size()), 25 | Body: f, 26 | }, nil 27 | } 28 | 29 | func (d *DiskStorage) Delete(_ context.Context, filename string) error { 30 | return os.Remove(filename) 31 | } 32 | 33 | func (d *DiskStorage) WriteFile(ctx context.Context, r io.Reader, filename string) (string, error) { 34 | f, err := os.Create(filename) 35 | if err != nil { 36 | return "", fmt.Errorf("failed to open file: %w", err) 37 | } 38 | defer f.Close() 39 | if _, err := io.Copy(f, r); err != nil { 40 | return "", fmt.Errorf("failed to write file: %w", err) 41 | } 42 | return filename, nil 43 | } 44 | 45 | func NewDiskStorage() Storage { 46 | return &DiskStorage{} 47 | } 48 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "path/filepath" 7 | 8 | "github.com/beego/beego/v2/core/logs" 9 | 10 | "github.com/beego/beego/v2/server/web" 11 | _ "go.uber.org/automaxprocs" 12 | 13 | "github.com/lifei6671/douyinbot/admin" 14 | "github.com/lifei6671/douyinbot/admin/models" 15 | ) 16 | 17 | var ( 18 | port = ":9080" 19 | configFile = "./admin/conf/app.conf" 20 | dataPath = "./data/douyinbot.db" 21 | ) 22 | 23 | func main() { 24 | flag.StringVar(&port, "port", port, "Listening address and port.") 25 | flag.StringVar(&configFile, "config-file", configFile, "config file path.") 26 | flag.StringVar(&dataPath, "data-file", dataPath, "database file path.") 27 | flag.Parse() 28 | if port == "" { 29 | port = ":9080" 30 | } 31 | if work, err := filepath.Abs("admin"); err == nil { 32 | if configFile == "" { 33 | configFile = filepath.Join(work, "/conf/app.conf") 34 | } else { 35 | configFile, err = filepath.Abs(configFile) 36 | } 37 | if err != nil { 38 | panic(err) 39 | } 40 | web.WorkPath = work 41 | } 42 | 43 | if err := models.Init(dataPath); err != nil { 44 | panic(err) 45 | } 46 | if err := admin.Run(port, configFile); err != nil { 47 | panic(err) 48 | } 49 | } 50 | 51 | func init() { 52 | log.SetFlags(log.LstdFlags | log.Lshortfile) 53 | logs.EnableFuncCallDepth(true) 54 | } 55 | -------------------------------------------------------------------------------- /admin/conf/app.example.conf: -------------------------------------------------------------------------------- 1 | appname=douyinbot 2 | httpaddr=127.0.0.1 3 | httpport=9080 4 | autorender=true 5 | recoverpanic=true 6 | viewspath=views 7 | runmode=dev 8 | copyrequestbody = true 9 | 10 | #session配置 11 | sessionon=true 12 | sessionprovider=memory 13 | sessionname=MINCRONID 14 | sessiongcmaxlifetime=3600 15 | sessionproviderconfig= 16 | sessionhashkey=PNroxCpM0ULMRZ$z5e4e!Gxn 17 | 18 | #xsrc跨站攻击防护 19 | enablexsrf = false 20 | xsrfkey = mI7fL9k109ljmoDAmBBTPfowTGEAZznx9b8jH9@5 21 | xsrfexpire = 3600 22 | 23 | #视频解析工作协程数量 24 | workernumber=15 25 | 26 | #每页最大视频数量 27 | max_page_limit=30 28 | 29 | #文件默认保存路径 30 | auto-save-path= 31 | douyin-base-url=https://p5-ipv6.douyinpic.com 32 | image-save-path=/var/www/tools.iminho.me/images/ 33 | 34 | 35 | #微信公众号Token 36 | wechatappid= 37 | wechattoken= 38 | #EncodingAESKey 39 | wechatencodingaeskey= 40 | 41 | 42 | 43 | #七牛配置 44 | qiniuenable= 45 | qiuniuaccesskey= 46 | qiuniusecretkey= 47 | qiuniubucketname= 48 | qiniudoamin= 49 | 50 | 51 | # Cloudflare 配置 52 | s3_enable=false 53 | s3_bucket_name= 54 | s3_account_id= 55 | s3_access_key_id= 56 | s3_access_key_secret= 57 | s3_endpoint= 58 | s3_domain= 59 | 60 | # 抖音解析代理 61 | # https://github.com/lifei6671/ChromeDouYin 62 | douyinproxy= 63 | douyinproxyusername= 64 | douyinproxypassword= 65 | 66 | auto_reply_content= 67 | 68 | 69 | # 登录的账号密码 70 | auth.user=admin 71 | auth.pass=123456 72 | -------------------------------------------------------------------------------- /storage/option.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | type Options struct { 4 | BucketName string `json:"bucket_name"` 5 | AccountID string `json:"account_id"` 6 | AccessKeyID string `json:"access_key_id"` 7 | AccessKeySecret string `json:"access_key_secret"` 8 | Endpoint string `json:"endpoint"` 9 | Domain string `json:"domain"` 10 | } 11 | 12 | type OptionsFunc func(*Options) error 13 | 14 | // WithBucketName 设置Bucket名称 15 | func WithBucketName(bucketName string) OptionsFunc { 16 | return func(opt *Options) error { 17 | opt.BucketName = bucketName 18 | return nil 19 | } 20 | } 21 | 22 | // WithAccountID 设置账号 23 | func WithAccountID(accountID string) OptionsFunc { 24 | return func(opt *Options) error { 25 | opt.AccountID = accountID 26 | return nil 27 | } 28 | } 29 | func WithAccessKeyID(accessKeyID string) OptionsFunc { 30 | return func(opt *Options) error { 31 | opt.AccessKeyID = accessKeyID 32 | return nil 33 | } 34 | } 35 | func WithAccessKeySecret(accessKeySecret string) OptionsFunc { 36 | return func(opt *Options) error { 37 | opt.AccessKeySecret = accessKeySecret 38 | return nil 39 | } 40 | } 41 | func WithDomain(domain string) OptionsFunc { 42 | return func(opt *Options) error { 43 | opt.Domain = domain 44 | return nil 45 | } 46 | } 47 | 48 | func WithEndpoint(endpoint string) OptionsFunc { 49 | return func(opt *Options) error { 50 | opt.Endpoint = endpoint 51 | return nil 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DouYinBot 2 | 3 | 抖音(中国区)无水印视频、背景音乐、作者ID、作者昵称、作品标题等的全能解析和下载。 4 | 5 | ## 写在前面 6 | 7 | - 本项目纯属个人爱好创作 8 | - 所有视频的版权始终属于「字节跳动」 9 | - 严禁用于任何商业用途,如果构成侵权概不负责 10 | 11 | ## 目前功能 12 | 13 | - 解析无水印视频 14 | - 解析视频标题 15 | - 解析作者昵称 16 | - 解析作者ID 17 | - 不需要去除多余字符 18 | - 微信公众号消息转发后解析 19 | - 解析视频存库(仅支持通过微信转发消息的抖音视频,仅支持sqlite数据库) 20 | - 视频上传到七牛 21 | - 视频首页列表展示 22 | 23 | ## 使用 24 | 25 | ### 编译 26 | 27 | ```shell 28 | go build -o douyinbot main.go 29 | ``` 30 | 31 | ### 运行 32 | 33 | ```shell 34 | ./douyinbot --config-file=配置文件 --data-file=数据库路径 35 | ``` 36 | 37 | 38 | ### Docker 使用 39 | 40 | #### 部署 [ChromeDouYin](https://github.com/lifei6671/ChromeDouYin) 项目 41 | 42 | ```go 43 | go install github.com/lifei6671/ChromeDouYin 44 | 45 | ``` 46 | 47 | 默认情况下 [ChromeDouYin](https://github.com/lifei6671/ChromeDouYin) 会自动下载一个无头浏览器,并通过无头浏览器抓取抖音信息。 48 | 49 | 但是不保证所有系统都能成功,因此建议使用Docker部署: 50 | 51 | ```shell 52 | docker run -p 7317:7317 ghcr.io/go-rod/rod 53 | ``` 54 | 55 | 部署成功后, [ChromeDouYin](https://github.com/lifei6671/ChromeDouYin) 会自动连接到该实例。 56 | 57 | #### 部署 DouYinBot 58 | 59 | ```shell 60 | docker pull lifei6671/douyinbot:v1.0.17 61 | docker run -p 9080:9080 -v /data/conf:/var/www/douyinbot/conf /data/data:/var/www/douyinbot/data -v /data/douyin:/var/www/douyinbot/douyin -d lifei6671/douyinbot:v1.0.18 62 | ``` 63 | 64 | 需要修改配置文件中的代理信息: 65 | 66 | ``` 67 | douyinproxy=ChromeDouYin的访问接口,如果配置了认证信息只支持https访问 68 | douyinproxyusername=认证用户名 69 | douyinproxypassword=认证密码 70 | ``` -------------------------------------------------------------------------------- /baidu/netdisk_test.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | func TestPreCreateUploadFile_String(t *testing.T) { 9 | str := `{ 10 | "return_type": 2, 11 | "errno": 0, 12 | "info": { 13 | "size": 2626327, 14 | "category": 6, 15 | "isdir": 0, 16 | "request_id": 273435691682366413, 17 | "path": "/baidu/test/test.zip", 18 | "fs_id": 657059106724647, 19 | "md5": "60bac7b6464d84fed842955e6126826a", 20 | "ctime": 1545819399, 21 | "mtime": 1545819399 22 | }, 23 | "request_id": 273435691682366413 24 | }` 25 | var uploadFile PreCreateUploadFile 26 | err := json.Unmarshal([]byte(str), &uploadFile) 27 | if err != nil { 28 | t.Fatalf("unmarshl fail:%+v", err) 29 | } else { 30 | t.Logf("unmarshl succ:%s", uploadFile.String()) 31 | } 32 | } 33 | 34 | func TestCreateFile_String(t *testing.T) { 35 | str := `{ 36 | "errno": 0, 37 | "fs_id": 693789892866840, 38 | "md5": "7d57c40c9fdb4e4a32d533bee1a4e409", 39 | "server_filename": "test.txt", 40 | "category": 4, 41 | "path": "/apps/appName/test.txt", 42 | "size": 33818, 43 | "ctime": 1545969541, 44 | "mtime": 1545969541, 45 | "isdir": 0, 46 | "name": "/apps/appName/test.txt" 47 | }` 48 | var uploadFile CreateFile 49 | err := json.Unmarshal([]byte(str), &uploadFile) 50 | if err != nil { 51 | t.Fatalf("unmarshl fail:%+v", err) 52 | } else { 53 | t.Logf("unmarshl succ:%s", uploadFile.String()) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /admin/routers/routers.go: -------------------------------------------------------------------------------- 1 | package routers 2 | 3 | import ( 4 | "github.com/beego/beego/v2/server/web" 5 | 6 | "github.com/lifei6671/douyinbot/admin/filters" 7 | 8 | "github.com/lifei6671/douyinbot/admin/controllers" 9 | ) 10 | 11 | func init() { 12 | web.Router("/", &controllers.IndexController{}, "get:Index") 13 | web.Router("/page/:page:int.html", &controllers.IndexController{}, "get:Index") 14 | web.Router("/page/:author_id:int_:page:int.html", &controllers.IndexController{}, "get:List") 15 | 16 | // 🔐 对 /douyin 及所有子路径生效 17 | web.InsertFilter("/douyin/*", web.BeforeRouter, filters.BasicAuthFilter()) 18 | 19 | web.Router("/douyin", &controllers.HomeController{}, "get,post:Index") 20 | web.Router("/douyin/download", &controllers.HomeController{}, "get:Download") 21 | web.Router("/douyin/sendVideo", &controllers.HomeController{}, "get:SendVideo") 22 | 23 | web.Router("/tag/:tag_id:int_:page:int.html", &controllers.TagController{}, "get:Index") 24 | 25 | web.Router("/content_:video_id:string.html", &controllers.ContentController{}, "get:Index") 26 | web.Router("/content/next", &controllers.ContentController{}, "get:Next") 27 | 28 | web.Router("/wechat", &controllers.WeiXinController{}, "get:Index") 29 | web.Router("/wechat", &controllers.WeiXinController{}, "post:Dispatch") 30 | 31 | web.Router("/baidu/authorize", &controllers.BaiduController{}, "get:Index") 32 | web.Router("/baidu", &controllers.BaiduController{}, "get:Authorize") 33 | 34 | web.Router("/video/local/play", &controllers.VideoController{}, "get:Index") 35 | web.Router("/video/remote/play", &controllers.VideoController{}, "get:Play") 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release on Tag 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' # 监听所有标签 7 | 8 | jobs: 9 | build-and-release: 10 | name: Build and Release 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # 检出代码 15 | - name: Checkout Code 16 | uses: actions/checkout@v3 17 | 18 | # 安装依赖 19 | - name: Install Dependencies 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install -y libwebp-dev musl-tools musl-dev 23 | 24 | # 设置 Go 环境 25 | - name: Set up Go 26 | uses: actions/setup-go@v4 27 | with: 28 | go-version: 1.25 29 | 30 | # 获取当前标签 31 | - name: Get Tag Name 32 | id: vars 33 | run: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV 34 | 35 | # 登录 Docker Hub 36 | - name: Log in to Docker Hub 37 | uses: docker/login-action@v2 38 | with: 39 | username: ${{ secrets.DOCKERHUB_USERNAME }} 40 | password: ${{ secrets.DOCKERHUB_TOKEN }} 41 | 42 | # 构建 Docker 镜像 43 | - name: Build Docker Image 44 | run: | 45 | docker build -t docker.io/${{ secrets.DOCKERHUB_USERNAME }}/douyinbot:${{ env.tag }} . 46 | 47 | # 推送 Docker 镜像 48 | - name: Push Docker Image 49 | run: | 50 | # 添加 latest 标签 51 | docker tag docker.io/${{ secrets.DOCKERHUB_USERNAME }}/douyinbot:${{ env.tag }} docker.io/${{ secrets.DOCKERHUB_USERNAME }}/douyinbot:latest 52 | 53 | # 推送带版本号的标签 54 | docker push docker.io/${{ secrets.DOCKERHUB_USERNAME }}/douyinbot:${{ env.tag }} 55 | 56 | # 推送 latest 标签 57 | docker push docker.io/${{ secrets.DOCKERHUB_USERNAME }}/douyinbot:latest 58 | -------------------------------------------------------------------------------- /douyin/video_test.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | import ( 4 | "github.com/beego/beego/v2/server/web" 5 | "github.com/smartystreets/goconvey/convey" 6 | "testing" 7 | ) 8 | 9 | func TestVideo_Download(t *testing.T) { 10 | str := `5.35 Slc:/ 谁不爱全能的小王子呢%头盔 %拍照姿势 %单眼皮 https://v.douyin.com/RtQ332e/ 複製佌链接,打开Dou音搜索,矗接观看視频! oxBCQt9rsUybLpUJ0BqHYk1SWZR4` 11 | 12 | dy := NewDouYin( 13 | web.AppConfig.DefaultString("douyinproxy", "https://api.disign.me/api"), 14 | web.AppConfig.DefaultString("douyinproxyusername", ""), 15 | web.AppConfig.DefaultString("douyinproxypassword", ""), 16 | ) 17 | video, err := dy.Get(str) 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | p, err := video.Download("./video/") 22 | if err != nil { 23 | t.Fatal(err) 24 | } 25 | t.Log(p) 26 | } 27 | 28 | func TestVideo_DownloadCover(t *testing.T) { 29 | convey.Convey("Video_DownloadCove", t, func() { 30 | urlStr := "http://v3-web.douyinvod.com/46dcfa120d9045b22915eef9685a83b2/66d2a43e/video/tos/cn/tos-cn-ve-15/oIA5EmZBAe2EAUF9KZEQ5AANwYgfw16S9n0IzD/?a=6383\u0026ch=26\u0026cr=3\u0026dr=0\u0026lr=all\u0026cd=0%7C0%7C0%7C3\u0026cv=1\u0026br=3285\u0026bt=3285\u0026cs=0\u0026ds=4\u0026ft=4TMWc6Dnppft2zLd.sd.C_bAja-CInniuGtc6B3U~JP2SYpHDDaPd.m-ZGgzLusZ.\u0026mime_type=video_mp4\u0026qs=0\u0026rc=NDc0O2g0NTVmZDtlZjkzaEBpM3M0Onc5cmdzdTMzNGkzM0BiMzI2Nl9iXzIxM15fMDMzYSNscDQtMmRjbzVgLS1kLWFzcw%3D%3D\u0026btag=80000e00008000\u0026cquery=100w_100B_100x_100z_100o\u0026dy_q=1725069826\u0026feature_id=46a7bb47b4fd1280f3d3825bf2b29388\u0026l=2024083110034682A8613EB0FDE26EF8C2" 31 | 32 | video := Video{VideoId: "a"} 33 | cover, err := video.DownloadCover(urlStr, "./aaa/") 34 | convey.So(err, convey.ShouldBeNil) 35 | convey.So(cover, convey.ShouldNotBeNil) 36 | 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /wechat/structs.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "encoding/xml" 5 | "time" 6 | ) 7 | 8 | const ( 9 | EncryptTypeAES = "aes" 10 | EncryptTypeRAW = "raw" 11 | ) 12 | const ( 13 | WeiXinTextMsgType WeiXinMsgType = "text" 14 | WeiXinImageMsgType WeiXinMsgType = "image" 15 | WeiXinVoiceMsgType WeiXinMsgType = "voice" 16 | WeiXinVideoMsgType WeiXinMsgType = "video" 17 | WeiXinMusicMsgType WeiXinMsgType = "music" 18 | WeiXinNewsMsgType WeiXinMsgType = "news" 19 | WeiXinEventMsgType WeiXinMsgType = "event" 20 | ) 21 | const ( 22 | WeiXinSubscribeEvent = "subscribe" 23 | WeiXinUnsubscribeEvent = "unsubscribe" 24 | ) 25 | 26 | type WeiXinMsgType string 27 | 28 | type CDATA struct { 29 | Text string `xml:",cdata"` 30 | } 31 | type TextRequestBody struct { 32 | XMLName xml.Name `xml:"xml"` 33 | ToUserName string 34 | FromUserName string 35 | CreateTime time.Duration 36 | MsgType string 37 | Content string 38 | MsgId int 39 | Event string 40 | } 41 | 42 | type PassiveUserReplyMessage struct { 43 | XMLName xml.Name `xml:"xml"` 44 | ToUserName CDATA `xml:"ToUserName"` 45 | FromUserName CDATA `xml:"FromUserName"` 46 | CreateTime CDATA `xml:"CreateTime"` 47 | MsgType CDATA `xml:"MsgType"` 48 | Content CDATA `xml:"Content"` 49 | } 50 | 51 | func (p *PassiveUserReplyMessage) String() string { 52 | b, _ := xml.Marshal(p) 53 | return string(b) 54 | } 55 | 56 | type EncryptRequestBody struct { 57 | XMLName xml.Name `xml:"xml"` 58 | ToUserName string 59 | Encrypt string 60 | } 61 | 62 | type EncryptResponseBody struct { 63 | XMLName xml.Name `xml:"xml"` 64 | Encrypt CDATA 65 | MsgSignature CDATA 66 | TimeStamp string 67 | Nonce CDATA 68 | } 69 | -------------------------------------------------------------------------------- /admin/models/douyin_user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/beego/beego/v2/client/orm" 7 | ) 8 | 9 | type DouYinUser struct { 10 | Id int `orm:"column(id);auto;pk"` 11 | Nickname string `orm:"column(nickname);size(100); description(作者昵称)"` 12 | Signature string `orm:"column(signature);size(255);null;description(作者信息)"` 13 | AvatarLarger string `orm:"column(avatar_larger);size(2000);null;description(作者头像)"` 14 | AvatarCDNURL string `orm:"column(avatar_cdn_url);size(2000);null;description(作者头像)"` 15 | HashValue string `orm:"column(hash_value);index;size(64);null;description(作者头像)"` 16 | AuthorId string `orm:"column(author_id);size(20);null;description(作者长ID)"` 17 | Created time.Time `orm:"auto_now_add;type(datetime);description(创建时间)"` 18 | } 19 | 20 | func (d *DouYinUser) TableName() string { 21 | return "douyin_user" 22 | } 23 | 24 | // TableUnique 多字段唯一键 25 | func (d *DouYinUser) TableUnique() [][]string { 26 | return [][]string{ 27 | {"AuthorId"}, 28 | } 29 | } 30 | 31 | func NewDouYinUser() *DouYinUser { 32 | return &DouYinUser{} 33 | } 34 | 35 | func (d *DouYinUser) GetById(authorId string) (*DouYinUser, error) { 36 | err := orm.NewOrm().QueryTable(d.TableName()).Filter("author_id", authorId).One(d) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return d, nil 41 | } 42 | 43 | func (d *DouYinUser) Create() (int, error) { 44 | id, err := orm.NewOrm().Insert(d) 45 | if err != nil { 46 | return 0, err 47 | } 48 | return int(id), nil 49 | } 50 | 51 | func (d *DouYinUser) Update() error { 52 | _, err := orm.NewOrm().Update(d) 53 | if err != nil { 54 | return err 55 | } 56 | return nil 57 | } 58 | 59 | func init() { 60 | // 需要在init中注册定义的model 61 | orm.RegisterModel(new(DouYinUser)) 62 | } 63 | -------------------------------------------------------------------------------- /admin/views/index/script.gohtml: -------------------------------------------------------------------------------- 1 | {{template "layout/footer.gohtml"}} 2 | -------------------------------------------------------------------------------- /admin/filters/auth.go: -------------------------------------------------------------------------------- 1 | package filters 2 | 3 | import ( 4 | "encoding/base64" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/beego/beego/v2/server/web" 9 | beego "github.com/beego/beego/v2/server/web/context" 10 | ) 11 | 12 | func BasicAuthFilter() web.FilterFunc { 13 | return func(ctx *beego.Context) { 14 | // 只保护 /douyin 开头 15 | path := ctx.Input.URL() 16 | if !strings.HasPrefix(path, "/douyin") { 17 | return 18 | } 19 | 20 | user, _ := web.AppConfig.String("auth.user") 21 | pass, _ := web.AppConfig.String("auth.pass") 22 | 23 | // 防止线上误配置导致接口裸奔 24 | if user == "" || pass == "" { 25 | ctx.Output.SetStatus(http.StatusUnauthorized) 26 | ctx.Output.Body([]byte("basic auth not configured")) 27 | return 28 | } 29 | 30 | auth := ctx.Input.Header("Authorization") 31 | if auth == "" || !strings.HasPrefix(auth, "Basic ") { 32 | unauthorized(ctx) 33 | return 34 | } 35 | 36 | raw := strings.TrimPrefix(auth, "Basic ") 37 | decoded, err := base64.StdEncoding.DecodeString(raw) 38 | if err != nil { 39 | unauthorized(ctx) 40 | return 41 | } 42 | 43 | parts := strings.SplitN(string(decoded), ":", 2) 44 | if len(parts) != 2 { 45 | unauthorized(ctx) 46 | return 47 | } 48 | 49 | if !secureEquals(parts[0], user) || !secureEquals(parts[1], pass) { 50 | unauthorized(ctx) 51 | return 52 | } 53 | 54 | // ✔ 鉴权通过 55 | // 可以把用户塞到 context,后续 controller 可用 56 | ctx.Input.SetData("basic_auth_user", parts[0]) 57 | } 58 | } 59 | 60 | func unauthorized(ctx *beego.Context) { 61 | ctx.Output.Header("WWW-Authenticate", `Basic realm="Douyin API", charset="UTF-8"`) 62 | ctx.Output.SetStatus(http.StatusUnauthorized) 63 | ctx.Output.Body([]byte("unauthorized")) 64 | } 65 | 66 | func secureEquals(a, b string) bool { 67 | if len(a) != len(b) { 68 | return false 69 | } 70 | var v byte 71 | for i := 0; i < len(a); i++ { 72 | v |= a[i] ^ b[i] 73 | } 74 | return v == 0 75 | } 76 | -------------------------------------------------------------------------------- /qiniu/qiuniu.go: -------------------------------------------------------------------------------- 1 | package qiniu 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/qiniu/go-sdk/v7/auth/qbox" 7 | "github.com/qiniu/go-sdk/v7/storage" 8 | ) 9 | 10 | type Bucket struct { 11 | mac *qbox.Mac 12 | Zone *storage.Zone 13 | } 14 | 15 | func NewBucket(accessKey, secretKey string) *Bucket { 16 | if accessKey == "" || secretKey == "" { 17 | return nil 18 | } 19 | mac := qbox.NewMac(accessKey, secretKey) 20 | 21 | return &Bucket{ 22 | mac: mac, 23 | Zone: &storage.ZoneHuanan, 24 | } 25 | } 26 | 27 | func (b *Bucket) UploadFile(bucket string, key string, localFile string) error { 28 | putPolicy := storage.PutPolicy{ 29 | Scope: bucket, 30 | } 31 | upToken := putPolicy.UploadToken(b.mac) 32 | cfg := storage.Config{} 33 | // 空间对应的机房 34 | cfg.Zone = b.Zone 35 | // 是否使用https域名 36 | cfg.UseHTTPS = true 37 | // 上传是否使用CDN上传加速 38 | cfg.UseCdnDomains = false 39 | // 构建表单上传的对象 40 | formUploader := storage.NewFormUploader(&cfg) 41 | ret := storage.PutRet{} 42 | // 可选配置 43 | putExtra := storage.PutExtra{ 44 | Params: map[string]string{ 45 | "x:name": key, 46 | }, 47 | } 48 | err := formUploader.PutFile(context.Background(), &ret, upToken, key, localFile, &putExtra) 49 | if err != nil { 50 | return err 51 | } 52 | return nil 53 | } 54 | func (b *Bucket) Upload(bucket string, key string, data []byte) error { 55 | putPolicy := storage.PutPolicy{ 56 | Scope: bucket, 57 | } 58 | upToken := putPolicy.UploadToken(b.mac) 59 | cfg := storage.Config{} 60 | // 空间对应的机房 61 | cfg.Zone = b.Zone 62 | // 是否使用https域名 63 | cfg.UseHTTPS = true 64 | // 上传是否使用CDN上传加速 65 | cfg.UseCdnDomains = false 66 | formUploader := storage.NewFormUploader(&cfg) 67 | ret := storage.PutRet{} 68 | putExtra := storage.PutExtra{ 69 | Params: map[string]string{ 70 | "x:name": key, 71 | }, 72 | } 73 | dataLen := int64(len(data)) 74 | err := formUploader.Put(context.Background(), &ret, upToken, key, bytes.NewReader(data), dataLen, &putExtra) 75 | if err != nil { 76 | return err 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /admin/models/baidu.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/beego/beego/v2/client/orm" 6 | "time" 7 | ) 8 | 9 | type BaiduUser struct { 10 | BaiduId int `orm:"column(baidu_id);pk;description(百度网盘Id)"` 11 | BaiduName string `orm:"column(baidu_name);size(255);description(百度账号)"` 12 | NetdiskName string `orm:"column(netdisk_name);size(255);description(百度网盘账号)"` 13 | AvatarUrl string `orm:"column(avatar_url);size(2000);null;description(头像地址)"` 14 | VipType int `orm:"column(vip_type);description(会员类型)"` 15 | AccessToken string `orm:"column(access_token);size(500);description(授权码)"` 16 | ExpiresIn int64 `orm:"column(expires_in);default(0);description(过期时间,单位秒)"` 17 | RefreshToken string `orm:"column(refresh_token);size(500);description(刷新access_token的token)"` 18 | Scope string `orm:"column(scope);size(1000);description(用户授权的权限)"` 19 | Created time.Time `orm:"column(created);auto_now_add;type(datetime);description(创建时间)"` 20 | Updated time.Time `orm:"column(updated);auto_now;type(datetime);description(修改时间)"` 21 | RefreshTokenCreateAt time.Time `orm:"column(refresh_token_create_at);auto_now_add;type(datetime);description(刷新access_token的时间)"` 22 | } 23 | 24 | func (b *BaiduUser) TableName() string { 25 | return "baidu_tokens" 26 | } 27 | 28 | func NewBaiduToken() *BaiduUser { 29 | return &BaiduUser{} 30 | } 31 | 32 | func (b *BaiduUser) First(baiduId int) (*BaiduUser, error) { 33 | o := orm.NewOrm() 34 | 35 | err := o.QueryTable(b.TableName()).Filter("baidu_id", baiduId).One(b) 36 | return b, err 37 | } 38 | 39 | func (b *BaiduUser) Save() (err error) { 40 | o := orm.NewOrm() 41 | if o.QueryTable(b.TableName()).Filter("baidu_id", b.BaiduId).Exist() { 42 | _, err = o.Update(b, "vip_type", "access_token", "expires_in", "refresh_token", "scope", "updated", "refresh_token_create_at") 43 | } else { 44 | _, err = o.Insert(b) 45 | } 46 | return 47 | } 48 | 49 | func (b *BaiduUser) String() string { 50 | body, _ := json.Marshal(b) 51 | return string(body) 52 | } 53 | func init() { 54 | orm.RegisterModel(new(BaiduUser)) 55 | } 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | # push: 5 | # branches: [ "main" ] 6 | # pull_request: 7 | # branches: [ "main" ] 8 | release: 9 | types: [created] 10 | 11 | env: 12 | # Use docker.io for Docker Hub if empty 13 | REGISTRY: '' 14 | # github.repository as / 15 | IMAGE_NAME: ${{ github.repository }} 16 | jobs: 17 | 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Get Release Info 24 | run: | 25 | { 26 | echo "RELEASE_TAG=${GITHUB_REF/refs\/tags\//}" 27 | echo "REPOSITORY_NAME=${GITHUB_REPOSITORY#*/}" 28 | echo "OS_NAME=${{ matrix.goos }}" 29 | } >> "$GITHUB_ENV" 30 | - uses: actions/checkout@v3 31 | with: 32 | submodules: 'true' 33 | - name: Set up QEMU 34 | uses: docker/setup-qemu-action@v2 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v2 37 | - name: Login to DockerHub 38 | if: github.event_name != 'pull_request' 39 | uses: docker/login-action@v2 40 | with: 41 | registry: ${{env.REGISTRY}} 42 | username: ${{ secrets.DOCKERHUB_USERNAME }} 43 | password: ${{ secrets.DOCKERHUB_TOKEN }} 44 | - name: Get current date # get the date of the build 45 | id: date 46 | run: echo "::set-output name=date::$(date +'%Y-%m-%d--%M-%S')" 47 | - name: Build the Docker image 48 | run: docker build . --file Dockerfile --tag lifei6671/douyinbot:${{ env.RELEASE_TAG }} 49 | - name: Extract Docker metadata 50 | id: meta 51 | uses: docker/metadata-action@v4 52 | with: 53 | images: ${{ env.IMAGE_NAME }} 54 | tags: | 55 | # set latest tag for default branch 56 | type=raw,value=latest,enable={{is_default_branch}} 57 | # tag event 58 | type=ref,enable=true,priority=600,prefix=,suffix=,event=tag 59 | - name: Push Docker Image 60 | uses: docker/build-push-action@v3 61 | with: 62 | context: . 63 | push: ${{ github.event_name != 'pull_request' }} 64 | platforms: linux/amd64,linux/arm64 65 | tags: ${{ steps.meta.outputs.tags }} 66 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /admin/controllers/tag.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "math" 5 | "net/http" 6 | "strconv" 7 | "time" 8 | 9 | "github.com/beego/beego/v2/core/logs" 10 | "github.com/beego/beego/v2/server/web" 11 | 12 | "github.com/lifei6671/douyinbot/admin/models" 13 | "github.com/lifei6671/douyinbot/internal/utils" 14 | ) 15 | 16 | type TagController struct { 17 | web.Controller 18 | } 19 | 20 | func (c *TagController) Index() { 21 | if err := utils.IfLastModified(c.Ctx.Input, time.Now()); err == nil { 22 | c.Abort(strconv.Itoa(http.StatusNotModified)) 23 | return 24 | } 25 | page := c.Ctx.Input.Param(":page") 26 | tagID := c.Ctx.Input.Param(":tag_id") 27 | pageIndex := 1 28 | if page != "" { 29 | if num, err := strconv.Atoi(page); err == nil { 30 | if num <= 0 { 31 | c.Abort("404") 32 | return 33 | } 34 | pageIndex = int(math.Max(float64(num), float64(pageIndex))) 35 | } 36 | } 37 | if tagID == "" { 38 | c.Abort("404") 39 | return 40 | } 41 | list, tagName, total, err := models.NewDouYinTag().GetList(pageIndex, tagID) 42 | if err != nil { 43 | logs.Error("获取数据列表失败 -> +%+v", err) 44 | } 45 | 46 | if len(list) > 0 { 47 | c.Data["Nickname"] = tagName 48 | 49 | for i, video := range list { 50 | if desc, err := models.NewDouYinTag().FormatTagHtml(video.Desc); err == nil { 51 | video.Desc = desc 52 | list[i] = video 53 | } else { 54 | logs.Error("渲染标签失败 ->%d - %+v", video.Id, err) 55 | } 56 | } 57 | } 58 | c.Data["List"] = list 59 | totalPage := int(math.Ceil(float64(total) / float64(models.PageSize))) 60 | 61 | if pageIndex <= 1 { 62 | c.Data["Previous"] = "#" 63 | c.Data["First"] = "#" 64 | } else { 65 | c.Data["Previous"] = c.URLFor("TagController.Index", ":tag_id", tagID, ":page", pageIndex-1) 66 | c.Data["First"] = c.URLFor("TagController.Index", ":tag_id", tagID, ":page", 1) 67 | } 68 | if pageIndex >= totalPage { 69 | c.Data["Next"] = "#" 70 | c.Data["Last"] = "#" 71 | } else { 72 | c.Data["Next"] = c.URLFor("TagController.Index", ":tag_id", tagID, ":page", pageIndex+1) 73 | c.Data["Last"] = c.URLFor("TagController.Index", ":tag_id", tagID, ":page", totalPage) 74 | } 75 | utils.CacheHeader(c.Ctx.Output, time.Now(), 3600, 86400) 76 | 77 | c.TplName = "index/list.gohtml" 78 | } 79 | -------------------------------------------------------------------------------- /admin/static/css/video.css: -------------------------------------------------------------------------------- 1 | header { 2 | position: fixed; 3 | top: 0; 4 | width: 100%; 5 | z-index: 9999; 6 | } 7 | 8 | main { 9 | margin-top: 50px; 10 | } 11 | 12 | .page { 13 | /*width: 100%;*/ 14 | width: 90vh; 15 | height: 90vh; 16 | overflow: auto; 17 | position: relative; 18 | transition: transform 0.6s ease-in-out; 19 | margin: 0 auto; 20 | } 21 | 22 | .video-bg { 23 | position: absolute; 24 | top: 50%; 25 | left: 50%; 26 | transform: translate(-50%, -50%); 27 | /*min-width: 100%;*/ 28 | /*min-height: 80%;*/ 29 | width: 90vh; 30 | height: 90vh; 31 | z-index: 1; 32 | background-color: black; 33 | } 34 | 35 | .video-controls { 36 | position: absolute; 37 | bottom: 100px; 38 | left: 20px; 39 | width: 200px; 40 | z-index: 2; 41 | } 42 | 43 | .progress-bar { 44 | width: 100%; 45 | height: 3px; 46 | background: rgba(255, 255, 255, 0.3); 47 | margin-bottom: 10px; 48 | } 49 | 50 | .progress { 51 | height: 100%; 52 | background: #fff; 53 | transition: width 0.1s linear; 54 | } 55 | 56 | .play-btn { 57 | background: none; 58 | border: 1px solid #fff; 59 | color: white; 60 | padding: 5px 15px; 61 | border-radius: 15px; 62 | cursor: pointer; 63 | } 64 | 65 | .content { 66 | position: absolute; 67 | left: 20px; 68 | bottom: 60px; 69 | color: white; 70 | z-index: 1; 71 | max-width: 600px; 72 | } 73 | 74 | .author { 75 | font-weight: bold; 76 | margin-bottom: 5px; 77 | font-size: 1.2rem; 78 | } 79 | 80 | .quote { 81 | font-size: 1.1rem; 82 | line-height: 1.5; 83 | opacity: 0.9; 84 | height: 70px; 85 | display: -webkit-box; 86 | -webkit-line-clamp: 2; 87 | -webkit-box-orient: vertical; 88 | overflow: hidden; 89 | text-overflow: ellipsis; 90 | } 91 | 92 | #issue-info { 93 | position: fixed; 94 | left: 20px; 95 | bottom: 20px; 96 | color: white; 97 | z-index: 1000; 98 | font-size: 0.9rem; 99 | opacity: 0.7; 100 | } 101 | 102 | a { 103 | color: white; 104 | text-decoration: none; 105 | } 106 | 107 | a:hover { 108 | color: white; 109 | } -------------------------------------------------------------------------------- /admin/views/index/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{if .PageIndex}}第{{.PageIndex}}页-{{else}}首页-{{end}}抖音无水印工具-抖音无水印-抖音型男集锦 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 35 |
36 | 37 | 38 |
39 |
40 |
41 | {{template "index/main.gohtml" .}} 42 |
43 |
44 | {{template "index/video.gohtml" .}} 45 |
46 | {{template "index/script.gohtml" .}} 47 | 48 | 49 | -------------------------------------------------------------------------------- /admin/views/index/main.gohtml: -------------------------------------------------------------------------------- 1 |
2 | {{range .List}} 3 |
4 |
5 |
6 | 7 | {{.Nickname}} 8 | 9 |
10 |
11 |

12 | {{if $.Nickname}} 13 | {{else}} 14 | @{{.Nickname}} 16 | {{end}} 17 | {{if .Desc}} 18 | {{str2html .Desc}}. 19 | {{end}} 20 |

21 |
22 |
23 | 24 | 来源 25 |
26 | {{dateformat .Created "2006-01-02 15:04:05"}} 27 |
28 |
29 |
30 |
31 | {{else}} 32 |

没有数据

33 | {{end}} 34 |
35 |
36 | 44 |
-------------------------------------------------------------------------------- /admin/static/css/main.css: -------------------------------------------------------------------------------- 1 | body { 2 | position: relative; 3 | font-family: Helvetica, -apple-system, "Helvetica Neue", Helvetica, "Segoe UI", Arial, freesans, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Microsoft Yahei", "Helvetica Neue", Helvetica; 4 | } 5 | 6 | .bd-placeholder-img { 7 | font-size: 1.125rem; 8 | text-anchor: middle; 9 | -webkit-user-select: none; 10 | -moz-user-select: none; 11 | -ms-user-select: none; 12 | user-select: none; 13 | } 14 | 15 | @media (min-width: 768px) { 16 | .bd-placeholder-img-lg { 17 | font-size: 3.5rem; 18 | } 19 | } 20 | 21 | .image-content { 22 | height: 425px; 23 | width: 100%; 24 | display: flex; 25 | justify-content: center; 26 | align-items: center; 27 | overflow: hidden 28 | } 29 | 30 | .image-content img { 31 | width: 100%; 32 | vertical-align: middle; 33 | } 34 | 35 | main .card { 36 | height: 570px 37 | } 38 | 39 | main .card .card-body > .card-text { 40 | height: 70px; 41 | display: -webkit-box; 42 | -webkit-line-clamp: 2; 43 | -webkit-box-orient: vertical; 44 | overflow: hidden; 45 | text-overflow: ellipsis; 46 | } 47 | 48 | .search-head { 49 | margin: 10px auto; 50 | padding-bottom: 15px; 51 | line-height: 1.5em; 52 | border-bottom: 3px solid #EEEEEE; 53 | } 54 | 55 | .search-head .search-title { 56 | font-size: 22px; 57 | font-weight: 300; 58 | } 59 | 60 | .image-content { 61 | height: 425px; 62 | width: 100%; 63 | display: flex; 64 | justify-content: center; 65 | align-items: center; 66 | overflow: hidden 67 | } 68 | 69 | .image-content > img { 70 | width: 100%; 71 | vertical-align: middle; 72 | overflow: hidden; 73 | } 74 | 75 | 76 | main .card-body .author > a { 77 | color: #0886E9; 78 | } 79 | 80 | main .card-body .author > a:hover { 81 | text-decoration: none; 82 | } 83 | 84 | .author-desc { 85 | margin: 15px auto; 86 | background-color: white; 87 | /*display: -webkit-box;*/ 88 | /*-webkit-line-clamp: 3;*/ 89 | /*-webkit-box-orient: vertical;*/ 90 | /*overflow: hidden;*/ 91 | /*text-overflow: ellipsis;*/ 92 | /*!*border: 1px solid rgba(0,0,0,.125);*!*/ 93 | /*border: solid #f8f9fa;*/ 94 | /*border-radius:.25rem;*/ 95 | /*padding: 15px 10px;*/ 96 | /*margin-bottom: 10px;*/ 97 | 98 | } 99 | 100 | .author-desc > img { 101 | width: 100px; 102 | height: 100px; 103 | } -------------------------------------------------------------------------------- /admin/service/handler.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "fmt" 5 | "github.com/beego/beego/v2/core/logs" 6 | "github.com/beego/beego/v2/server/web" 7 | "github.com/lifei6671/douyinbot/admin/models" 8 | "github.com/lifei6671/douyinbot/wechat" 9 | "net/url" 10 | "time" 11 | ) 12 | 13 | var ( 14 | handlers = make(map[string]WechatHandler) 15 | ) 16 | 17 | type WechatHandler func(*wechat.TextRequestBody) (wechat.PassiveUserReplyMessage, error) 18 | 19 | func UserRegisterHandler(textRequestBody *wechat.TextRequestBody) (wechat.PassiveUserReplyMessage, error) { 20 | 21 | return wechat.PassiveUserReplyMessage{ 22 | ToUserName: wechat.Value(textRequestBody.FromUserName), 23 | FromUserName: wechat.Value(textRequestBody.ToUserName), 24 | CreateTime: wechat.Value(fmt.Sprintf("%d", time.Now().Unix())), 25 | MsgType: wechat.Value(string(wechat.WeiXinTextMsgType)), 26 | Content: wechat.Value("注册格式: 注册#账号#密码#邮箱地址"), 27 | }, nil 28 | } 29 | 30 | func BindBaiduNetdiskHandler(textRequestBody *wechat.TextRequestBody) (wechat.PassiveUserReplyMessage, error) { 31 | message := wechat.PassiveUserReplyMessage{ 32 | ToUserName: wechat.Value(textRequestBody.FromUserName), 33 | FromUserName: wechat.Value(textRequestBody.ToUserName), 34 | CreateTime: wechat.Value(fmt.Sprintf("%d", time.Now().Unix())), 35 | MsgType: wechat.Value(string(wechat.WeiXinTextMsgType)), 36 | } 37 | 38 | if !web.AppConfig.DefaultBool("baidunetdiskenable", false) { 39 | message.Content = wechat.Value("网站未开启百度网盘接入功能") 40 | return message, nil 41 | } 42 | if !models.NewUser().ExistByWechatId(textRequestBody.FromUserName) { 43 | message.Content = wechat.Value("你不是已注册用户不能绑定百度网盘") 44 | return message, nil 45 | } 46 | registeredUrl := web.AppConfig.DefaultString("baiduregisteredurl", "") 47 | uri, err := url.Parse(registeredUrl) 48 | if err != nil { 49 | logs.Error("解析注册回调地址失败 -> %s - %+v", registeredUrl, err) 50 | message.Content = wechat.Value("生成百度网盘绑定数据失败") 51 | return message, nil 52 | } 53 | message.Content = wechat.Value(uri.Scheme + "://" + uri.Host + "/baidu/authorize?wid=" + textRequestBody.FromUserName) 54 | return message, nil 55 | } 56 | func RegisterHandler(keyword string, handler WechatHandler) { 57 | handlers[keyword] = handler 58 | } 59 | 60 | func GetHandler(keyword string) WechatHandler { 61 | if handler, ok := handlers[keyword]; ok { 62 | return handler 63 | } 64 | return nil 65 | } 66 | 67 | func init() { 68 | handlers["注册"] = UserRegisterHandler 69 | handlers["1"] = UserRegisterHandler 70 | handlers["2"] = BindBaiduNetdiskHandler 71 | 72 | } 73 | -------------------------------------------------------------------------------- /admin/models/user.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/beego/beego/v2/client/orm" 6 | "time" 7 | ) 8 | 9 | type User struct { 10 | Id int `orm:"column(id);auto;pk"` 11 | Account string `orm:"column(account);size(255);unique;description(账号)"` 12 | Password string `orm:"column(password);size(2000);null;description(密码)" json:"-"` 13 | Email string `orm:"column(email);size(255);unique;description(用户邮箱)"` 14 | Avatar string `orm:"column(avatar);default(/static/avatar/default.jpg);size(1000);" json:"avatar"` 15 | WechatId string `orm:"column(wechat_id);size(200);null;description(微信的用户ID)"` 16 | BaiduId int `orm:"column(baidu_id);size(200);null;description(百度网盘用户Id)"` 17 | Status int `orm:"column(status);type(tinyint);default(0);description(用户状态:0=正常/1=禁用/2=删除)" json:"status"` 18 | Created time.Time `orm:"column(created);auto_now_add;type(datetime);description(创建时间)"` 19 | Updated time.Time `orm:"column(updated);auto_now;type(datetime);description(修改时间)"` 20 | } 21 | 22 | func (u *User) TableName() string { 23 | return "users" 24 | } 25 | 26 | func NewUser() *User { 27 | return &User{} 28 | } 29 | 30 | func (u *User) Insert() error { 31 | o := orm.NewOrm() 32 | 33 | if o.QueryTable(u.TableName()).Filter("account", u.Account).Exist() { 34 | return ErrUserAccountExist 35 | } 36 | if o.QueryTable(u.TableName()).Filter("email", u.Email).Exist() { 37 | return ErrUsrEmailExist 38 | } 39 | if u.WechatId != "" && o.QueryTable(u.TableName()).Filter("wechat_id", u.WechatId).Exist() { 40 | return ErrUserWechatIdExist 41 | } 42 | id, err := o.Insert(u) 43 | if err != nil { 44 | return err 45 | } 46 | u.Id = int(id) 47 | return nil 48 | } 49 | 50 | func (u *User) Update(cols ...string) error { 51 | o := orm.NewOrm() 52 | _, err := o.Update(u, cols...) 53 | return err 54 | } 55 | 56 | func (u *User) First(account string) (*User, error) { 57 | o := orm.NewOrm() 58 | cond := orm.NewCondition().And("account", account). 59 | Or("email", account). 60 | Or("wechat_id", account). 61 | Or("baidu_id", account) 62 | 63 | err := o.QueryTable(u.TableName()).SetCond(cond).One(u) 64 | 65 | return u, err 66 | } 67 | 68 | func (u *User) FirstByWechatId(id string) (*User, error) { 69 | err := orm.NewOrm().QueryTable(u.TableName()).Filter("wechat_id", id).One(u) 70 | 71 | return u, err 72 | } 73 | 74 | func (u *User) ExistByWechatId(id string) bool { 75 | return orm.NewOrm().QueryTable(u.TableName()).Filter("wechat_id", id).Exist() 76 | } 77 | func (u *User) String() string { 78 | b, _ := json.Marshal(u) 79 | return string(b) 80 | } 81 | 82 | func init() { 83 | // 需要在init中注册定义的model 84 | orm.RegisterModel(new(User)) 85 | } 86 | -------------------------------------------------------------------------------- /admin/service/download_cover.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "net/url" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/beego/beego/v2/core/logs" 13 | 14 | "github.com/lifei6671/douyinbot/admin/models" 15 | "github.com/lifei6671/douyinbot/internal/utils" 16 | ) 17 | 18 | var ( 19 | ErrDownloadCover = errors.New("download cover error") 20 | ) 21 | var ( 22 | _downloadQueue = make(chan models.DouYinVideo, 100) 23 | ) 24 | 25 | func PushDownloadQueue(video models.DouYinVideo) { 26 | _downloadQueue <- video 27 | } 28 | 29 | func ExecDownloadQueue(videoModel models.DouYinVideo) { 30 | log.Println(videoModel.VideoId, videoModel.VideoLocalCover) 31 | if videoModel.VideoLocalCover == "/cover" || videoModel.VideoLocalCover == "/cover/" { 32 | avatarPath, err := utils.DownloadCover(videoModel.AuthorId, videoModel.VideoCover, savepath) 33 | if err != nil { 34 | var uri *url.URL 35 | uri, err = url.ParseRequestURI(videoModel.VideoCover) 36 | if err != nil { 37 | logs.Error("解析封面文件失败: url[%s] filename[%s] %+v", videoModel.VideoCover, err) 38 | return 39 | } 40 | if !strings.HasPrefix(uri.Host, "p5-ipv6") { 41 | uri.Host = "p5-ipv6.douyinpic.com" 42 | } 43 | avatarPath, err = utils.DownloadCover(videoModel.AuthorId, uri.String(), savepath) 44 | 45 | if err == nil { 46 | videoModel.VideoCover = uri.String() 47 | } 48 | videoModel.VideoLocalCover = "/static/images/default.jpg" 49 | } 50 | if err != nil { 51 | logs.Error("下载视频封面失败: url[%s] filename[%s] %+v", videoModel.VideoCover, err) 52 | } 53 | if err == nil { 54 | videoModel.VideoLocalCover = "/cover" + strings.ReplaceAll("/"+strings.TrimPrefix(avatarPath, savepath), "//", "/") 55 | 56 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 57 | defer cancel() 58 | // 将封面上传到S3服务器 59 | if urlStr, err := uploadFile(ctx, avatarPath); err == nil { 60 | videoModel.VideoLocalCover = urlStr 61 | } 62 | } 63 | } else if strings.HasPrefix(videoModel.VideoLocalCover, "/cover/") { 64 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) 65 | defer cancel() 66 | coverPath := filepath.Join(savepath, strings.TrimPrefix(videoModel.VideoLocalCover, "/cover")) 67 | log.Println(coverPath) 68 | if utils.FileExists(coverPath) { 69 | // 将封面上传到S3服务器 70 | if urlStr, err := uploadFile(ctx, coverPath); err == nil { 71 | videoModel.VideoLocalCover = urlStr 72 | } else { 73 | logs.Error("下载视频封面失败: url[%s] filename[%s] %+v", coverPath, err) 74 | } 75 | } 76 | } 77 | 78 | if err := videoModel.Save(); err != nil { 79 | logs.Error("下载视频封面失败 -> [video_id=%s] %+v", videoModel.VideoId, err) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /admin/views/index/list.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | {{.Nickname}}-{{if .PageIndex}}第{{.PageIndex}}页-{{end}}抖音无水印-抖音型男集锦 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 | 22 | 35 |
36 | 37 | 38 |
39 |
40 |
41 |
42 | 显示"{{.Nickname}}"的视频列表 43 |
44 | {{if .Desc}} 45 |
46 | {{.Nickname}} 47 |
48 |
{{.Nickname}}
49 |

{{.Desc}}

50 |
51 |
52 | {{end}} 53 | {{template "index/main.gohtml" .}} 54 |
55 |
56 | {{template "index/video.gohtml" .}} 57 |
58 | {{template "index/script.gohtml" .}} 59 | 60 | 61 | -------------------------------------------------------------------------------- /admin/models/douyin_cover.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "github.com/beego/beego/v2/client/orm" 5 | "github.com/beego/beego/v2/core/logs" 6 | "golang.org/x/exp/rand" 7 | "net/url" 8 | "strconv" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type DouYinCover struct { 14 | Id int `orm:"column(id);auto;pk"` 15 | VideoId string `orm:"column(video_id);size(255);unique;description(视频唯一ID)"` 16 | Cover string `orm:"column(cover);size(255);description(第一张封面)"` 17 | CoverImage string `orm:"column(cover_image);size(2000);description(封面地址)"` 18 | Expires int `orm:"column(expires);description(封面有效期)"` 19 | Status int `orm:"column(status);default(0);description(状态:0=有效,1=无效)"` 20 | Created time.Time `orm:"auto_now_add;type(datetime);description(创建时间)"` 21 | } 22 | 23 | func NewDouYinCover() *DouYinCover { 24 | return new(DouYinCover) 25 | } 26 | func (d *DouYinCover) TableName() string { 27 | return "douyin_cover" 28 | } 29 | 30 | // Save 更新或插入封面信息 31 | func (d *DouYinCover) Save(videoId string) error { 32 | if len(d.CoverImage) == 0 { 33 | return nil 34 | } 35 | o := orm.NewOrm() 36 | 37 | if d.Expires == 0 { 38 | uri, err := url.ParseRequestURI(d.Cover) 39 | if err != nil { 40 | return err 41 | } 42 | if v := uri.Query().Get("x-expires"); v != "" { 43 | expire, err := strconv.Atoi(v) 44 | if err != nil { 45 | return err 46 | } 47 | d.Expires = expire 48 | } 49 | } 50 | var cover DouYinCover 51 | err := o.QueryTable(d.TableName()).Filter("video_id", videoId).One(&cover) 52 | if err == orm.ErrNoRows { 53 | _, err = o.Insert(d) 54 | } else if err == nil { 55 | d.Id = cover.Id 56 | _, err = o.Update(d) 57 | } 58 | 59 | return err 60 | } 61 | 62 | // CoverFirst 获取视频的地址 63 | func (d *DouYinCover) CoverFirst(videoId string) (string, error) { 64 | o := orm.NewOrm() 65 | var cover *DouYinCover 66 | err := o.QueryTable(d.TableName()).Filter("video_id", videoId).One(&cover) 67 | if err != nil { 68 | return "", err 69 | } 70 | covers := strings.Split(cover.CoverImage, "|") 71 | if len(covers) > 0 { 72 | return covers[rand.Intn(len(covers))], nil 73 | } 74 | return "", orm.ErrNoRows 75 | } 76 | 77 | // GetExpireList 获取临近过期的封面 78 | func (d *DouYinCover) GetExpireList() ([]DouYinCover, error) { 79 | o := orm.NewOrm() 80 | var covers []DouYinCover 81 | 82 | _, err := o.QueryTable(d.TableName()).Filter("expires__gt", 0).Filter("expires__lte", time.Now().Unix()+600).Filter("status", 0).All(&covers) 83 | if err != nil { 84 | logs.Error("查询过期封面失败: %+v", err) 85 | } 86 | return covers, err 87 | } 88 | 89 | func (d *DouYinCover) SetStatus(videoId string, status int) error { 90 | o := orm.NewOrm() 91 | _, err := o.QueryTable(d.TableName()).Filter("video_id", videoId).Update(orm.Params{ 92 | "status": status, 93 | }) 94 | if err != nil { 95 | logs.Error("更新封面状态失败:video_id[%s] %+v", videoId, err) 96 | } 97 | return err 98 | } 99 | func init() { 100 | rand.Seed(uint64(time.Now().UnixNano())) 101 | // 需要在init中注册定义的model 102 | orm.RegisterModel(new(DouYinCover)) 103 | } 104 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lifei6671/douyinbot 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.41.0 7 | github.com/aws/aws-sdk-go-v2/config v1.32.6 8 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 9 | github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 10 | github.com/beego/beego/v2 v2.3.8 11 | github.com/chai2010/webp v1.4.0 12 | github.com/gabriel-vasile/mimetype v1.4.12 13 | github.com/go-resty/resty/v2 v2.16.3 14 | github.com/lifei6671/fink-download v0.0.1 15 | github.com/mattn/go-sqlite3 v1.14.27 16 | github.com/qiniu/go-sdk/v7 v7.9.5 17 | github.com/smartystreets/goconvey v1.8.1 18 | github.com/tidwall/gjson v1.17.0 19 | go.uber.org/automaxprocs v1.4.0 20 | golang.org/x/crypto v0.45.0 21 | golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 22 | golang.org/x/image v0.23.0 23 | ) 24 | 25 | require ( 26 | github.com/PuerkitoBio/goquery v1.11.0 // indirect 27 | github.com/andybalholm/cascadia v1.3.3 // indirect 28 | github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect 29 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 32 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 33 | github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 37 | github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect 38 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 39 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 40 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 42 | github.com/aws/smithy-go v1.24.0 // indirect 43 | github.com/beorn7/perks v1.0.1 // indirect 44 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 45 | github.com/gopherjs/gopherjs v1.17.2 // indirect 46 | github.com/hashicorp/golang-lru v0.5.4 // indirect 47 | github.com/jtolds/gls v4.20.0+incompatible // indirect 48 | github.com/kr/text v0.2.0 // indirect 49 | github.com/mitchellh/mapstructure v1.5.0 // indirect 50 | github.com/prometheus/client_golang v1.19.0 // indirect 51 | github.com/prometheus/client_model v0.5.0 // indirect 52 | github.com/prometheus/common v0.48.0 // indirect 53 | github.com/prometheus/procfs v0.12.0 // indirect 54 | github.com/shiena/ansicolor v0.0.0-20200904210342-c7312218db18 // indirect 55 | github.com/smarty/assertions v1.15.0 // indirect 56 | github.com/tidwall/match v1.1.1 // indirect 57 | github.com/tidwall/pretty v1.2.1 // indirect 58 | github.com/valyala/bytebufferpool v1.0.0 // indirect 59 | golang.org/x/net v0.47.0 // indirect 60 | golang.org/x/sync v0.18.0 // indirect 61 | golang.org/x/sys v0.38.0 // indirect 62 | golang.org/x/text v0.31.0 // indirect 63 | google.golang.org/protobuf v1.34.2 // indirect 64 | gopkg.in/yaml.v3 v3.0.1 // indirect 65 | ) 66 | -------------------------------------------------------------------------------- /admin/controllers/video.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/beego/beego/v2/client/cache" 11 | "github.com/beego/beego/v2/core/logs" 12 | "github.com/beego/beego/v2/server/web" 13 | "github.com/tidwall/gjson" 14 | 15 | "github.com/lifei6671/douyinbot/admin/models" 16 | "github.com/lifei6671/douyinbot/douyin" 17 | ) 18 | 19 | var ( 20 | defaultVideoUrl = "https://api.amemv.com/aweme/v1/play/?video_id=v0200f480000br2flq7iv420dp6l9js0&ratio=480p&line=1" 21 | defaultVideoContent []byte 22 | bm, _ = cache.NewCache("memory", `{"interval":60}`) 23 | ) 24 | 25 | type VideoController struct { 26 | web.Controller 27 | } 28 | 29 | func (c *VideoController) Index() { 30 | videoId := c.Ctx.Input.Query("video_id") 31 | if videoId == "" { 32 | c.sendFile("") 33 | return 34 | } 35 | 36 | video, err := models.NewDouYinVideo().FirstByVideoId(videoId) 37 | if err != nil { 38 | c.sendFile("") 39 | return 40 | } 41 | dir := web.AppConfig.DefaultString("auto-save-path", "") 42 | if dir == "" { 43 | c.sendFile("") 44 | logs.Warn("没有配置本地储存路径 -> %s", videoId) 45 | return 46 | } 47 | filename := filepath.Join(dir, video.VideoLocalAddr) 48 | c.sendFile(filename) 49 | } 50 | 51 | func (c *VideoController) Play() { 52 | videoId := c.Ctx.Input.Query("video_id") 53 | if videoId == "" { 54 | c.Ctx.Abort(404, "param err") 55 | return 56 | } 57 | cid, err := bm.Get(context.Background(), videoId) 58 | if err == nil { 59 | c.Ctx.Redirect(301, cid.(string)) 60 | c.StopRun() 61 | } 62 | 63 | video, err := models.NewDouYinVideo().FirstByVideoId(videoId) 64 | if err != nil { 65 | c.Ctx.Abort(404, "param err") 66 | return 67 | } 68 | if len(video.AwemeId) == 0 { 69 | c.Ctx.Abort(404, "") 70 | return 71 | } 72 | dy := douyin.NewDouYin( 73 | web.AppConfig.DefaultString("douyinproxy", ""), 74 | web.AppConfig.DefaultString("douyinproxyusername", ""), 75 | web.AppConfig.DefaultString("douyinproxypassword", ""), 76 | ) 77 | b, err := dy.GetVideoInfo(video.VideoRawPlayAddr) 78 | if err != nil { 79 | logs.Error(err) 80 | c.Ctx.Abort(500, "get video failed") 81 | } 82 | playURL := gjson.Get(b, "video_data.wm_video_url_HQ").String() 83 | if len(playURL) > 0 { 84 | _ = bm.Put(context.Background(), videoId, playURL, time.Hour*24) 85 | } 86 | c.Ctx.Redirect(301, playURL) 87 | c.StopRun() 88 | } 89 | 90 | func (c *VideoController) sendFile(filename string) { 91 | if stat, err := os.Stat(filename); os.IsNotExist(err) || stat.IsDir() { 92 | logs.Warn("文件不存在 -> %s", filename) 93 | if defaultVideoContent == nil || len(defaultVideoContent) == 0 { 94 | c.Redirect(defaultVideoUrl, http.StatusFound) 95 | } else { 96 | c.Ctx.Output.Header("Content-Type", "video/mp4") 97 | _ = c.Ctx.Output.Body(defaultVideoContent) 98 | } 99 | return 100 | } 101 | 102 | c.Ctx.Output.Header("Content-Type", "video/mp4") 103 | http.ServeFile(c.Ctx.ResponseWriter, c.Ctx.Request, filename) 104 | c.StopRun() 105 | } 106 | func SetDefaultVideoContent(body []byte) { 107 | defaultVideoContent = body 108 | } 109 | -------------------------------------------------------------------------------- /admin/static/css/video-index.css: -------------------------------------------------------------------------------- 1 | /* 新增加载提示样式 */ 2 | .loading-indicator { 3 | position: fixed; 4 | bottom: 30px; 5 | left: 50%; 6 | transform: translateX(-50%); 7 | background: rgba(0, 0, 0, 0.7); 8 | color: white; 9 | padding: 10px 20px; 10 | border-radius: 20px; 11 | display: none; 12 | z-index: 100; 13 | } 14 | 15 | .error-indicator { 16 | background: rgba(255, 0, 0, 0.7); 17 | } 18 | 19 | * { 20 | margin: 0; 21 | padding: 0; 22 | box-sizing: border-box; 23 | } 24 | 25 | body { 26 | overflow: hidden; 27 | font-family: Arial, sans-serif; 28 | overscroll-behavior-y: none; 29 | -webkit-overflow-scrolling: auto; 30 | } 31 | 32 | .video-container { 33 | position: relative; 34 | height: 100vh; 35 | width: 100%; 36 | overflow: hidden; 37 | } 38 | 39 | .video-wrapper { 40 | position: absolute; 41 | width: 100%; 42 | /*transition: transform 0.5s ease-in-out;*/ 43 | transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); 44 | } 45 | 46 | .swipe-active { 47 | transition: none !important; 48 | } 49 | 50 | .video-item { 51 | position: relative; 52 | height: 100vh; 53 | height: calc(var(--vh, 1vh) * 100); 54 | width: 100%; 55 | display: flex; 56 | flex-direction: column; 57 | justify-content: flex-end; 58 | } 59 | 60 | video { 61 | position: absolute; 62 | top: 0; 63 | left: 0; 64 | width: 100%; 65 | height: 100%; 66 | object-fit: scale-down; 67 | z-index: 1; 68 | background-color: black; 69 | display: flex; 70 | } 71 | 72 | .video-info { 73 | color: white; 74 | z-index: 2; 75 | position: absolute; 76 | bottom: 80px; 77 | left: 1.25rem; 78 | } 79 | 80 | .video-back { 81 | color: white; 82 | z-index: 2; 83 | position: absolute; 84 | top: 10px; 85 | left: 1.25rem; 86 | } 87 | 88 | .video-back .back-a { 89 | display: flex; 90 | width: 54px; 91 | height: 54px; 92 | background-color: rgba(0, 0, 0, .18); 93 | border: 1px solid rgba(255, 255, 255, .15); 94 | border-radius: 32px; 95 | justify-content: center; 96 | align-items: center; 97 | font-size: 24px; 98 | } 99 | 100 | .semi-icon { 101 | text-align: center; 102 | text-transform: none; 103 | text-rendering: optimizelegibility; 104 | fill: currentColor; 105 | font-style: normal; 106 | line-height: 0; 107 | display: inline-block 108 | } 109 | 110 | .video-back .semi-icon > svg { 111 | vertical-align: middle; 112 | } 113 | 114 | .video-title { 115 | font-size: 1.5em; 116 | margin-bottom: 10px; 117 | text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5); 118 | } 119 | 120 | .video-description { 121 | font-size: 1em; 122 | opacity: 0.9; 123 | } 124 | 125 | video::-webkit-media-controls-panel { 126 | display: flex !important; 127 | opacity: 1 !important; 128 | } 129 | 130 | video::-webkit-media-controls-timeline { 131 | display: flex !important; 132 | } 133 | 134 | /* 禁止控制条自动隐藏(仅部分浏览器支持) */ 135 | video::-webkit-media-controls { 136 | transition: none !important; 137 | visibility: visible !important; 138 | } 139 | 140 | a { 141 | color: white; 142 | text-decoration: none; 143 | } 144 | 145 | a:hover { 146 | color: white; 147 | } -------------------------------------------------------------------------------- /admin/service/baidu.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/beego/beego/v2/client/cache" 8 | "github.com/beego/beego/v2/core/logs" 9 | "github.com/beego/beego/v2/server/web" 10 | "github.com/lifei6671/douyinbot/admin/models" 11 | "github.com/lifei6671/douyinbot/baidu" 12 | "time" 13 | ) 14 | 15 | var ( 16 | baiduAppId = web.AppConfig.DefaultString("baiduappid", "") 17 | baiduAppKey = web.AppConfig.DefaultString("baiduappkey", "") 18 | baiduSecretKey = web.AppConfig.DefaultString("baidusecretkey", "") 19 | baiduSignKey = web.AppConfig.DefaultString("baidusignkey", "") 20 | baiduCache = cache.NewMemoryCache() 21 | ) 22 | 23 | func uploadBaiduNetdisk(ctx context.Context, baiduId int, filename string, remoteName string) (*baidu.CreateFile, error) { 24 | key := fmt.Sprintf("baidu::%d", baiduId) 25 | val, _ := baiduCache.Get(ctx, key) 26 | bd, ok := val.(*baidu.NetDisk) 27 | if !ok || bd == nil { 28 | token, err := models.NewBaiduToken().First(baiduId) 29 | if err != nil { 30 | return nil, fmt.Errorf("用户未绑定百度网盘:[baiduid=%d] - %w", baiduId, err) 31 | } 32 | bd = baidu.NewNetDisk(baiduAppId, baiduAppKey, baiduSecretKey, baiduSignKey) 33 | bd.SetAccessToken(&baidu.TokenResponse{ 34 | AccessToken: token.AccessToken, 35 | ExpiresIn: token.ExpiresIn, 36 | RefreshToken: token.RefreshToken, 37 | Scope: token.Scope, 38 | CreateAt: token.Created.Unix(), 39 | RefreshTokenCreateAt: token.RefreshTokenCreateAt.Unix(), 40 | }) 41 | bd.IsDebug(true) 42 | _ = bd.RefreshToken(false) 43 | 44 | _ = baiduCache.Put(ctx, key, bd, time.Duration(token.ExpiresIn)*time.Second) 45 | } else { 46 | _ = bd.RefreshToken(false) 47 | } 48 | 49 | uploadFile, err := baidu.NewPreCreateUploadFileParam(filename, remoteName) 50 | if err != nil { 51 | logs.Error("预创建文件失败 -> [filename=%s] ; %+v", remoteName, err) 52 | return nil, fmt.Errorf("预创建文件失败 -> [filename=%s] ; %w", remoteName, err) 53 | } 54 | logs.Info("开始预创建文件 ->%s", uploadFile) 55 | preUploadFile, err := bd.PreCreate(uploadFile) 56 | if err != nil { 57 | logs.Error("预创建文件失败 -> [filename=%s] ; %+v", remoteName, err) 58 | return nil, fmt.Errorf("预创建文件失败 -> [filename=%s] ; %w", remoteName, err) 59 | } 60 | logs.Info("开始分片上传文件 -> %s", preUploadFile) 61 | 62 | superFiles, err := bd.UploadFile(preUploadFile, filename) 63 | if err != nil { 64 | logs.Error("创建文件失败 -> [filename=%s] ; %+v", remoteName, err) 65 | return nil, fmt.Errorf("创建文件失败 -> [filename=%s] ; %w", remoteName, err) 66 | } 67 | b, _ := json.Marshal(&superFiles) 68 | logs.Info("分片上传成功 -> %s", string(b)) 69 | 70 | param := baidu.NewCreateFileParam(remoteName, uploadFile.Size, false) 71 | param.BlockList = make([]string, len(superFiles)) 72 | param.UploadId = preUploadFile.UploadId 73 | //文件命名策略,默认1 74 | //0 为不重命名,返回冲突 75 | //1 为只要path冲突即重命名 76 | //2 为path冲突且block_list不同才重命名 77 | //3 为覆盖 78 | param.RType = 3 79 | param.Path = "/apps/DouYinBot" + param.Path 80 | 81 | for i, f := range superFiles { 82 | param.BlockList[i] = f.Md5 83 | } 84 | logs.Info("最终合并文件 -> %s", param) 85 | createFile, err := bd.CreateFile(param) 86 | if err != nil { 87 | logs.Error("创建文件失败 -> [filename=%s] ; %+v", remoteName, err) 88 | return nil, fmt.Errorf("创建文件失败 -> [filename=%s] ; %w", remoteName, err) 89 | } 90 | logs.Info("百度网盘上传成功 -> %s", createFile) 91 | return createFile, nil 92 | } 93 | -------------------------------------------------------------------------------- /internal/utils/download.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | 15 | "github.com/beego/beego/v2/core/logs" 16 | ) 17 | 18 | var DefaultUserAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0` 19 | var ErrAnimatedWebP = errors.New("animated webp") 20 | 21 | // DownloadCover 下载封面到指定路径 22 | func DownloadCover(authorId, urlStr, filename string) (string, error) { 23 | uri, err := url.ParseRequestURI(urlStr) 24 | if err != nil { 25 | logs.Error("解析封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 26 | return "", err 27 | } 28 | 29 | hash := md5.Sum([]byte(uri.Path)) 30 | hashStr := hex.EncodeToString(hash[:]) 31 | 32 | ext := filepath.Ext(uri.Path) 33 | 34 | filename = filepath.Join(filename, authorId, "cover", hashStr+ext) 35 | 36 | dir := filepath.Dir(filename) 37 | if _, err := os.Stat(dir); os.IsNotExist(err) { 38 | if err := os.MkdirAll(dir, 0755); err != nil { 39 | return "", err 40 | } 41 | } 42 | f, err := os.Create(filename) 43 | if err != nil { 44 | logs.Error("创建封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 45 | return "", err 46 | } 47 | defer SafeClose(f) 48 | 49 | header := http.Header{} 50 | header.Add("Accept", "*/*") 51 | header.Add("Accept-Encoding", "identity;q=1, *;q=0") 52 | header.Add("User-Agent", DefaultUserAgent) 53 | header.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,mt;q=0.5,ru;q=0.4,de;q=0.3") 54 | header.Add("Referer", urlStr) 55 | header.Add("Pragma", "no-cache") 56 | 57 | req, err := http.NewRequest(http.MethodGet, urlStr, nil) 58 | if err != nil { 59 | logs.Error("下载封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 60 | return "", err 61 | } 62 | req.Header = header 63 | resp, err := http.DefaultTransport.RoundTrip(req) 64 | if err != nil { 65 | return "", err 66 | } 67 | defer SafeClose(resp.Body) 68 | if resp.StatusCode != http.StatusOK { 69 | b, _ := io.ReadAll(resp.Body) 70 | return "", fmt.Errorf("http error: status_code[%d] err_msg[%s]", resp.StatusCode, string(b)) 71 | } 72 | _, err = io.Copy(f, resp.Body) 73 | if err != nil { 74 | logs.Error("保存图片失败: %s %+v", urlStr, err) 75 | return "", err 76 | } 77 | if ext == "" { 78 | switch resp.Header.Get("Content-Type") { 79 | case "image/jpeg": 80 | ext = ".jpeg" 81 | case "image/png": 82 | ext = ".png" 83 | case "image/gif": 84 | ext = ".gif" 85 | case "image/webp": 86 | ext = ".webp" 87 | default: 88 | ext = ".jpg" 89 | } 90 | newPath := filename + ext 91 | if ext == ".webp" { 92 | if ok, err := IsAnimatedWebP(filename); ok && err == nil { 93 | _ = os.Remove(filename) 94 | return "", ErrAnimatedWebP 95 | } 96 | } 97 | 98 | if err := os.Rename(filename, newPath); err == nil { 99 | 100 | filename = newPath 101 | } 102 | 103 | } 104 | 105 | if ext != ".webp" { 106 | newPath := strings.TrimSuffix(filename, ext) + ".webp" 107 | if oErr := Image2Webp(filename, newPath); oErr == nil { 108 | _ = os.Remove(filename) 109 | return newPath, nil 110 | } else { 111 | logs.Error("转换 WebP 格式出错: %+v", oErr) 112 | } 113 | } 114 | 115 | logs.Info("保存封面成功: %s %s", urlStr, filename) 116 | return filename, nil 117 | } 118 | -------------------------------------------------------------------------------- /storage/cloudflare.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/tls" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/aws/aws-sdk-go-v2/aws" 13 | "github.com/aws/aws-sdk-go-v2/config" 14 | "github.com/aws/aws-sdk-go-v2/credentials" 15 | "github.com/aws/aws-sdk-go-v2/service/s3" 16 | "github.com/gabriel-vasile/mimetype" 17 | ) 18 | 19 | type Cloudflare struct { 20 | opts *Options 21 | client *s3.Client 22 | } 23 | 24 | func (c *Cloudflare) OpenFile(ctx context.Context, filename string) (*File, error) { 25 | input := &s3.GetObjectInput{} 26 | object, err := c.client.GetObject(ctx, input) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &File{ 31 | ContentLength: object.ContentLength, 32 | ContentType: object.ContentType, 33 | Body: object.Body, 34 | }, nil 35 | } 36 | 37 | func (c *Cloudflare) Delete(ctx context.Context, filename string) error { 38 | 39 | input := &s3.DeleteObjectInput{ 40 | Bucket: aws.String(c.opts.BucketName), 41 | Key: aws.String(filename), 42 | } 43 | _, err := c.client.DeleteObject(ctx, input) 44 | if err != nil { 45 | return err 46 | } 47 | return nil 48 | } 49 | 50 | func (c *Cloudflare) WriteFile(ctx context.Context, r io.Reader, filename string) (string, error) { 51 | mimeBuf := &bytes.Buffer{} 52 | inputBuf := &bytes.Buffer{} 53 | 54 | w := io.MultiWriter(mimeBuf, inputBuf) 55 | 56 | if _, err := io.Copy(w, r); err != nil { 57 | return "", fmt.Errorf("failed to copy file to cloudflare: %w", err) 58 | } 59 | mimeType, err := mimetype.DetectReader(mimeBuf) 60 | if err != nil { 61 | return "", fmt.Errorf("detect file %s error: %w", filename, err) 62 | } 63 | 64 | putObjectInput := &s3.PutObjectInput{ 65 | Bucket: &c.opts.BucketName, 66 | Key: aws.String(filename), 67 | Body: inputBuf, 68 | ContentType: aws.String(mimeType.String()), 69 | } 70 | 71 | _, pErr := c.client.PutObject(ctx, putObjectInput) 72 | if pErr != nil { 73 | return "", fmt.Errorf("upload file %s error: %w", filename, pErr) 74 | } 75 | return c.opts.Domain + strings.ReplaceAll("/"+filename, "//", "/"), nil 76 | } 77 | 78 | func NewCloudflare(opts ...OptionsFunc) (Storage, error) { 79 | var o Options 80 | for _, opt := range opts { 81 | if err := opt(&o); err != nil { 82 | return nil, err 83 | } 84 | } 85 | // 自定义 HTTP 客户端以支持所需的 TLS 配置 86 | customTransport := &http.Transport{ 87 | TLSClientConfig: &tls.Config{ 88 | MinVersion: tls.VersionTLS11, 89 | }, 90 | Proxy: http.ProxyFromEnvironment, 91 | } 92 | customHTTPClient := &http.Client{ 93 | Transport: customTransport, 94 | } 95 | cfg, err := config.LoadDefaultConfig(context.TODO(), 96 | config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(o.AccessKeyID, o.AccessKeySecret, "")), 97 | config.WithRegion("auto"), 98 | config.WithHTTPClient(customHTTPClient), 99 | config.WithRetryMaxAttempts(5), 100 | ) 101 | if err != nil { 102 | return nil, fmt.Errorf("load s3 config err:%w", err) 103 | } 104 | endpoint := o.Endpoint 105 | if o.Endpoint == "" { 106 | o.Endpoint = fmt.Sprintf("https://%s.r2.cloudflarestorage.com", o.AccountID) 107 | endpoint = o.Endpoint 108 | } 109 | 110 | client := s3.NewFromConfig(cfg, func(o *s3.Options) { 111 | o.BaseEndpoint = aws.String(endpoint) 112 | }) 113 | return &Cloudflare{ 114 | opts: &o, 115 | client: client, 116 | }, nil 117 | } 118 | -------------------------------------------------------------------------------- /admin/service/crontab.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "github.com/beego/beego/v2/core/logs" 6 | "github.com/beego/beego/v2/server/web" 7 | "github.com/lifei6671/douyinbot/admin/models" 8 | "github.com/lifei6671/douyinbot/douyin" 9 | "github.com/lifei6671/douyinbot/internal/utils" 10 | "strings" 11 | ) 12 | 13 | var _cronCh = make(chan string, 100) 14 | 15 | // RunCron 运行定时任务 16 | func RunCron(ctx context.Context) { 17 | //go func() { 18 | // once := sync.Once{} 19 | // timer := time.NewTicker(time.Second * 1) 20 | // defer timer.Stop() 21 | // for { 22 | // select { 23 | // case <-timer.C: 24 | // coverList, err := models.NewDouYinCover().GetExpireList() 25 | // if err != nil && !errors.Is(err, orm.ErrNoRows) { 26 | // logs.Error("查询过期列表失败 : %+v", err) 27 | // } 28 | // logs.Info("cover len", len(coverList)) 29 | // if err == nil { 30 | // for _, cover := range coverList { 31 | // if cover.Expires > 0 { 32 | // _cronCh <- cover.VideoId 33 | // } 34 | // } 35 | // } 36 | // once.Do(func() { 37 | // timer.Reset(time.Minute * 30) 38 | // }) 39 | // case <-ctx.Done(): 40 | // return 41 | // } 42 | // } 43 | // 44 | //}() 45 | //go func() { 46 | // var limiter = rate.NewLimiter(rate.Every(time.Second*2), 1) 47 | // for { 48 | // func() { 49 | // waitCtx, cancel := context.WithTimeout(context.Background(), time.Second) 50 | // defer cancel() 51 | // //等待1s 52 | // _ = limiter.Wait(waitCtx) 53 | // limiter.Allow() 54 | // }() 55 | // 56 | // select { 57 | // case videoId, ok := <-_cronCh: 58 | // if !ok { 59 | // return 60 | // } 61 | // 62 | // err := syncCover(videoId) 63 | // if err != nil { 64 | // logs.Error("更新封面失败: 【%s】 %+v", videoId, err) 65 | // } else { 66 | // logs.Info("更新封面成功: %s", videoId) 67 | // } 68 | // break 69 | // 70 | // case <-ctx.Done(): 71 | // return 72 | // } 73 | // } 74 | //}() 75 | } 76 | 77 | // syncCover 同步过期的封面 78 | func syncCover(videoId string) error { 79 | defer func() { 80 | if err := recover(); err != nil { 81 | logs.Error("syncCover_Panic:%s", err) 82 | } 83 | }() 84 | videoRecord, err := models.NewDouYinVideo().FirstByVideoId(videoId) 85 | if err != nil { 86 | return err 87 | } 88 | logs.Info("开始解析抖音视频任务 -> %s", videoRecord.VideoRawPlayAddr) 89 | dy := douyin.NewDouYin( 90 | web.AppConfig.DefaultString("douyinproxy", ""), 91 | web.AppConfig.DefaultString("douyinproxyusername", ""), 92 | web.AppConfig.DefaultString("douyinproxypassword", ""), 93 | ) 94 | 95 | video, err := dy.Get(videoRecord.VideoRawPlayAddr) 96 | if err != nil { 97 | logs.Error("解析抖音视频地址失败 -> 【%s】- %+v", videoRecord.RawLink, err) 98 | //将状态更新为无效 99 | _ = models.NewDouYinCover().SetStatus(videoId, 1) 100 | return err 101 | } 102 | if len(video.OriginCoverList) > 0 { 103 | expire, _ := utils.ParseExpireUnix(video.OriginCoverList[0]) 104 | cover := models.DouYinCover{ 105 | VideoId: videoRecord.VideoId, 106 | Cover: video.OriginCoverList[0], 107 | CoverImage: strings.Join(video.OriginCoverList, "|"), 108 | Expires: expire, 109 | } 110 | if err := cover.Save(videoRecord.VideoId); err != nil { 111 | logs.Error("保存封面失败: %+v", err) 112 | } else { 113 | videoRecord.AwemeId = video.VideoId 114 | videoRecord.VideoCover = video.OriginCover 115 | if err := videoRecord.Save(); err != nil { 116 | logs.Error("保存默认封面:【%s】- %+v", videoRecord.RawLink, err) 117 | return err 118 | } else { 119 | logs.Info("更新封面成功: %s", videoRecord.VideoCover) 120 | } 121 | } 122 | } 123 | return nil 124 | } 125 | 126 | // AddSyncCover 推送到chan中 127 | func AddSyncCover(videoId string) { 128 | _cronCh <- videoId 129 | } 130 | -------------------------------------------------------------------------------- /internal/utils/webp.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "fmt" 7 | "image" 8 | "image/jpeg" 9 | "io" 10 | "log" 11 | "os" 12 | "strings" 13 | 14 | "golang.org/x/image/webp" 15 | 16 | webpp "github.com/chai2010/webp" 17 | ) 18 | 19 | // Image2Webp 将图片转为webp 20 | // inputFile 图片字节切片(仅限gif,jpeg,png格式) 21 | // outputFile webp图片字节切片 22 | // 图片质量 23 | func Image2Webp(inputPath, outputPath string) error { 24 | file, err := os.Open(inputPath) 25 | if err != nil { 26 | return fmt.Errorf("failed to open input file: %w", err) 27 | } 28 | defer file.Close() 29 | oFile, err := os.Create(outputPath) 30 | if err != nil { 31 | return fmt.Errorf("failed to create output file: %w", err) 32 | } 33 | defer oFile.Close() 34 | //解析图片 35 | img, _, err := image.Decode(file) 36 | if err != nil { 37 | log.Printf("decode image err:%s", err) 38 | return err 39 | } 40 | //转为webp 41 | webpBytes, err := webpp.EncodeRGBA(img, 100) 42 | 43 | if err != nil { 44 | log.Printf("encode image err:%s", err) 45 | return err 46 | } 47 | _, oErr := oFile.Write(webpBytes) 48 | 49 | return oErr 50 | } 51 | 52 | // IsAnimatedWebP 判断一个 WebP 是否是一个动画文件 53 | func IsAnimatedWebP(filepath string) (bool, error) { 54 | // 打开文件 55 | file, err := os.Open(filepath) 56 | if err != nil { 57 | return false, err 58 | } 59 | defer file.Close() 60 | scanner := bufio.NewScanner(bufio.NewReader(file)) 61 | scanner.Split(bufio.ScanWords) 62 | var isAnimWebp = false 63 | var current = 0 64 | var limit = 6 65 | for scanner.Scan() { 66 | if strings.Contains(scanner.Text(), "ANIM") || strings.Contains(scanner.Text(), "VP8X") { 67 | isAnimWebp = true 68 | break 69 | } 70 | 71 | if current > limit { 72 | break 73 | } 74 | // 读取到一定的行数就不读了 75 | current++ 76 | } 77 | if isAnimWebp { 78 | return true, nil 79 | } 80 | file.Seek(0, io.SeekStart) 81 | 82 | // 读取文件头部前 12 个字节 83 | header := make([]byte, 12) 84 | _, err = file.Read(header) 85 | if err != nil { 86 | return false, err 87 | } 88 | 89 | // 检查 WebP 文件签名 90 | if !bytes.Equal(header[:4], []byte("RIFF")) || !bytes.Equal(header[8:12], []byte("WEBP")) { 91 | return false, fmt.Errorf("not a valid WebP file") 92 | } 93 | 94 | // 读取 VP8X 块,找到动画标志 95 | buffer := make([]byte, 1024) 96 | _, err = file.Read(buffer) 97 | if err != nil { 98 | return false, err 99 | } 100 | 101 | // 搜索 VP8X 块 102 | for i := 0; i < len(buffer)-7; i++ { 103 | if bytes.Equal(buffer[i:i+4], []byte("VP8X")) { 104 | // VP8X 块找到后,检查动画标志(第 5 字节的第 1 位是否为 1) 105 | animationFlag := buffer[i+4] 106 | return (animationFlag & 0x02) != 0, nil 107 | } 108 | } 109 | 110 | // 如果未找到 VP8X 块 111 | return false, fmt.Errorf("VP8X block not found, possibly not an extended WebP file") 112 | } 113 | 114 | // ExtractFirstFrame extracts the first frame of an animated WebP file 115 | func ExtractFirstFrame(inputPath, outputPath string) error { 116 | // 打开输入文件 117 | file, err := os.Open(inputPath) 118 | if err != nil { 119 | return fmt.Errorf("failed to open input file: %w", err) 120 | } 121 | defer file.Close() 122 | 123 | // 解码 WebP 文件 124 | img, err := webp.Decode(file) 125 | if err != nil { 126 | return fmt.Errorf("failed to decode WebP file: %w", err) 127 | } 128 | 129 | // 将图像保存为新的 WebP(静态图像) 130 | outputFile, err := os.Create(outputPath) 131 | if err != nil { 132 | return fmt.Errorf("failed to create output file: %w", err) 133 | } 134 | defer outputFile.Close() 135 | 136 | // 使用 PNG 作为中间格式保存图像(也可以直接用其他库保存为 WebP) 137 | err = jpeg.Encode(outputFile, img, &jpeg.Options{Quality: 95}) 138 | if err != nil { 139 | return fmt.Errorf("failed to encode PNG: %w", err) 140 | } 141 | 142 | fmt.Println("Successfully converted animated WebP to static WebP (saved as PNG).") 143 | return nil 144 | } 145 | -------------------------------------------------------------------------------- /admin/controllers/baidu.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "github.com/beego/beego/v2/core/logs" 5 | "github.com/beego/beego/v2/server/web" 6 | "github.com/lifei6671/douyinbot/admin/models" 7 | "github.com/lifei6671/douyinbot/admin/service" 8 | "github.com/lifei6671/douyinbot/baidu" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | var ( 14 | bd *baidu.NetDisk 15 | ) 16 | 17 | type BaiduController struct { 18 | web.Controller 19 | display string 20 | } 21 | 22 | func (c *BaiduController) Prepare() { 23 | if !web.AppConfig.DefaultBool("baidunetdiskenable", false) { 24 | _ = c.Ctx.Output.Body([]byte("网站未开启百度网盘接入功能!")) 25 | c.StopRun() 26 | return 27 | } 28 | c.display = "mobile" 29 | if !service.IsMobile(c.Ctx.Input.UserAgent()) { 30 | c.display = "page" 31 | } 32 | } 33 | func (c *BaiduController) Index() { 34 | wid := c.Ctx.Input.Query("wid") 35 | if wid == "" { 36 | _ = c.Ctx.Output.Body([]byte("参数错误")) 37 | c.StopRun() 38 | return 39 | } 40 | if err := c.SetSession("wid", wid); err != nil { 41 | _ = c.Ctx.Output.Body([]byte("保存参数失败")) 42 | c.StopRun() 43 | return 44 | } 45 | 46 | registeredUrl := web.AppConfig.DefaultString("baiduregisteredurl", "") 47 | 48 | authorizeUrl := bd.AuthorizeURI(registeredUrl, c.display) 49 | c.Redirect(authorizeUrl, http.StatusFound) 50 | c.StopRun() 51 | } 52 | 53 | func (c *BaiduController) Authorize() { 54 | code := c.Ctx.Input.Query("code") 55 | if code == "" { 56 | _ = c.Ctx.Output.Body([]byte("获取百度网盘授权信息失败!")) 57 | c.StopRun() 58 | return 59 | } 60 | wid, ok := c.GetSession("wid").(string) 61 | if !ok { 62 | _ = c.Ctx.Output.Body([]byte("授权失败请重新发起授权")) 63 | return 64 | } 65 | 66 | registeredUrl := web.AppConfig.DefaultString("baiduregisteredurl", "") 67 | 68 | token, err := bd.GetAccessToken(code, registeredUrl) 69 | if err != nil { 70 | logs.Error("百度网盘授权失败 -> [code=%s] error=%s", code, err) 71 | _ = c.Ctx.Output.Body([]byte("获取百度网盘授权信息失败!")) 72 | c.StopRun() 73 | return 74 | } 75 | userInfo, err := bd.UserInfo() 76 | if err != nil { 77 | logs.Error("百度网盘用户信息失败 -> [code=%s] error=%+v", code, err) 78 | _ = c.Ctx.Output.Body([]byte("获取百度网盘用户信息失败!")) 79 | c.StopRun() 80 | return 81 | } 82 | 83 | user, err := models.NewUser().FirstByWechatId(wid) 84 | if err != nil { 85 | _ = c.Ctx.Output.Body([]byte("您不是已注册用户不能绑定百度网盘")) 86 | return 87 | } 88 | user.BaiduId = userInfo.UserId 89 | if err := user.Update("baidu_id"); err != nil { 90 | logs.Error("更新用户BaiduId失败 -> %+v", err) 91 | _ = c.Ctx.Output.Body([]byte("绑定用户网盘失败,请重试")) 92 | return 93 | } 94 | 95 | baiduUser := models.NewBaiduToken() 96 | baiduUser.BaiduId = userInfo.UserId 97 | baiduUser.BaiduName = userInfo.BaiduName 98 | baiduUser.VipType = userInfo.VipType 99 | baiduUser.NetdiskName = userInfo.NetdiskName 100 | baiduUser.AvatarUrl = userInfo.AvatarUrl 101 | baiduUser.AccessToken = token.AccessToken 102 | baiduUser.RefreshToken = token.RefreshToken 103 | baiduUser.ExpiresIn = token.ExpiresIn 104 | baiduUser.Scope = token.Scope 105 | baiduUser.Created = time.Unix(token.CreateAt, 0) 106 | baiduUser.RefreshTokenCreateAt = time.Unix(token.RefreshTokenCreateAt, 0) 107 | err = baiduUser.Save() 108 | if err != nil { 109 | logs.Error("百度网盘用户信息失败 -> [user=%s] error=%+v", baiduUser, err) 110 | _ = c.Ctx.Output.Body([]byte("保存百度网盘用户信息失败!")) 111 | c.StopRun() 112 | return 113 | } 114 | logs.Info("百度网盘授权成功 -> [code=%s] baiduUser:%+v", code, baiduUser) 115 | _ = c.Ctx.Output.Body([]byte("百度网盘授权成功!")) 116 | c.StopRun() 117 | return 118 | } 119 | 120 | func init() { 121 | if !web.AppConfig.DefaultBool("baidunetdiskenable", false) { 122 | return 123 | } 124 | appId := web.AppConfig.DefaultString("baiduappid", "") 125 | appKey := web.AppConfig.DefaultString("baiduappkey", "") 126 | secretKey := web.AppConfig.DefaultString("baidusecretkey", "") 127 | signKey := web.AppConfig.DefaultString("baidusignkey", "") 128 | 129 | bd = baidu.NewNetDisk(appId, appKey, secretKey, signKey) 130 | bd.IsDebug(true) 131 | } 132 | -------------------------------------------------------------------------------- /admin/views/index/content_index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 10 | 11 | {{.video.Nickname}}{{if .raw_nickname}}({{.raw_nickname}}){{end}}-抖音无水印-抖音型男集锦 12 | 13 | 28 | 29 | 30 |
31 |
32 |
33 | 43 | 48 |
49 |

@{{.video.Nickname}}

52 |

{{str2html .video.Desc}}

53 |
54 |
55 | 56 | 57 |
58 | 59 |
加载中...
60 | 64 |
65 | 74 | 75 | 84 | 85 | -------------------------------------------------------------------------------- /admin/models/douyin_tag.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | "sync/atomic" 10 | "time" 11 | "unicode/utf8" 12 | 13 | "github.com/beego/beego/v2/client/orm" 14 | "github.com/beego/beego/v2/server/web" 15 | ) 16 | 17 | var incrID atomic.Int64 18 | var tagReg = regexp.MustCompile(`#([^#\s]+)`) 19 | 20 | type DouYinTag struct { 21 | Id int `orm:"column(id);auto;pk"` 22 | TagID string `orm:"column(tag_id);size(255);index;not null" json:"tag_id"` 23 | Name string `orm:"column(name);size(255);index;not null" json:"name"` 24 | VideoID string `orm:"column(video_id);size(255);index;description(视频ID)" json:"video_id"` 25 | Created time.Time `orm:"auto_now_add;type(datetime);description(创建时间)"` 26 | } 27 | 28 | func (d *DouYinTag) TableName() string { 29 | return "douyin_tag" 30 | } 31 | 32 | // TableUnique 多字段唯一键 33 | func (d *DouYinTag) TableUnique() [][]string { 34 | return [][]string{ 35 | {"tag_id", "name", "video_id"}, 36 | } 37 | } 38 | 39 | func NewDouYinTag() *DouYinTag { 40 | return &DouYinTag{} 41 | } 42 | 43 | func (d *DouYinTag) Create(text string, videoId string) error { 44 | // 使用 FindAllString 提取所有匹配的内容 45 | matches := tagReg.FindAllString(text, -1) 46 | 47 | if len(matches) == 0 { 48 | return nil 49 | } 50 | o := orm.NewOrm() 51 | 52 | for _, m := range matches { 53 | tagName := strings.TrimSpace(strings.Trim(m, "#")) 54 | if strings.Contains(tagName, "#") || utf8.RuneCountInString(tagName) > 10 { 55 | continue 56 | } 57 | var tag DouYinTag 58 | err := o.QueryTable(d.TableName()).Filter("name", tagName).One(&tag) 59 | 60 | newTag := DouYinTag{ 61 | Name: tagName, 62 | VideoID: videoId, 63 | Created: time.Now(), 64 | } 65 | //如果没查到 66 | if errors.Is(err, orm.ErrNoRows) { 67 | newTag.TagID = strconv.FormatInt(incrID.Add(1), 10) 68 | } else if err == nil { 69 | newTag.TagID = tag.TagID 70 | } 71 | if newTag.TagID != "" { 72 | if _, err := o.Insert(&newTag); err != nil { 73 | return err 74 | } 75 | } 76 | } 77 | return nil 78 | } 79 | 80 | func (d *DouYinTag) GetList(pageIndex int, tagID string) (list []*DouYinVideo, tagName string, total int, err error) { 81 | if tagID == "" { 82 | return 83 | } 84 | o := orm.NewOrm() 85 | offset := (max(pageIndex, 1) - 1) * PageSize 86 | query := o.QueryTable(d.TableName()). 87 | OrderBy("-id"). 88 | Filter("tag_id", tagID) 89 | 90 | count, err := query.Count() 91 | total = int(count) 92 | 93 | var tagList []*DouYinTag 94 | _, err = query.Offset(offset).Limit(PageSize).All(&tagList) 95 | var videoIDs []any 96 | for _, v := range tagList { 97 | videoIDs = append(videoIDs, v.VideoID) 98 | tagName = v.Name 99 | } 100 | 101 | _, err = o.QueryTable(NewDouYinVideo().TableName()).Filter("video_id__in", videoIDs...).OrderBy("-id").All(&list) 102 | if err != nil { 103 | return nil, "", 0, err 104 | } 105 | return 106 | } 107 | 108 | func (d *DouYinTag) GetByID(tagID string) (*DouYinTag, error) { 109 | err := orm.NewOrm().QueryTable(d.TableName()).Filter("tag_id", tagID).One(&d) 110 | if err != nil { 111 | return nil, err 112 | } 113 | return d, nil 114 | } 115 | 116 | func (d *DouYinTag) Insert() (int, error) { 117 | id, err := orm.NewOrm().Insert(d) 118 | if err != nil { 119 | return 0, err 120 | } 121 | return int(id), nil 122 | } 123 | 124 | func (d *DouYinTag) FormatTagHtml(text string) (string, error) { 125 | // 使用 FindAllString 提取所有匹配的内容 126 | matches := tagReg.FindAllString(text, -1) 127 | 128 | var tags []any 129 | for _, m := range matches { 130 | tags = append(tags, strings.TrimSpace(strings.Trim(m, "#"))) 131 | } 132 | if len(tags) == 0 { 133 | return text, nil 134 | } 135 | var list []*DouYinTag 136 | _, err := orm.NewOrm().QueryTable(d.TableName()).Filter("name__in", tags...).All(&list) 137 | if err != nil { 138 | return "", err 139 | } 140 | for _, v := range list { 141 | text = strings.ReplaceAll(text, "#"+v.Name+" ", fmt.Sprintf(`#%s `, web.URLFor("TagController.Index", ":tag_id", v.TagID, ":page", 1), v.Name, v.Name)) 142 | } 143 | 144 | return text, nil 145 | } 146 | 147 | func init() { 148 | // 需要在init中注册定义的model 149 | orm.RegisterModel(new(DouYinTag)) 150 | 151 | incrID.Store(time.Now().UnixNano()) 152 | } 153 | -------------------------------------------------------------------------------- /admin/controllers/home.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | 9 | "github.com/beego/beego/v2/server/web" 10 | "github.com/lifei6671/douyinbot/admin/service" 11 | 12 | "github.com/lifei6671/douyinbot/admin/structs" 13 | "github.com/lifei6671/douyinbot/douyin" 14 | ) 15 | 16 | var douYin *douyin.DouYin 17 | var once sync.Once 18 | 19 | var ( 20 | videoHtml = `` 21 | ) 22 | 23 | type HomeController struct { 24 | web.Controller 25 | } 26 | 27 | func (c *HomeController) Index() { 28 | once.Do(func() { 29 | douYin = douyin.NewDouYin( 30 | web.AppConfig.DefaultString("douyinproxy", ""), 31 | web.AppConfig.DefaultString("douyinproxyusername", ""), 32 | web.AppConfig.DefaultString("douyinproxypassword", ""), 33 | ) 34 | }) 35 | if c.Ctx.Input.IsGet() { 36 | c.TplName = "home/index.gohtml" 37 | } else { 38 | douYinContent := c.Ctx.Input.Query("douYinContent") 39 | if douYinContent == "" { 40 | c.Data["json"] = &structs.JsonResult[string]{ 41 | ErrCode: 1, 42 | Message: "解析内容失败", 43 | } 44 | } else { 45 | //service.Push(context.Background(), service.MediaContent{ 46 | // Content: douYinContent, 47 | // UserId: "lifei6671", 48 | //}) 49 | //return 50 | video, err := douYin.Get(douYinContent) 51 | if err != nil { 52 | c.Data["json"] = &structs.JsonResult[string]{ 53 | ErrCode: 1, 54 | Message: err.Error(), 55 | } 56 | } else if video.VideoType == douyin.VideoPlayType { 57 | c.Data["json"] = &structs.JsonResult[string]{ 58 | ErrCode: 0, 59 | Message: "ok", 60 | Data: strings.ReplaceAll(videoHtml, "{{__VIDEO__}}", video.PlayAddr), 61 | } 62 | } else if video.VideoType == douyin.ImagePlayType { 63 | var imageHtml string 64 | 65 | for _, image := range video.Images { 66 | imageHtml += fmt.Sprintf(`

`, 67 | image.ImageUrl, image.ImageUrl, 68 | ) 69 | } 70 | c.Data["json"] = &structs.JsonResult[string]{ 71 | ErrCode: 0, 72 | Message: "ok", 73 | Data: imageHtml, 74 | } 75 | } else { 76 | c.Data["json"] = &structs.JsonResult[string]{ 77 | ErrCode: 1, 78 | Message: "无法解析", 79 | } 80 | } 81 | 82 | } 83 | c.ServeJSON() 84 | } 85 | } 86 | 87 | func (c *HomeController) Download() { 88 | urlStr := c.Ctx.Input.Query("url") 89 | if urlStr == "" { 90 | c.Data["json"] = &structs.JsonResult[string]{ 91 | ErrCode: 1, 92 | Message: "获取抖音地址失败", 93 | } 94 | } else { 95 | video, err := douYin.Get(urlStr) 96 | if err != nil { 97 | c.Data["json"] = &structs.JsonResult[string]{ 98 | ErrCode: 1, 99 | Message: err.Error(), 100 | } 101 | } else { 102 | if c.Ctx.Input.IsAjax() { 103 | location, _ := video.GetDownloadUrl() 104 | 105 | c.Data["json"] = &structs.JsonResult[map[string]string]{ 106 | ErrCode: 0, 107 | Message: "ok", 108 | Data: map[string]string{ 109 | "url": location, 110 | "name": video.GetFilename(), 111 | }, 112 | } 113 | 114 | } else { 115 | filename, err := web.AppConfig.String("auto-save-path") 116 | if err != nil { 117 | c.Data["json"] = &structs.JsonResult[string]{ 118 | ErrCode: 2, 119 | Message: "未找到文件保存目录", 120 | Data: video.PlayAddr, 121 | } 122 | } else { 123 | _, err = video.Download(filename) 124 | if err != nil { 125 | c.Data["json"] = &structs.JsonResult[string]{ 126 | ErrCode: 1, 127 | Message: err.Error(), 128 | } 129 | } else { 130 | c.Data["json"] = &structs.JsonResult[string]{ 131 | ErrCode: 0, 132 | Message: "ok", 133 | Data: video.PlayAddr, 134 | } 135 | } 136 | } 137 | } 138 | } 139 | } 140 | _ = c.ServeJSON() 141 | } 142 | 143 | func (c *HomeController) SendVideo() { 144 | videoId := c.Ctx.Input.Query("url") 145 | if videoId == "" { 146 | c.Data["json"] = &structs.JsonResult[string]{ 147 | ErrCode: 1, 148 | Message: "获取抖音视频ID失败", 149 | } 150 | } else { 151 | service.Push(context.Background(), service.MediaContent{ 152 | Content: videoId, 153 | UserId: "", 154 | }) 155 | c.Data["json"] = &structs.JsonResult[string]{ 156 | ErrCode: 0, 157 | Message: "后台处理中", 158 | } 159 | } 160 | _ = c.ServeJSON() 161 | } 162 | -------------------------------------------------------------------------------- /admin/models/douyin.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/beego/beego/v2/client/orm" 8 | "github.com/beego/beego/v2/core/logs" 9 | ) 10 | 11 | var PageSize = 18 12 | 13 | type DouYinVideo struct { 14 | Id int `orm:"column(id);auto;pk"` 15 | UserId int `orm:"column(user_id);index;description(所属用户)"` 16 | Nickname string `orm:"column(nickname);size(100); description(作者昵称)"` 17 | Signature string `orm:"column(signature);size(255);null;description(作者信息)"` 18 | AvatarLarger string `orm:"column(avatar_larger);size(2000);null;description(作者头像)"` 19 | AuthorId string `orm:"column(author_id);size(20);null;description(作者长ID)"` 20 | AuthorShortId string `orm:"column(author_short_id);size(10);null;description(作者短ID)"` 21 | VideoRawPlayAddr string `orm:"column(video_raw_play_addr);size(2000);description(原视频地址)"` 22 | VideoPlayAddr string `orm:"column(video_play_addr);size(2000);description(视频原播放地址)"` 23 | VideoId string `orm:"column(video_id);size(255);unique;description(视频唯一ID)"` 24 | AwemeId string `orm:"column(aweme_id);size(255);description(原始awemeid)"` 25 | VideoCover string `orm:"column(video_cover);size(2000);null;description(视频封面)"` 26 | VideoLocalCover string `orm:"column(video_local_cover);size(2000);description(本地备份封面)"` 27 | VideoLocalAddr string `orm:"column(video_local_addr);size(2000);description(本地路径)"` 28 | VideoBackAddr string `orm:"column(video_back_addr);size(2000);null;description(备份的地址)"` 29 | Desc string `orm:"column(desc);size(1000);null;description(视频描述)"` 30 | RawLink string `orm:"column(raw_link);size(255);default('');description(原始分享内容)"` 31 | Created time.Time `orm:"auto_now_add;type(datetime);description(创建时间)"` 32 | } 33 | 34 | func (d *DouYinVideo) TableName() string { 35 | return "douyin_video" 36 | } 37 | 38 | func NewDouYinVideo() *DouYinVideo { 39 | return &DouYinVideo{} 40 | } 41 | 42 | func (d *DouYinVideo) GetList(pageIndex int, authorId int) (list []DouYinVideo, total int, err error) { 43 | o := orm.NewOrm() 44 | offset := (pageIndex - 1) * PageSize 45 | query := o.QueryTable(d.TableName()).OrderBy("-id") 46 | if authorId > 0 { 47 | query = query.Filter("author_id", authorId) 48 | } 49 | count, err := query.Count() 50 | total = int(count) 51 | 52 | _, err = query.Offset(offset).Limit(PageSize).All(&list) 53 | 54 | return 55 | } 56 | 57 | func (d *DouYinVideo) Save() error { 58 | o := orm.NewOrm() 59 | 60 | var video DouYinVideo 61 | 62 | err := o.QueryTable(d.TableName()).Filter("video_id", d.VideoId).One(&video) 63 | if errors.Is(err, orm.ErrNoRows) { 64 | _, err = o.Insert(d) 65 | } else if err == nil { 66 | d.Id = video.Id 67 | _, err = o.Update(d) 68 | } 69 | 70 | return err 71 | } 72 | 73 | func (d *DouYinVideo) FirstByVideoId(videoId string) (*DouYinVideo, error) { 74 | o := orm.NewOrm() 75 | err := o.QueryTable(d.TableName()).Filter("video_id", videoId).One(d) 76 | if err != nil && !errors.Is(err, orm.ErrNoRows) { 77 | logs.Error("查询视频失败 -> video_id=%s ; error=%+v", videoId, err) 78 | return nil, err 79 | } 80 | return d, nil 81 | } 82 | 83 | // Next 查询指定视频的下一条视频 84 | func (d *DouYinVideo) Next(videoId string) (*DouYinVideo, error) { 85 | o := orm.NewOrm() 86 | 87 | var current DouYinVideo 88 | err := o.QueryTable(d.TableName()).Filter("video_id", videoId).One(¤t) 89 | if err != nil { 90 | return nil, err 91 | } 92 | // 查询下一条 93 | var nextVideo DouYinVideo 94 | errNext := o.QueryTable(d.TableName()). 95 | Filter("id__gt", current.Id). 96 | OrderBy("id"). 97 | Limit(1). 98 | One(&nextVideo) 99 | if errNext != nil { 100 | return nil, errNext 101 | } 102 | return &nextVideo, nil 103 | } 104 | 105 | // Prev 查询指定视频的上一条视频 106 | func (d *DouYinVideo) Prev(videoId string) (*DouYinVideo, error) { 107 | o := orm.NewOrm() 108 | 109 | var current DouYinVideo 110 | err := o.QueryTable(d.TableName()).Filter("video_id", videoId).One(¤t) 111 | if err != nil { 112 | return nil, err 113 | } 114 | // 查询下一条 115 | var prevVideo DouYinVideo 116 | errPrev := o.QueryTable(d.TableName()). 117 | Filter("id__lt", current.Id). 118 | OrderBy("-id"). // 降序排列后取第一条 119 | Limit(1). 120 | One(&prevVideo) 121 | if errPrev != nil { 122 | return nil, errPrev 123 | } 124 | return &prevVideo, nil 125 | } 126 | 127 | func init() { 128 | // 需要在init中注册定义的model 129 | orm.RegisterModel(new(DouYinVideo)) 130 | } 131 | -------------------------------------------------------------------------------- /admin/controllers/index.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "math" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/beego/beego/v2/core/logs" 11 | "github.com/beego/beego/v2/server/web" 12 | 13 | "github.com/lifei6671/douyinbot/admin/models" 14 | "github.com/lifei6671/douyinbot/admin/service" 15 | "github.com/lifei6671/douyinbot/internal/utils" 16 | ) 17 | 18 | type IndexController struct { 19 | web.Controller 20 | } 21 | 22 | func (c *IndexController) Index() { 23 | if err := utils.IfLastModified(c.Ctx.Input, time.Now()); err == nil { 24 | c.Abort(strconv.Itoa(http.StatusNotModified)) 25 | return 26 | } 27 | page := c.Ctx.Input.Param(":page") 28 | pageIndex := 1 29 | if page != "" { 30 | if num, err := strconv.Atoi(page); err == nil { 31 | if num <= 0 { 32 | c.Abort("404") 33 | return 34 | } 35 | pageIndex = int(math.Max(float64(num), float64(pageIndex))) 36 | } 37 | } 38 | 39 | list, total, err := models.NewDouYinVideo().GetList(pageIndex, 0) 40 | if err != nil { 41 | logs.Error("获取数据列表失败 -> +%+v", err) 42 | } 43 | for i, video := range list { 44 | if desc, err := models.NewDouYinTag().FormatTagHtml(video.Desc); err == nil { 45 | video.Desc = desc 46 | list[i] = video 47 | } else { 48 | logs.Error("渲染标签失败 ->%d - %+v", video.Id, err) 49 | } 50 | if strings.HasPrefix(video.VideoLocalCover, "/cover") { 51 | service.PushDownloadQueue(video) 52 | } 53 | } 54 | c.Data["List"] = list 55 | c.Data["PageIndex"] = pageIndex 56 | totalPage := int(math.Ceil(float64(total) / float64(models.PageSize))) 57 | 58 | if pageIndex <= 1 { 59 | c.Data["Previous"] = "#" 60 | c.Data["First"] = "#" 61 | } else { 62 | c.Data["Previous"] = c.URLFor("IndexController.Index", ":page", pageIndex-1) 63 | c.Data["First"] = c.URLFor("IndexController.Index", ":page", 1) 64 | } 65 | if pageIndex >= totalPage { 66 | c.Data["Next"] = "#" 67 | c.Data["Last"] = "#" 68 | } else { 69 | c.Data["Next"] = c.URLFor("IndexController.Index", ":page", pageIndex+1) 70 | c.Data["Last"] = c.URLFor("IndexController.Index", ":page", totalPage) 71 | } 72 | utils.CacheHeader(c.Ctx.Output, time.Now(), 1440, 7200) 73 | 74 | c.TplName = "index/index.gohtml" 75 | } 76 | 77 | func (c *IndexController) List() { 78 | if err := utils.IfLastModified(c.Ctx.Input, time.Now()); err == nil { 79 | c.Abort(strconv.Itoa(http.StatusNotModified)) 80 | return 81 | } 82 | page := c.Ctx.Input.Param(":page") 83 | pageIndex := 1 84 | if page != "" { 85 | if num, err := strconv.Atoi(page); err == nil { 86 | if num <= 0 { 87 | c.Abort("404") 88 | return 89 | } 90 | pageIndex = int(math.Max(float64(num), float64(pageIndex))) 91 | } 92 | } 93 | authorIdStr := c.Ctx.Input.Param(":author_id") 94 | authorId := 0 95 | 96 | if authorIdStr != "" { 97 | if num, err := strconv.Atoi(authorIdStr); err == nil { 98 | authorId = num 99 | } 100 | } 101 | if authorId <= 0 { 102 | c.Abort("404") 103 | return 104 | } 105 | 106 | list, total, err := models.NewDouYinVideo().GetList(pageIndex, authorId) 107 | if err != nil { 108 | logs.Error("获取数据列表失败 -> +%+v", err) 109 | } 110 | 111 | if user, err := models.NewDouYinUser().GetById(authorIdStr); err == nil { 112 | c.Data["Desc"] = user.Signature 113 | if user.AvatarCDNURL != "" { 114 | c.Data["AvatarURL"] = user.AvatarCDNURL 115 | } else { 116 | c.Data["AvatarURL"] = user.AvatarLarger 117 | } 118 | 119 | } 120 | 121 | if len(list) > 0 { 122 | if _, ok := c.Data["NickName"]; !ok { 123 | c.Data["Nickname"] = list[0].Nickname 124 | } 125 | 126 | for i, video := range list { 127 | if desc, err := models.NewDouYinTag().FormatTagHtml(video.Desc); err == nil { 128 | video.Desc = desc 129 | list[i] = video 130 | } else { 131 | logs.Error("渲染标签失败 ->%d - %+v", video.Id, err) 132 | } 133 | } 134 | } 135 | c.Data["List"] = list 136 | c.Data["PageIndex"] = pageIndex 137 | totalPage := int(math.Ceil(float64(total) / float64(models.PageSize))) 138 | 139 | if pageIndex <= 1 { 140 | c.Data["Previous"] = "#" 141 | c.Data["First"] = "#" 142 | } else { 143 | c.Data["Previous"] = c.URLFor("IndexController.List", ":author_id", authorId, ":page", pageIndex-1) 144 | c.Data["First"] = c.URLFor("IndexController.List", ":author_id", authorId, ":page", 1) 145 | } 146 | if pageIndex >= totalPage { 147 | c.Data["Next"] = "#" 148 | c.Data["Last"] = "#" 149 | } else { 150 | c.Data["Next"] = c.URLFor("IndexController.List", ":author_id", authorId, ":page", pageIndex+1) 151 | c.Data["Last"] = c.URLFor("IndexController.List", ":author_id", authorId, ":page", totalPage) 152 | } 153 | utils.CacheHeader(c.Ctx.Output, time.Now(), 1440, 7200) 154 | c.TplName = "index/list.gohtml" 155 | } 156 | -------------------------------------------------------------------------------- /admin/controllers/content.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/beego/beego/v2/client/orm" 11 | "github.com/beego/beego/v2/core/logs" 12 | "github.com/beego/beego/v2/server/web" 13 | 14 | "github.com/lifei6671/douyinbot/admin/models" 15 | "github.com/lifei6671/douyinbot/admin/structs" 16 | "github.com/lifei6671/douyinbot/internal/utils" 17 | ) 18 | 19 | type ContentController struct { 20 | web.Controller 21 | } 22 | 23 | func (c *ContentController) Index() { 24 | 25 | videoId := c.Ctx.Input.Param(":video_id") 26 | 27 | if videoId == "" { 28 | c.Ctx.Output.SetStatus(404) 29 | return 30 | } 31 | video, err := models.NewDouYinVideo().FirstByVideoId(videoId) 32 | if err != nil { 33 | c.Ctx.Output.SetStatus(500) 34 | return 35 | } 36 | if err := utils.IfLastModified(c.Ctx.Input, video.Created); err == nil { 37 | c.Abort(strconv.Itoa(http.StatusNotModified)) 38 | return 39 | } 40 | 41 | if m, err := web.AppConfig.GetSection("nickname"); err == nil { 42 | if nickname, ok := m[video.AuthorId]; ok { 43 | video.Desc = "#" + nickname + " " + strings.TrimRight(video.Desc, ".") + " ." 44 | c.Data["raw_nickname"] = nickname 45 | } 46 | } 47 | c.Data["desc"] = video.Desc 48 | html, err := models.NewDouYinTag().FormatTagHtml(video.Desc) 49 | if err != nil { 50 | logs.Error("处理视频标签失败[video_id=%s] %+v", video.VideoId, err) 51 | } else { 52 | video.Desc = html 53 | } 54 | //如果原始播放链接是抖音的,则切换为本地播放 55 | if strings.Contains(video.VideoPlayAddr, "aweme.snssdk.com") || strings.Contains(video.VideoPlayAddr, ".douyinvod.com") { 56 | video.VideoPlayAddr = web.AppConfig.DefaultString("domain", "") + c.URLFor("VideoController.Index", "video_id", video.VideoId) 57 | } 58 | if !strings.HasPrefix(video.VideoLocalCover, "https://") { 59 | video.VideoLocalCover = web.AppConfig.DefaultString("domain", "") + video.VideoLocalCover 60 | } 61 | 62 | c.Data["video"] = video 63 | 64 | minAge, maxAge := 3600, 86400 65 | 66 | if time.Now().Sub(video.Created).Hours() > 24*7 { 67 | minAge = 3600 * 24 * 7 68 | maxAge = 3600 * 24 * 30 69 | } 70 | 71 | utils.CacheHeader(c.Ctx.Output, video.Created, minAge, maxAge) 72 | 73 | c.TplName = "index/content_index.gohtml" 74 | } 75 | 76 | type VideoResult struct { 77 | VideoId string `json:"video_id"` 78 | Cover string `json:"cover"` 79 | PlayAddr string `json:"play_addr"` 80 | LocalPlayAddr string `json:"local_play_addr"` 81 | AuthorURL string `json:"author_url"` 82 | Nickname string `json:"nickname"` 83 | Desc string `json:"desc"` 84 | } 85 | 86 | func (c *ContentController) Next() { 87 | videoId := c.Ctx.Input.Query("video_id") 88 | action := c.Ctx.Input.Query("action") 89 | 90 | if videoId == "" || action == "" || (action != "next" && action != "prev") { 91 | c.Ctx.Output.SetStatus(404) 92 | logs.Error("请求参数异常:[video_id=%s, action=%s]", videoId, action) 93 | return 94 | } 95 | var video *models.DouYinVideo 96 | var err error 97 | if action == "next" { 98 | video, err = models.NewDouYinVideo().Next(videoId) 99 | } else { 100 | video, err = models.NewDouYinVideo().Prev(videoId) 101 | } 102 | if err != nil { 103 | if errors.Is(err, orm.ErrNoRows) { 104 | ret := structs.JsonResult[*VideoResult]{ 105 | ErrCode: 404, 106 | Message: utils.Ternary(action == "next", "已经是最后一页啦", "已经是第一页啦"), 107 | } 108 | _ = c.JSONResp(ret) 109 | return 110 | } 111 | logs.Error("视频翻页出错:[video_id=%s, action=%s] %+v", videoId, action, err) 112 | c.Ctx.Output.SetStatus(500) 113 | return 114 | } 115 | //如果原始播放链接是抖音的,则切换为本地播放 116 | if strings.Contains(video.VideoPlayAddr, "aweme.snssdk.com") || strings.Contains(video.VideoPlayAddr, ".douyinvod.com") { 117 | video.VideoPlayAddr = web.AppConfig.DefaultString("domain", "") + c.URLFor("VideoController.Index", "video_id", video.VideoId) 118 | } 119 | if !strings.HasPrefix(video.VideoLocalCover, "https://") { 120 | video.VideoLocalCover = web.AppConfig.DefaultString("domain", "") + video.VideoLocalCover 121 | } 122 | ret := structs.JsonResult[*VideoResult]{ 123 | ErrCode: 0, 124 | Message: "", 125 | Data: &VideoResult{ 126 | VideoId: video.VideoId, 127 | Cover: video.VideoLocalCover, 128 | PlayAddr: video.VideoPlayAddr, 129 | LocalPlayAddr: c.URLFor("VideoController.Index", "video_id", video.VideoId), 130 | AuthorURL: c.URLFor("IndexController.List", ":author_id", video.AuthorId, ":page", 1), 131 | Nickname: video.Nickname, 132 | Desc: video.Desc, 133 | }, 134 | } 135 | html, err := models.NewDouYinTag().FormatTagHtml(video.Desc) 136 | if err != nil { 137 | logs.Error("处理视频标签失败[video_id=%s, action=%s] %+v", video.VideoId, action, err) 138 | } else { 139 | ret.Data.Desc = html 140 | } 141 | 142 | _ = c.JSONResp(ret) 143 | } 144 | -------------------------------------------------------------------------------- /admin/web.go: -------------------------------------------------------------------------------- 1 | package admin 2 | 3 | import ( 4 | context2 "context" 5 | "embed" 6 | "fmt" 7 | "mime" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/beego/beego/v2/core/logs" 15 | "github.com/beego/beego/v2/server/web" 16 | "github.com/beego/beego/v2/server/web/context" 17 | "github.com/lifei6671/fink-download/fink" 18 | 19 | "github.com/lifei6671/douyinbot/admin/controllers" 20 | "github.com/lifei6671/douyinbot/admin/models" 21 | _ "github.com/lifei6671/douyinbot/admin/routers" 22 | "github.com/lifei6671/douyinbot/admin/service" 23 | ) 24 | 25 | //go:embed views static 26 | var Assets embed.FS 27 | var RunTime = time.Now() 28 | var modifiedFormat = "Mon, 02 Jan 2006 15:04:05 GMT" 29 | 30 | func Run(addr string, configFile string) error { 31 | if configFile != "" { 32 | logs.Info("从文件加载配置文件 -> %s", configFile) 33 | err := web.LoadAppConfig("ini", configFile) 34 | if err != nil { 35 | logs.Error("加载配置文件失败 -> %s - %+v", configFile, err) 36 | return fmt.Errorf("load config file failed: %s - %w", configFile, err) 37 | } 38 | } 39 | 40 | if web.BConfig.RunMode == web.PROD { 41 | web.SetTemplateFSFunc(func() http.FileSystem { 42 | return http.FS(Assets) 43 | }) 44 | web.SetViewsPath("views") 45 | if b, err := Assets.ReadFile("static/video/default.mp4"); err == nil { 46 | controllers.SetDefaultVideoContent(b) 47 | } 48 | } else { 49 | web.SetViewsPath(filepath.Join(web.WorkPath, "views")) 50 | if b, err := os.ReadFile(filepath.Join(web.WorkPath, "static/video/default.mp4")); err == nil { 51 | controllers.SetDefaultVideoContent(b) 52 | } 53 | } 54 | models.PageSize = max(web.AppConfig.DefaultInt("max_page_limit", models.PageSize)) 55 | 56 | web.Get("/robots.txt", func(ctx *context.Context) { 57 | if configFile != "" { 58 | robotsPath := filepath.Join(filepath.Dir(configFile), "robots.txt") 59 | 60 | b, err := os.ReadFile(robotsPath) 61 | if err != nil { 62 | ctx.Output.SetStatus(http.StatusNotFound) 63 | return 64 | } 65 | ctx.Output.Header("X-Content-Type-Options", "nosniff") 66 | err = ctx.Output.Body(b) 67 | if err != nil { 68 | logs.Error("写入数据到客户端失败 -> %+v", err) 69 | } 70 | } 71 | ctx.Output.SetStatus(http.StatusNotFound) 72 | return 73 | }) 74 | 75 | web.Get("/static/*.*", func(ctx *context.Context) { 76 | var b []byte 77 | var err error 78 | if web.BConfig.RunMode == web.PROD { 79 | //读取文件 80 | b, err = Assets.ReadFile(strings.TrimPrefix(ctx.Request.URL.Path, "/")) 81 | } else { 82 | b, err = os.ReadFile(filepath.Join(web.WorkPath, ctx.Request.URL.Path)) 83 | } 84 | if err != nil { 85 | logs.Error("文件不存在 -> %s", ctx.Request.URL.Path) 86 | ctx.Output.SetStatus(404) 87 | return 88 | } 89 | //解析文件类型 90 | contentType := mime.TypeByExtension(filepath.Ext(ctx.Request.URL.Path)) 91 | if contentType != "" { 92 | ctx.Output.Header("Content-Type", contentType) 93 | ctx.Output.Header("X-Content-Type-Options", "nosniff") 94 | } 95 | //解析客户端文件版本 96 | modified := ctx.Request.Header.Get("If-Modified-Since") 97 | if last, err := time.Parse(modifiedFormat, modified); err == nil { 98 | if RunTime.Before(last) { 99 | ctx.Output.SetStatus(304) 100 | return 101 | } 102 | } 103 | //写入缓冲时间 104 | ctx.Output.Header("Cache-Control", fmt.Sprintf("max-age=%d, s-maxage=%d", 3600*30*24, 3600*30*24)) 105 | ctx.Output.Header("Cloudflare-CDN-Cache-Control", "max-age=14400") 106 | ctx.Output.Header("Last-Modified", RunTime.Format(modifiedFormat)) 107 | 108 | err = ctx.Output.Body(b) 109 | if err != nil { 110 | logs.Error("写入数据到客户端失败 -> %+v", err) 111 | } 112 | }) 113 | 114 | savePath, err := web.AppConfig.String("auto-save-path") 115 | if err == nil { 116 | if _, err := os.Stat(savePath); os.IsNotExist(err) { 117 | if err := os.MkdirAll(savePath, 0755); err != nil { 118 | return fmt.Errorf("mkdir fail ->%s - %w", savePath, err) 119 | } 120 | } 121 | //web.SetStaticPath("/video", savePath) 122 | } 123 | web.Get("/cover/*.*", func(ctx *context.Context) { 124 | filename := filepath.Join(savePath, strings.TrimPrefix(ctx.Request.RequestURI, "/cover")) 125 | b, err := os.ReadFile(filename) 126 | if err != nil { 127 | logs.Error("文件不存在 -> %s", ctx.Request.RequestURI) 128 | ctx.Output.SetStatus(404) 129 | return 130 | } 131 | //解析文件类型 132 | contentType := mime.TypeByExtension(filepath.Ext(filename)) 133 | if contentType != "" { 134 | ctx.Output.Header("Content-Type", contentType) 135 | ctx.Output.Header("X-Content-Type-Options", "nosniff") 136 | } 137 | //解析客户端文件版本 138 | modified := ctx.Request.Header.Get("If-Modified-Since") 139 | if last, err := time.Parse(modifiedFormat, modified); err == nil { 140 | if RunTime.Before(last) { 141 | ctx.Output.SetStatus(304) 142 | return 143 | } 144 | } 145 | //写入缓冲时间 146 | ctx.Output.Header("Cache-Control", fmt.Sprintf("max-age=%d, s-maxage=%d", 3600*30*24, 3600*30*24)) 147 | ctx.Output.Header("Cloudflare-CDN-Cache-Control", "max-age=14400") 148 | 149 | err = ctx.Output.Body(b) 150 | if err != nil { 151 | logs.Error("写入数据到客户端失败 -> %+v", err) 152 | } 153 | }) 154 | imagePath, err := web.AppConfig.String("image-save-path") 155 | 156 | if err == nil { 157 | if _, err := os.Stat(savePath); os.IsNotExist(err) { 158 | if err := os.MkdirAll(savePath, 0755); err != nil { 159 | return fmt.Errorf("mk dir err %s - %+w", savePath, err) 160 | } 161 | } 162 | go func() { 163 | if err := fink.Run(context2.Background(), imagePath); err != nil { 164 | panic(fmt.Errorf("create image path err %s - %w", savePath, err)) 165 | } 166 | }() 167 | } 168 | 169 | if err := service.Run(context2.Background()); err != nil { 170 | return err 171 | } 172 | 173 | go service.RunCron(context2.Background()) 174 | 175 | web.Run(addr) 176 | return nil 177 | } 178 | -------------------------------------------------------------------------------- /admin/controllers/weixin.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "encoding/xml" 6 | "errors" 7 | "fmt" 8 | "strings" 9 | "time" 10 | 11 | "github.com/beego/beego/v2/core/logs" 12 | "github.com/beego/beego/v2/server/web" 13 | "github.com/lifei6671/fink-download/fink" 14 | 15 | "github.com/lifei6671/douyinbot/admin/service" 16 | "github.com/lifei6671/douyinbot/wechat" 17 | ) 18 | 19 | var ( 20 | token = web.AppConfig.DefaultString("wechattoken", "") 21 | key = web.AppConfig.DefaultString("wechatencodingaeskey", "") 22 | appId = web.AppConfig.DefaultString("wechatappid", "") 23 | autoReplyContent = web.AppConfig.DefaultString("auto_reply_content", "") 24 | ) 25 | 26 | type WeiXinController struct { 27 | web.Controller 28 | wx *wechat.WeiXin 29 | body *wechat.TextRequestBody 30 | domain string 31 | } 32 | 33 | func (c *WeiXinController) Prepare() { 34 | c.wx = wechat.NewWeiXin(appId, token, key) 35 | c.domain = c.Ctx.Input.Scheme() + "://" + c.Ctx.Input.Host() 36 | } 37 | 38 | // Index 验证是否是微信请求 39 | func (c *WeiXinController) Index() { 40 | timestamp := c.Ctx.Input.Query("timestamp") 41 | signature := c.Ctx.Input.Query("signature") 42 | nonce := c.Ctx.Input.Query("nonce") 43 | echoStr := c.Ctx.Input.Query("echoStr") 44 | 45 | signatureGen := c.wx.MakeSignature(timestamp, nonce) 46 | 47 | if signatureGen == signature { 48 | _ = c.Ctx.Output.Body([]byte(echoStr)) 49 | } else { 50 | _ = c.Ctx.Output.Body([]byte("false")) 51 | } 52 | c.StopRun() 53 | } 54 | 55 | func (c *WeiXinController) Dispatch() { 56 | encryptType := c.Ctx.Input.Query("encrypt_type") 57 | msgSignature := c.Ctx.Input.Query("msg_signature") 58 | nonce := c.Ctx.Input.Query("nonce") 59 | timestamp := c.Ctx.Input.Query("timestamp") 60 | 61 | logs.Info("微信请求 ->", string(c.Ctx.Input.RequestBody)) 62 | if encryptType == wechat.EncryptTypeAES { 63 | requestBody := &wechat.EncryptRequestBody{} 64 | if err := xml.Unmarshal(c.Ctx.Input.RequestBody, requestBody); err != nil { 65 | logs.Error("解析微信消息失败 -> %+v", err) 66 | _ = c.Ctx.Output.Body([]byte("success")) 67 | c.StopRun() 68 | return 69 | } 70 | if !c.wx.ValidateMsg(timestamp, nonce, requestBody.Encrypt, msgSignature) { 71 | logs.Error("解析微信消息失败 -> %+v", msgSignature) 72 | _ = c.Ctx.Output.Body([]byte("success")) 73 | c.StopRun() 74 | return 75 | } 76 | var err error 77 | 78 | c.body, err = c.wx.ParseEncryptRequestBody(timestamp, nonce, msgSignature, c.Ctx.Input.RequestBody) 79 | if err != nil { 80 | logs.Error("解析微信消息失败 -> %+v", err) 81 | _ = c.Ctx.Output.Body([]byte("success")) 82 | c.StopRun() 83 | return 84 | } 85 | } else { 86 | var textRequestBody wechat.TextRequestBody 87 | err := xml.Unmarshal(c.Ctx.Input.RequestBody, &textRequestBody) 88 | if err != nil { 89 | logs.Error("解析微信消息失败 -> %+v", msgSignature) 90 | _ = c.Ctx.Output.Body([]byte("success")) 91 | c.StopRun() 92 | return 93 | } 94 | c.body = &textRequestBody 95 | } 96 | 97 | if c.body.MsgType == string(wechat.WeiXinTextMsgType) { 98 | if c.body.Content == "" { 99 | _ = c.response("解析消息失败") 100 | return 101 | } 102 | if handler := service.GetHandler(c.body.Content); handler != nil { 103 | if resp, err := handler(c.body); err != nil { 104 | _ = c.response("处理失败") 105 | } else { 106 | c.responseBody(resp) 107 | } 108 | return 109 | } 110 | if err := service.Register(c.body.Content, c.body.FromUserName); !errors.Is(err, service.ErrNoUserRegister) { 111 | if err != nil { 112 | _ = c.response(err.Error()) 113 | } else { 114 | _ = c.response("注册成功") 115 | } 116 | return 117 | } 118 | if i := strings.Index(c.body.Content, "www.finkapp.cn"); i >= 0 { 119 | fink.Push(c.body.Content) 120 | } else { 121 | service.Push(context.Background(), service.MediaContent{ 122 | Content: c.body.Content, 123 | UserId: c.body.FromUserName, 124 | }) 125 | } 126 | 127 | _ = c.response(autoReplyContent + "😁") 128 | return 129 | } else if c.body.MsgType == string(wechat.WeiXinEventMsgType) { 130 | //如果是推送的订阅事件 131 | if c.body.Event == wechat.WeiXinSubscribeEvent { 132 | _ = c.response(autoReplyContent) 133 | } 134 | 135 | } 136 | _ = c.response("不支持的消息类型") 137 | } 138 | 139 | func (c *WeiXinController) responseBody(resp wechat.PassiveUserReplyMessage) { 140 | nonce := c.Ctx.Input.Query("nonce") 141 | timestamp := c.Ctx.Input.Query("timestamp") 142 | encryptType := c.Ctx.Input.Query("encrypt_type") 143 | 144 | if encryptType == wechat.EncryptTypeAES { 145 | c.Data["xml"] = resp 146 | _ = c.ServeXML() 147 | } else { 148 | body, err := c.wx.MakeEncryptResponseBody(resp.FromUserName.Text, resp.ToUserName.Text, resp.Content.Text, nonce, timestamp) 149 | if err != nil { 150 | logs.Error("解析微信消息失败 -> %+v", resp) 151 | _ = c.Ctx.Output.Body([]byte("success")) 152 | c.StopRun() 153 | } 154 | err = c.Ctx.Output.Body(body) 155 | } 156 | c.StopRun() 157 | } 158 | 159 | func (c *WeiXinController) response(content string) error { 160 | nonce := c.Ctx.Input.Query("nonce") 161 | timestamp := c.Ctx.Input.Query("timestamp") 162 | encryptType := c.Ctx.Input.Query("encrypt_type") 163 | 164 | if encryptType == wechat.EncryptTypeAES { 165 | c.Data["xml"] = wechat.PassiveUserReplyMessage{ 166 | ToUserName: wechat.Value(c.body.FromUserName), 167 | FromUserName: wechat.Value(c.body.ToUserName), 168 | CreateTime: wechat.Value(fmt.Sprintf("%d", time.Now().Unix())), 169 | MsgType: wechat.Value(string(wechat.WeiXinTextMsgType)), 170 | Content: wechat.Value(content), 171 | } 172 | return c.ServeXML() 173 | } else { 174 | body, err := c.wx.MakeEncryptResponseBody(c.body.ToUserName, c.body.FromUserName, content, nonce, timestamp) 175 | if err != nil { 176 | logs.Error("解析微信消息失败 -> %+v", c.body) 177 | _ = c.Ctx.Output.Body([]byte("success")) 178 | c.StopRun() 179 | return err 180 | } 181 | err = c.Ctx.Output.Body(body) 182 | c.StopRun() 183 | return err 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /douyin/douyin.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "log" 7 | "math/rand" 8 | "regexp" 9 | "time" 10 | 11 | "github.com/beego/beego/v2/core/logs" 12 | "github.com/go-resty/resty/v2" 13 | 14 | "github.com/lifei6671/douyinbot/internal/utils" 15 | ) 16 | 17 | var ( 18 | patternStr = `http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+` 19 | DefaultUserAgent = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0` 20 | //relRrlStr = `https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?reflow_source=reflow_page&item_ids=` 21 | //relRrlStr = `https://www.iesdouyin.com/aweme/v1/web/aweme/detail/?aid=1128&version_name=23.5.0&device_platform=android&os_version=2333&aweme_id=` 22 | relRrlStr = `https://www.douyin.com/aweme/v1/web/aweme/detail/?aid=1128&version_name=23.5.0&device_platform=android&os_version=2333&aweme_id=` 23 | //apiStr = `https://aweme.snssdk.com/aweme/v1/play/?radio=1080p&line=0&video_id=` 24 | src = rand.NewSource(time.Now().UnixNano()) 25 | //代码来源 https://github.com/wujunwei928/parse-video/blob/main/parser/douyin.go 26 | ) 27 | 28 | const ( 29 | // 6 bits to represent a letter index 30 | letterIdBits = 6 31 | // All 1-bits as many as letterIdBits 32 | letterIdMask = 1<= 0; { 69 | if remain == 0 { 70 | cache, remain = src.Int63(), letterIdMax 71 | } 72 | if idx := int(cache & letterIdMask); idx < len(letters) { 73 | b[i] = letters[idx] 74 | i-- 75 | } 76 | cache >>= letterIdBits 77 | remain-- 78 | } 79 | return string(b) 80 | } 81 | 82 | func (d *DouYin) Get(shardContent string) (Video, error) { 83 | defer func() { 84 | if err := recover(); err != nil { 85 | logs.Error("解析抖音结果失败 -> [err=%s]", err) 86 | } 87 | }() 88 | urlStr := d.pattern.FindString(shardContent) 89 | if urlStr == "" { 90 | return Video{}, errors.New("获取视频链接失败") 91 | } 92 | 93 | body, err := d.parseShareUrl(urlStr) 94 | if err != nil { 95 | logs.Error("解析抖音结果失败 -> [err=%s]", err) 96 | return Video{}, err 97 | } 98 | 99 | logs.Info("获取抖音视频成功 -> [resp=%s]", body) 100 | var result DouYinResult 101 | 102 | if err := json.Unmarshal([]byte(body), &result); err != nil { 103 | logs.Error("解析抖音结果失败 -> [err=%s]", err) 104 | return Video{}, err 105 | } 106 | if len(result.VideoData.NwmVideoUrlHQ) == 0 && len(result.VideoData.NwmVideoUrl) == 0 { 107 | logs.Error("解析抖音结果失败 -> [err=%s]", body) 108 | return Video{}, errors.New(body) 109 | } 110 | 111 | video := Video{ 112 | RawLink: shardContent, 113 | VideoRawAddr: urlStr, 114 | PlayRawAddr: result.Url, 115 | Images: []ImageItem{}, 116 | } 117 | 118 | video.PlayAddr = result.VideoData.NwmVideoUrl 119 | if result.VideoData.NwmVideoUrlHQ != "" { 120 | video.PlayAddr = result.VideoData.NwmVideoUrlHQ 121 | } 122 | 123 | logs.Info("视频时长 [duration=%d]", result.Music.Duration) 124 | //获取播放时长,视频有播放时长,图文类无播放时长 125 | if result.Type == "video" { 126 | video.VideoType = VideoPlayType 127 | } else { 128 | video.VideoType = ImagePlayType 129 | } 130 | //获取播放地址 131 | video.PlayId = result.Url 132 | 133 | //获取视频唯一id 134 | logs.Info("唯一ID [aweme_id=%s]", result.AwemeId) 135 | video.VideoId = result.AwemeId 136 | 137 | //解析图片 138 | if len(result.Images) > 0 { 139 | for _, image := range result.Images { 140 | video.Images = append(video.Images, ImageItem{ 141 | ImageUrl: utils.First(image.URLList), 142 | ImageId: image.URI, 143 | }) 144 | } 145 | } 146 | 147 | //获取封面 148 | video.Cover = utils.First(result.CoverData.OriginCover.UrlList) 149 | //获取原始封面 150 | video.OriginCover = utils.First(result.CoverData.OriginCover.UrlList) 151 | 152 | if len(result.CoverData.DynamicCover.UrlList) > 0 { 153 | video.OriginCover = utils.First(result.CoverData.DynamicCover.UrlList) 154 | } 155 | 156 | video.OriginCoverList = result.CoverData.Cover.UrlList 157 | 158 | logs.Info("所有原始封面: %+v", video.OriginCoverList) 159 | 160 | //获取音乐地址 161 | video.MusicAddr = utils.First(result.Music.PlayUrl.UrlList) 162 | 163 | //获取作者id 164 | video.Author.Id = result.Author.Uid 165 | 166 | video.Author.ShortId = result.Author.ShortId 167 | 168 | video.Author.Nickname = result.Author.Nickname 169 | 170 | video.Author.Signature = result.Author.Signature 171 | 172 | //获取视频描述 173 | video.Desc = result.Desc 174 | 175 | //回获取作者大头像 176 | video.Author.AvatarLarger = utils.First(result.Author.AvatarThumb.UrlList) 177 | 178 | logs.Info("解析后数据 [video=%s]", video.String()) 179 | return video, nil 180 | } 181 | 182 | func (d *DouYin) GetVideoInfo(reqUrl string) (string, error) { 183 | return d.parseShareUrl(reqUrl) 184 | } 185 | 186 | func (d *DouYin) parseShareUrl(shareUrl string) (string, error) { 187 | proxyURL := d.proxy + "?url=" + shareUrl 188 | client := resty.New() 189 | 190 | log.Println(d.username, d.password) 191 | res, err := client.R(). 192 | SetHeader("User-Agent", DefaultUserAgent). 193 | SetBasicAuth(d.username, d.password). 194 | Get(proxyURL) 195 | 196 | // 这里会返回err, auto redirect is disabled 197 | if err != nil { 198 | return "", err 199 | } 200 | return string(res.Body()), nil 201 | } 202 | -------------------------------------------------------------------------------- /admin/views/index/content.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{.video.Nickname}}-抖音无水印-抖音型男集锦 9 | 10 | 11 | 12 | 13 |
14 | 15 | 34 |
35 | 36 |
37 |
38 |
39 |
40 |
41 | 46 |
47 | 51 |
{{str2html .video.Desc}}
52 |
53 |
54 |
55 | {{/*
当前问题:加载中...
*/}} 56 |
57 |
58 |
59 | 60 | 61 | 164 | 165 | -------------------------------------------------------------------------------- /wechat/wechat.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "crypto/sha1" 9 | "encoding/base64" 10 | "encoding/binary" 11 | "encoding/xml" 12 | "errors" 13 | "fmt" 14 | "github.com/beego/beego/v2/core/logs" 15 | "io" 16 | "log" 17 | "math/big" 18 | "sort" 19 | "strings" 20 | ) 21 | 22 | var defaultLetters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 23 | 24 | type WeiXin struct { 25 | appid string 26 | token string 27 | encodingKey string 28 | aesKey []byte 29 | } 30 | 31 | func NewWeiXin(appid, token, key string) *WeiXin { 32 | wx := &WeiXin{ 33 | appid: appid, 34 | token: token, 35 | encodingKey: key, 36 | } 37 | wx.aesKey = wx.EncodingAESKey2AESKey() 38 | return wx 39 | } 40 | 41 | func (w *WeiXin) ValidateMsg(timestamp, nonce, msgEncrypt, msgSignatureIn string) bool { 42 | msgSignatureGen := w.MakeMsgSignature(timestamp, nonce, msgEncrypt) 43 | return msgSignatureGen == msgSignatureIn 44 | } 45 | 46 | func (w *WeiXin) MakeMsgSignature(timestamp, nonce, msgEncrypt string) string { 47 | sl := []string{w.token, timestamp, nonce, msgEncrypt} 48 | sort.Strings(sl) 49 | s := sha1.New() 50 | _, _ = io.WriteString(s, strings.Join(sl, "")) 51 | return fmt.Sprintf("%x", s.Sum(nil)) 52 | } 53 | 54 | func (w *WeiXin) MakeSignature(timestamp, nonce string) string { //本地计算signature 55 | si := []string{w.token, timestamp, nonce} 56 | sort.Strings(si) //字典序排序 57 | str := strings.Join(si, "") //组合字符串 58 | s := sha1.New() //返回一个新的使用SHA1校验的hash.Hash接口 59 | _, err := io.WriteString(s, str) 60 | if err != nil { 61 | return "" 62 | } 63 | //WriteString函数将字符串数组str中的内容写入到s中 64 | return fmt.Sprintf("%x", s.Sum(nil)) 65 | } 66 | 67 | func (w *WeiXin) EncodingAESKey2AESKey() []byte { 68 | if w.aesKey == nil || len(w.aesKey) == 0 { 69 | data, _ := base64.StdEncoding.DecodeString(w.encodingKey + "=") 70 | w.aesKey = data 71 | } 72 | b := make([]byte, len(w.aesKey)) 73 | copy(b, w.aesKey) 74 | return b 75 | } 76 | 77 | func (w *WeiXin) aesDecrypt(cipherData []byte, aesKey []byte) ([]byte, error) { 78 | k := len(aesKey) //PKCS#7 79 | if len(cipherData)%k != 0 { 80 | return nil, errors.New("crypto/cipher: ciphertext size is not multiple of aes key length") 81 | } 82 | 83 | block, err := aes.NewCipher(aesKey) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | iv := make([]byte, aes.BlockSize) 89 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 90 | return nil, err 91 | } 92 | 93 | blockMode := cipher.NewCBCDecrypter(block, iv) 94 | plainData := make([]byte, len(cipherData)) 95 | blockMode.CryptBlocks(plainData, cipherData) 96 | return plainData, nil 97 | } 98 | 99 | func (w *WeiXin) PKCS7Pad(message []byte, blockSize int) (padded []byte) { 100 | // block size must be bigger or equal 2 101 | if blockSize < 1<<1 { 102 | panic("block size is too small (minimum is 2 bytes)") 103 | } 104 | // block size up to 255 requires 1 byte padding 105 | if blockSize < 1<<8 { 106 | // calculate padding length 107 | padLen := w.PadLength(len(message), blockSize) 108 | 109 | // define PKCS7 padding block 110 | padding := bytes.Repeat([]byte{byte(padLen)}, padLen) 111 | 112 | // apply padding 113 | padded = append(message, padding...) 114 | return padded 115 | } 116 | // block size bigger or equal 256 is not currently supported 117 | panic("unsupported block size") 118 | } 119 | 120 | func (w *WeiXin) PadLength(sliceLength, blockSize int) (padLen int) { 121 | padLen = blockSize - sliceLength%blockSize 122 | if padLen == 0 { 123 | padLen = blockSize 124 | } 125 | return padLen 126 | } 127 | 128 | func (w *WeiXin) ValidateAppId(id []byte) bool { 129 | return string(id) == w.appid 130 | } 131 | 132 | func (w *WeiXin) aesEncrypt(plainData []byte, aesKey []byte) ([]byte, error) { 133 | k := len(aesKey) 134 | if len(plainData)%k != 0 { 135 | plainData = w.PKCS7Pad(plainData, k) 136 | } 137 | fmt.Printf("aesEncrypt: after padding, plainData length = %d\n", len(plainData)) 138 | 139 | block, err := aes.NewCipher(aesKey) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | iv := make([]byte, aes.BlockSize) 145 | if _, err := io.ReadFull(rand.Reader, iv); err != nil { 146 | return nil, err 147 | } 148 | 149 | cipherData := make([]byte, len(plainData)) 150 | blockMode := cipher.NewCBCEncrypter(block, iv) 151 | blockMode.CryptBlocks(cipherData, plainData) 152 | 153 | return cipherData, nil 154 | } 155 | 156 | func (w *WeiXin) ParseEncryptTextRequestBody(plainText []byte) (*EncryptRequestBody, error) { 157 | // xml Decoding 158 | textRequestBody := &EncryptRequestBody{} 159 | err := xml.Unmarshal(plainText, textRequestBody) 160 | return textRequestBody, err 161 | } 162 | 163 | func (w *WeiXin) ParseEncryptRequestBody(timestamp, nonce, msgSignature string, rawBody []byte) (*TextRequestBody, error) { 164 | encryptRequestBody, err := w.ParseEncryptTextRequestBody(rawBody) 165 | if err != nil { 166 | return nil, err 167 | } 168 | // Validate msg signature 169 | if !w.ValidateMsg(timestamp, nonce, encryptRequestBody.Encrypt, msgSignature) { 170 | return nil, errors.New("校验数据来源失败") 171 | } 172 | // Decode base64 173 | cipherData, err := base64.StdEncoding.DecodeString(encryptRequestBody.Encrypt) 174 | if err != nil { 175 | log.Println("Wechat Service: Decode base64 error:", err) 176 | return nil, err 177 | } 178 | 179 | // AES Decrypt 180 | plainText, err := aesDecrypt(cipherData, w.aesKey) 181 | if err != nil { 182 | logs.Error("解密微信加密数据失败 ->", err) 183 | return nil, err 184 | } 185 | // Read length 186 | buf := bytes.NewBuffer(plainText[16:20]) 187 | var length int32 188 | binary.Read(buf, binary.BigEndian, &length) 189 | // appID validation 190 | appIDstart := 20 + length 191 | id := plainText[appIDstart : int(appIDstart)+len(w.appid)] 192 | if !w.ValidateAppId(id) { 193 | log.Println("Wechat Service: appid is invalid!") 194 | return nil, errors.New("Appid is invalid") 195 | } 196 | textRequestBody := &TextRequestBody{} 197 | err = xml.Unmarshal(plainText[20:20+length], textRequestBody) 198 | return textRequestBody, err 199 | } 200 | 201 | func (w *WeiXin) MakeEncryptXmlData(fromUserName, toUserName, timestamp, content string) (string, error) { 202 | textResponseBody := &PassiveUserReplyMessage{} 203 | textResponseBody.FromUserName = Value(fromUserName) 204 | textResponseBody.ToUserName = Value(toUserName) 205 | textResponseBody.MsgType = Value("text") 206 | textResponseBody.Content = Value(content) 207 | textResponseBody.CreateTime = Value(timestamp) 208 | 209 | body, err := xml.MarshalIndent(textResponseBody, " ", " ") 210 | if err != nil { 211 | return "", err 212 | } 213 | 214 | buf := new(bytes.Buffer) 215 | err = binary.Write(buf, binary.BigEndian, int32(len(body))) 216 | if err != nil { 217 | return "", err 218 | } 219 | bodyLength := buf.Bytes() 220 | 221 | randomBytes := []byte(w.randomString(16)) 222 | 223 | plainData := bytes.Join([][]byte{randomBytes, bodyLength, body, []byte(w.appid)}, nil) 224 | cipherData, err := w.aesEncrypt(plainData, w.aesKey) 225 | if err != nil { 226 | return "", err 227 | } 228 | 229 | return base64.StdEncoding.EncodeToString(cipherData), nil 230 | } 231 | 232 | func (w *WeiXin) MakeEncryptResponseBody(fromUserName, toUserName, content, nonce, timestamp string) ([]byte, error) { 233 | encryptBody := &EncryptResponseBody{} 234 | 235 | encryptXmlData, _ := w.MakeEncryptXmlData(fromUserName, toUserName, timestamp, content) 236 | encryptBody.Encrypt = Value(encryptXmlData) 237 | encryptBody.MsgSignature = Value(w.MakeMsgSignature(timestamp, nonce, encryptXmlData)) 238 | encryptBody.TimeStamp = timestamp 239 | encryptBody.Nonce = Value(nonce) 240 | 241 | return xml.MarshalIndent(encryptBody, " ", " ") 242 | } 243 | 244 | func (w *WeiXin) randomString(n int, allowedChars ...[]rune) string { 245 | var letters []rune 246 | 247 | if len(allowedChars) == 0 { 248 | letters = defaultLetters 249 | } else { 250 | letters = allowedChars[0] 251 | } 252 | 253 | b := make([]rune, n) 254 | for i := range b { 255 | num, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 256 | b[i] = letters[int(num.Int64())] 257 | } 258 | return string(b) 259 | } 260 | -------------------------------------------------------------------------------- /douyin/video.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "log" 11 | "net/http" 12 | "net/url" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | 18 | "github.com/beego/beego/v2/core/logs" 19 | 20 | "github.com/lifei6671/douyinbot/internal/utils" 21 | ) 22 | 23 | var ErrAnimatedWebP = errors.New("animated webp") 24 | 25 | type VideoType int 26 | 27 | const ( 28 | //VideoPlayType 视频类 29 | VideoPlayType VideoType = 0 30 | //ImagePlayType 图文类 31 | ImagePlayType VideoType = 1 32 | ) 33 | 34 | type Video struct { 35 | VideoId string `json:"video_id"` 36 | PlayId string `json:"play_id"` 37 | PlayAddr string `json:"play_addr"` 38 | VideoRawAddr string `json:"video_raw_addr"` 39 | PlayRawAddr string `json:"play_raw_addr"` 40 | Cover string `json:"cover"` 41 | OriginCover string `json:"origin_cover"` 42 | OriginCoverList []string `json:"origin_cover_list"` 43 | MusicAddr string `json:"music_addr"` 44 | Desc string `json:"desc"` 45 | RawLink string `json:"raw_link"` 46 | Author struct { 47 | Id string `json:"id"` 48 | ShortId string `json:"short_id"` 49 | Nickname string `json:"nickname"` 50 | AvatarLarger string `json:"avatar_larger"` 51 | Signature string `json:"signature"` 52 | } `json:"author"` 53 | Images []ImageItem `json:"images"` 54 | VideoType VideoType `json:"video_type"` 55 | } 56 | 57 | type ImageItem struct { 58 | ImageUrl string `json:"image_url"` 59 | ImageId string `json:"image_id"` 60 | } 61 | 62 | func (v *Video) GetFilename() string { 63 | if ext := filepath.Ext(v.PlayId); ext != "" { 64 | return v.VideoId + ext 65 | } 66 | return v.VideoId + ".mp4" 67 | } 68 | 69 | // Download 下载视频文件到指定目录 70 | func (v *Video) Download(filename string) (string, error) { 71 | defer func() { 72 | if err := recover(); err != nil { 73 | logs.Error("出现panic: [filename=%s] [errmsg=%s]", filename, err) 74 | } 75 | }() 76 | filename, err := filepath.Abs(filename) 77 | if err != nil { 78 | log.Printf("获取报错地址失败 [filename=%s] [error=%+v]", filename, err) 79 | return "", err 80 | } 81 | filename = filepath.Join(filename, v.Author.Id, v.GetFilename()) 82 | log.Printf("文件名: [filename=%s]", filename) 83 | dir := filepath.Dir(filename) 84 | 85 | if _, err := os.Stat(dir); os.IsNotExist(err) { 86 | if err := os.MkdirAll(dir, 0755); err != nil { 87 | return "", err 88 | } 89 | } 90 | //如果是图片类,则将图片下载到指定目录 91 | if v.VideoType == ImagePlayType { 92 | imagePath := filepath.Join(dir, v.VideoId) 93 | if err := os.MkdirAll(imagePath, 0755); err != nil { 94 | log.Printf("创建目录失败 [path=%s]", imagePath) 95 | } 96 | for _, image := range v.Images { 97 | ext := ".jpeg" 98 | uri, err := url.Parse(image.ImageUrl) 99 | if err != nil { 100 | log.Printf("解析图片地址失败 [image_url=%s] [errmsg=%+v]", image.ImageUrl, err) 101 | } else { 102 | ext = filepath.Ext(uri.Path) 103 | } 104 | imageId := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(image.ImageId, "//", ""), "\\\\", "/"), "/", "-") 105 | imageName := filepath.Join(imagePath, imageId+ext) 106 | 107 | log.Printf("图片数据 [image_url=%s] [image_name=%s]", image.ImageUrl, imageName) 108 | req, err := http.NewRequest(http.MethodGet, image.ImageUrl, nil) 109 | if err != nil { 110 | logs.Error("下载图像出错 -> [play_id=%s] [image_url=%s] [errmsg=%+v]", v.PlayId, image.ImageUrl, err) 111 | continue 112 | } 113 | req.Header.Add("User-Agent", DefaultUserAgent) 114 | resp, err := http.DefaultClient.Do(req) 115 | if err != nil { 116 | logs.Error("获取图像响应出错 -> [play_id=%s] [image_url=%s] [errmsg=%+v]", v.PlayId, image.ImageUrl, err) 117 | continue 118 | } 119 | 120 | b, err := io.ReadAll(resp.Body) 121 | if err != nil { 122 | logs.Error("解析图像出错 -> [play_id=%s] [image_url=%s]", v.PlayId, image.ImageUrl) 123 | continue 124 | } 125 | _ = resp.Body.Close() 126 | err = os.WriteFile(imageName, b, 0755) 127 | if err != nil { 128 | logs.Error("保存图像出错 -> [play_id=%s] [image_url=%s]", v.PlayId, image.ImageUrl) 129 | continue 130 | } 131 | time.Sleep(time.Microsecond * 110) 132 | } 133 | //如果是图文,需要将音频和图像放入一个目录 134 | filename = filepath.Join(imagePath, filepath.Base(filename)) 135 | } 136 | req, err := http.NewRequest(http.MethodGet, v.PlayAddr, nil) 137 | if err != nil { 138 | return "", err 139 | } 140 | req.Header.Add("Accept", "*/*") 141 | req.Header.Add("User-Agent", DefaultUserAgent) 142 | req.Header.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,mt;q=0.5,ru;q=0.4,de;q=0.3") 143 | req.Header.Add("Referer", v.PlayAddr) 144 | req.Header.Add("Accept-Encoding", "identity;q=1, *;q=0") 145 | req.Header.Add("Pragma", "no-cache") 146 | 147 | resp, err := http.DefaultClient.Do(req) 148 | if err != nil { 149 | return "", err 150 | } 151 | if resp.StatusCode != http.StatusOK { 152 | b, _ := io.ReadAll(resp.Body) 153 | return "", fmt.Errorf("http error: status_code[%d] err_msg[%s]", resp.StatusCode, string(b)) 154 | } 155 | defer resp.Body.Close() 156 | 157 | f1, err := os.Create(filename) 158 | if err != nil { 159 | log.Printf("创建文件失败 [filename=%s] [errmsg=%+v]", filename, err) 160 | return "", err 161 | } 162 | defer f1.Close() 163 | _, err = io.Copy(f1, resp.Body) 164 | return filename, err 165 | } 166 | 167 | // DownloadCover 下载封面文件 168 | func (v *Video) DownloadCover(urlStr string, filename string) (string, error) { 169 | uri, err := url.ParseRequestURI(urlStr) 170 | if err != nil { 171 | logs.Error("解析封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 172 | return "", err 173 | } 174 | 175 | hash := md5.Sum([]byte(uri.Path)) 176 | hashStr := hex.EncodeToString(hash[:]) 177 | 178 | ext := filepath.Ext(uri.Path) 179 | 180 | filename = filepath.Join(filename, v.Author.Id, "cover", hashStr+ext) 181 | 182 | dir := filepath.Dir(filename) 183 | if _, err := os.Stat(dir); os.IsNotExist(err) { 184 | if err := os.MkdirAll(dir, 0755); err != nil { 185 | return "", err 186 | } 187 | } 188 | f, err := os.Create(filename) 189 | if err != nil { 190 | logs.Error("创建封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 191 | return "", err 192 | } 193 | defer utils.SafeClose(f) 194 | 195 | header := http.Header{} 196 | header.Add("Accept", "*/*") 197 | header.Add("Accept-Encoding", "identity;q=1, *;q=0") 198 | header.Add("User-Agent", DefaultUserAgent) 199 | header.Add("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,mt;q=0.5,ru;q=0.4,de;q=0.3") 200 | header.Add("Referer", urlStr) 201 | header.Add("Pragma", "no-cache") 202 | 203 | req, err := http.NewRequest(http.MethodGet, urlStr, nil) 204 | if err != nil { 205 | logs.Error("下载封面文件失败: url[%s] filename[%s] %+v", urlStr, filename, err) 206 | return "", err 207 | } 208 | req.Header = header 209 | resp, err := http.DefaultTransport.RoundTrip(req) 210 | if err != nil { 211 | return "", err 212 | } 213 | defer utils.SafeClose(resp.Body) 214 | if resp.StatusCode != http.StatusOK { 215 | b, _ := io.ReadAll(resp.Body) 216 | return "", fmt.Errorf("http error: status_code[%d] err_msg[%s]", resp.StatusCode, string(b)) 217 | } 218 | _, err = io.Copy(f, resp.Body) 219 | if err != nil { 220 | logs.Error("保存图片失败: %s %+v", urlStr, err) 221 | return "", err 222 | } 223 | if ext == "" { 224 | switch resp.Header.Get("Content-Type") { 225 | case "image/jpeg": 226 | ext = ".jpeg" 227 | case "image/png": 228 | ext = ".png" 229 | case "image/gif": 230 | ext = ".gif" 231 | case "image/webp": 232 | ext = ".webp" 233 | default: 234 | ext = ".jpg" 235 | } 236 | newPath := filename + ext 237 | if ext == ".webp" { 238 | if ok, err := utils.IsAnimatedWebP(filename); ok && err == nil { 239 | _ = os.Remove(filename) 240 | return "", ErrAnimatedWebP 241 | } 242 | } 243 | 244 | if err := os.Rename(filename, newPath); err == nil { 245 | 246 | filename = newPath 247 | } 248 | 249 | } 250 | 251 | if ext != ".webp" { 252 | newPath := strings.TrimSuffix(filename, ext) + ".webp" 253 | if oErr := utils.Image2Webp(filename, newPath); oErr == nil { 254 | _ = os.Remove(filename) 255 | return newPath, nil 256 | } else { 257 | logs.Error("转换 WebP 格式出错: %+v", oErr) 258 | } 259 | } 260 | 261 | logs.Info("保存封面成功: %s %s", urlStr, filename) 262 | return filename, nil 263 | } 264 | 265 | // GetDownloadUrl 获取下载链接 266 | func (v *Video) GetDownloadUrl() (string, error) { 267 | req, err := http.NewRequest(http.MethodGet, v.PlayAddr, nil) 268 | if err != nil { 269 | return "", err 270 | } 271 | req.Header.Add("User-Agent", DefaultUserAgent) 272 | resp, err := http.DefaultTransport.RoundTrip(req) 273 | if err != nil { 274 | return "", err 275 | } 276 | defer resp.Body.Close() 277 | lv := resp.Header.Get("Location") 278 | 279 | return lv, nil 280 | } 281 | 282 | func (v *Video) String() string { 283 | b, err := json.Marshal(v) 284 | if err != nil { 285 | logs.Error("编码失败 -> %s", err) 286 | } else { 287 | return string(b) 288 | } 289 | return fmt.Sprintf("%+v", *v) 290 | } 291 | -------------------------------------------------------------------------------- /baidu/structs.go: -------------------------------------------------------------------------------- 1 | package baidu 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/url" 10 | "os" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | var ( 16 | ErrRefreshTokenExpired = errors.New("refresh_token expired") 17 | ErrAccessTokenEmpty = errors.New("user not authorized ") 18 | ErrAccessTokenExpired = errors.New("access token expired") 19 | ) 20 | 21 | type ErrorResponse struct { 22 | Error string `json:"error"` 23 | ErrorDescription string `json:"error_description"` 24 | TokenResponse 25 | } 26 | 27 | func (e *ErrorResponse) String() string { 28 | return fmt.Sprintf("error: %s;error_description: %s", e.Error, e.ErrorDescription) 29 | } 30 | 31 | type TokenResponse struct { 32 | AccessToken string `json:"access_token"` 33 | ExpiresIn int64 `json:"expires_in"` 34 | RefreshToken string `json:"refresh_token"` 35 | Scope string `json:"scope"` 36 | SessionKey string `json:"session_key"` 37 | SessionSecret string `json:"session_secret"` 38 | CreateAt int64 `json:"-"` 39 | RefreshTokenCreateAt int64 `json:"-"` 40 | } 41 | 42 | func (t *TokenResponse) Clone() *TokenResponse { 43 | return &TokenResponse{ 44 | AccessToken: t.AccessToken, 45 | ExpiresIn: t.ExpiresIn, 46 | RefreshToken: t.RefreshToken, 47 | Scope: t.Scope, 48 | SessionKey: t.SessionKey, 49 | SessionSecret: t.SessionSecret, 50 | CreateAt: t.CreateAt, 51 | RefreshTokenCreateAt: t.RefreshTokenCreateAt, 52 | } 53 | } 54 | func (t *TokenResponse) IsExpired() bool { 55 | return time.Now().Unix() >= t.CreateAt+t.ExpiresIn 56 | } 57 | 58 | func (t *TokenResponse) IsRefreshTokenExpired() bool { 59 | return time.Now().AddDate(-10, 0, 0).Unix() >= t.RefreshTokenCreateAt 60 | } 61 | 62 | type UserInfo struct { 63 | ErrNo int `json:"errno"` 64 | ErrMsg string `json:"errmsg"` 65 | BaiduName string `json:"baidu_name"` 66 | NetdiskName string `json:"netdisk_name"` 67 | AvatarUrl string `json:"avatar_url"` 68 | VipType int `json:"vip_type"` 69 | UserId int `json:"uk"` 70 | } 71 | 72 | func (u *UserInfo) Clone() *UserInfo { 73 | return &UserInfo{ 74 | ErrNo: u.ErrNo, 75 | ErrMsg: u.ErrMsg, 76 | BaiduName: u.BaiduName, 77 | NetdiskName: u.NetdiskName, 78 | AvatarUrl: u.AvatarUrl, 79 | VipType: u.VipType, 80 | UserId: u.UserId, 81 | } 82 | } 83 | func (u *UserInfo) String() string { 84 | b, _ := json.Marshal(u) 85 | return string(b) 86 | } 87 | 88 | type PreCreateUploadFileParam struct { 89 | Path string `json:"path"` 90 | Size int `json:"size"` 91 | IsDir bool `json:"is_dir"` 92 | AutoInit int `json:"autoinit"` 93 | RType int `json:"rtype"` 94 | UploadId string `json:"uploadid"` 95 | BlockList []string `json:"block_list"` 96 | ContentMD5 string `json:"content_md5"` 97 | SliceMD5 string `json:"slice_md5"` 98 | LocalCTime int64 `json:"local_ctime"` 99 | LocalMTime int64 `json:"local_mtime"` 100 | } 101 | 102 | func NewPreCreateUploadFileParam(filename string, path string) (*PreCreateUploadFileParam, error) { 103 | reader, err := os.Open(filename) 104 | if err != nil { 105 | return nil, err 106 | } 107 | defer reader.Close() 108 | info, err := reader.Stat() 109 | if err != nil { 110 | return nil, err 111 | } 112 | b := make([]byte, 4096*1024) 113 | 114 | blockList := make([]string, 0) 115 | for { 116 | n, err := io.ReadFull(reader, b) 117 | if err == io.EOF { 118 | break 119 | } 120 | has := md5.Sum(b[:n]) 121 | md5str1 := fmt.Sprintf("%x", has) 122 | blockList = append(blockList, md5str1) 123 | } 124 | return &PreCreateUploadFileParam{ 125 | Path: path, 126 | Size: int(info.Size()), 127 | IsDir: false, 128 | AutoInit: 1, 129 | RType: 3, 130 | BlockList: blockList, 131 | LocalCTime: info.ModTime().Unix(), 132 | LocalMTime: time.Now().Unix(), 133 | }, nil 134 | } 135 | 136 | func (u *PreCreateUploadFileParam) Values() url.Values { 137 | values := url.Values{} 138 | values.Add("path", u.Path) 139 | values.Add("size", fmt.Sprintf("%d", u.Size)) 140 | if u.IsDir { 141 | values.Add("is_dir", "1") 142 | } else { 143 | values.Add("is_dir", "0") 144 | } 145 | values.Add("autoinit", "1") 146 | if u.RType > 0 { 147 | values.Add("rtype", fmt.Sprintf("%d", u.RType)) 148 | } else { 149 | values.Add("rtype", "0") 150 | } 151 | if u.UploadId != "" { 152 | values.Add("uploadid", u.UploadId) 153 | } 154 | if u.BlockList != nil && len(u.BlockList) > 0 { 155 | values.Add("block_list", fmt.Sprintf("[\"%s\"]", strings.Join(u.BlockList, "\",\""))) 156 | } 157 | if u.ContentMD5 != "" { 158 | values.Add("content-md5", u.ContentMD5) 159 | } 160 | if u.SliceMD5 != "" { 161 | values.Add("slice-md5", u.SliceMD5) 162 | } 163 | if u.LocalMTime > 0 { 164 | values.Add("local_ctime", fmt.Sprintf("%d", u.LocalCTime)) 165 | } 166 | if u.LocalMTime > 0 { 167 | values.Add("local_mtime", fmt.Sprintf("%d", u.LocalMTime)) 168 | } 169 | 170 | return values 171 | } 172 | 173 | func (u *PreCreateUploadFileParam) String() string { 174 | b, _ := json.Marshal(u) 175 | return string(b) 176 | } 177 | 178 | type PreCreateUploadFile struct { 179 | ErrNo int `json:"errno"` 180 | Path string `json:"path"` 181 | UploadId string `json:"uploadid"` 182 | ReturnType int `json:"return_type"` 183 | BlockList []int `json:"block_list"` 184 | Info UploadFileInfo `json:"info,omitempty"` 185 | } 186 | 187 | func (u *PreCreateUploadFile) String() string { 188 | b, _ := json.Marshal(u) 189 | return string(b) 190 | } 191 | 192 | type UploadFileInfo struct { 193 | Size int `json:"size"` 194 | Category int `json:"category"` 195 | IsDir int `json:"is_dir"` 196 | Path string `json:"path"` 197 | FsId int64 `json:"fs_id"` 198 | MD5 string `json:"md5"` 199 | CTime int64 `json:"ctime"` 200 | MTime int64 `json:"mtime"` 201 | } 202 | 203 | func (f UploadFileInfo) String() string { 204 | b, _ := json.Marshal(&f) 205 | return string(b) 206 | } 207 | 208 | type SuperFileParam struct { 209 | AccessToken string 210 | Method string 211 | Type string 212 | Path string 213 | UploadId string 214 | PartSeq int 215 | } 216 | 217 | func (s *SuperFileParam) Values() url.Values { 218 | values := url.Values{} 219 | values.Add("access_token", s.AccessToken) 220 | values.Add("method", "upload") 221 | values.Add("type", "tmpfile") 222 | values.Add("path", s.Path) 223 | values.Add("uploadid", s.UploadId) 224 | values.Add("partseq", fmt.Sprintf("%d", s.PartSeq)) 225 | return values 226 | } 227 | 228 | type SuperFile struct { 229 | ErrorNo int `json:"error_code"` 230 | ErrorMsg string `json:"error_msg"` 231 | Md5 string `json:"md5"` 232 | RequestId uint64 `json:"request_id"` 233 | } 234 | 235 | type CreateFileParam struct { 236 | Path string `json:"path"` 237 | Size int `json:"size"` 238 | IsDir bool `json:"isdir"` 239 | RType int `json:"rtype"` 240 | UploadId string `json:"uploadid"` 241 | BlockList []string `json:"block_list"` 242 | LocalCTime int64 `json:"local_ctime"` 243 | LocalMTime int64 `json:"local_mtime"` 244 | ZipQuality int `json:"zip_quality"` 245 | ZipSign string `json:"zip_sign"` 246 | IsRevision int `json:"is_revision"` 247 | Mode int `json:"mode"` 248 | ExifInfo string `json:"exif_info"` 249 | } 250 | 251 | func NewCreateFileParam(path string, size int, isDir bool) *CreateFileParam { 252 | return &CreateFileParam{ 253 | Path: path, 254 | Size: size, 255 | IsDir: isDir, 256 | } 257 | } 258 | 259 | func (p *CreateFileParam) Values() url.Values { 260 | values := url.Values{} 261 | values.Add("path", p.Path) 262 | values.Add("size", fmt.Sprintf("%d", p.Size)) 263 | if p.IsDir { 264 | values.Add("isdir", "1") 265 | } else { 266 | values.Add("isdir", "0") 267 | } 268 | 269 | values.Add("rtype", fmt.Sprintf("%d", p.RType)) 270 | values.Add("uploadid", p.UploadId) 271 | if p.BlockList != nil && len(p.BlockList) > 0 { 272 | values.Add("block_list", fmt.Sprintf("[\"%s\"]", strings.Join(p.BlockList, "\",\""))) 273 | } 274 | if p.LocalCTime > 0 { 275 | values.Add("local_ctime", fmt.Sprintf("%d", p.LocalCTime)) 276 | } 277 | if p.LocalMTime > 0 { 278 | values.Add("local_mtime", fmt.Sprintf("%d", p.LocalMTime)) 279 | } 280 | if p.ZipQuality > 0 { 281 | values.Add("zip_quality", fmt.Sprintf("%d", p.ZipQuality)) 282 | } 283 | if p.ZipSign != "" { 284 | values.Add("zip_sign", p.ZipSign) 285 | } 286 | if p.Mode > 0 { 287 | values.Add("mode", fmt.Sprintf("%d", p.Mode)) 288 | } 289 | if p.ExifInfo != "" { 290 | values.Add("exif_info", p.ExifInfo) 291 | } 292 | return values 293 | } 294 | 295 | func (p *CreateFileParam) String() string { 296 | b, _ := json.Marshal(p) 297 | return string(b) 298 | } 299 | 300 | type CreateFile struct { 301 | UploadFileInfo 302 | ErrNo int `json:"errno"` 303 | ServerFilename string `json:"server_filename"` 304 | } 305 | 306 | func (p *CreateFile) String() string { 307 | b, _ := json.Marshal(p) 308 | return string(b) 309 | } 310 | -------------------------------------------------------------------------------- /admin/views/home/index.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 抖音无水印工具_最新抖音在线无水印解析 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 33 |
34 | 35 | 36 |
37 |
38 |
39 |
40 | 抖音无水印下载 41 |
42 |
43 |
44 |
45 | 46 | 47 | 例如:3.8 md:/ %健身 %减肥 %健康 https://v.douyin.com/ekQqQpC/ 腹制佌链接,打开Dou音搜索,直接观看視频! 48 |
49 | 50 | 51 | 52 | 53 | 54 |
55 |
56 |
57 |
58 |
59 |
简单说明
60 |

1. 本站只是提供解析的操作,所有视频的版权仍属于「字节跳动」。

61 |

2. 请勿用于任何商业用途,如有构成侵权的,本站概不负责,后果自负。

62 |

3. 突破抖音视频禁止保存视频、视频有效期一天、视频有水印的限制。

63 |

4. iOS用户以Safari为例,点击「查看视频」后,点一下左上角的第二个图标关闭全屏,然后点击下方中间的分享按钮,再点击「存储到文件」即可。安卓端由于没有设备,自行研究。

64 |

5. 本站解析之后的无水印视频不支持电脑端查看,抖音提供的URL并不支持电脑版的UA,不是我的问题。

65 |
66 |
67 |
68 |
69 | 70 |
71 |
72 | 抖音无水印下载. 73 |
74 |
75 | 76 | 77 | 78 | 226 | 227 | 228 | -------------------------------------------------------------------------------- /admin/service/service.go: -------------------------------------------------------------------------------- 1 | package service 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/url" 11 | "os" 12 | "path/filepath" 13 | "strings" 14 | "time" 15 | 16 | "github.com/beego/beego/v2/client/orm" 17 | "github.com/beego/beego/v2/core/logs" 18 | "github.com/beego/beego/v2/server/web" 19 | "golang.org/x/crypto/bcrypt" 20 | 21 | "github.com/lifei6671/douyinbot/admin/models" 22 | "github.com/lifei6671/douyinbot/douyin" 23 | "github.com/lifei6671/douyinbot/internal/utils" 24 | "github.com/lifei6671/douyinbot/storage" 25 | ) 26 | 27 | var ( 28 | ErrNoUserRegister = errors.New("不是用户注册") 29 | workerNum = 10 30 | videoShareChan = make(chan MediaContent, 100) 31 | accessKey = "" 32 | secretKey = "" 33 | bucketName = "" 34 | domain = "" 35 | savepath = "" 36 | 37 | fileClient storage.Storage 38 | ) 39 | 40 | type MediaContent struct { 41 | Content string 42 | UserId string 43 | } 44 | 45 | func Push(ctx context.Context, content MediaContent) { 46 | select { 47 | case videoShareChan <- content: 48 | case <-ctx.Done(): 49 | } 50 | } 51 | 52 | func Run(ctx context.Context) (err error) { 53 | if num, err := web.AppConfig.Int("workernumber"); err == nil && num > 0 { 54 | workerNum = num 55 | } 56 | if web.AppConfig.DefaultBool("s3_enable", false) { 57 | fileClient, err = storage.Factory("cloudflare", 58 | storage.WithBucketName(web.AppConfig.DefaultString("s3_bucket_name", "")), 59 | storage.WithAccountID(web.AppConfig.DefaultString("s3_account_id", "")), 60 | storage.WithAccessKeyID(web.AppConfig.DefaultString("s3_access_key_id", "")), 61 | storage.WithAccessKeySecret(web.AppConfig.DefaultString("s3_access_key_secret", "")), 62 | storage.WithEndpoint(web.AppConfig.DefaultString("s3_endpoint", "")), 63 | storage.WithDomain(web.AppConfig.DefaultString("s3_domain", "")), 64 | ) 65 | if err != nil { 66 | return fmt.Errorf("init storage err: %w", err) 67 | } 68 | } else if web.AppConfig.DefaultBool("qiniuenable", false) { 69 | accessKey, err = web.AppConfig.String("qiuniuaccesskey") 70 | if err != nil { 71 | logs.Error("获取七牛配置失败 -> [qiuniuaccesskey] - %+v", err) 72 | } 73 | secretKey, err = web.AppConfig.String("qiuniusecretkey") 74 | if err != nil { 75 | logs.Error("获取七牛配置失败 -> [qiuniusecretkey] - %+v", err) 76 | } 77 | bucketName, err = web.AppConfig.String("qiuniubucketname") 78 | if err != nil { 79 | logs.Error("获取七牛配置失败 -> [qiuniubucketname] - %+v", err) 80 | } 81 | domain, err = web.AppConfig.String("qiniudoamin") 82 | if err != nil { 83 | logs.Error("获取七牛配置失败 -> [qiniudoamin] - %+v", err) 84 | return err 85 | } 86 | } 87 | savepath, err = filepath.Abs(web.AppConfig.DefaultString("auto-save-path", "./")) 88 | if err != nil { 89 | logs.Error("获取本地储存目录失败 ->[auto-save-path] %+v", err) 90 | return err 91 | } 92 | for i := 0; i < workerNum; i++ { 93 | go execute(ctx) 94 | } 95 | return nil 96 | } 97 | 98 | func execute(ctx context.Context) { 99 | dy := douyin.NewDouYin( 100 | web.AppConfig.DefaultString("douyinproxy", ""), 101 | web.AppConfig.DefaultString("douyinproxyusername", ""), 102 | web.AppConfig.DefaultString("douyinproxypassword", ""), 103 | ) 104 | 105 | for { 106 | select { 107 | case content, ok := <-videoShareChan: 108 | if !ok { 109 | return 110 | } 111 | logs.Info("开始解析抖音视频任务 -> %s", content) 112 | video, err := dy.Get(content.Content) 113 | if err != nil { 114 | logs.Error("解析抖音视频地址失败 -> 【%s】- %+v", content, err) 115 | continue 116 | } 117 | logs.Info("开始下载抖音视频->%s", video) 118 | videoPath, err := video.Download(savepath) 119 | if err != nil { 120 | logs.Error("下载抖音视频失败 -> 【%s】- %+v", content, err) 121 | continue 122 | } 123 | coverURL := video.OriginCover 124 | 125 | coverPath, err := video.DownloadCover(video.OriginCover, savepath) 126 | if err != nil && errors.Is(err, douyin.ErrAnimatedWebP) { 127 | coverPath, err = video.DownloadCover(video.Cover, savepath) 128 | } 129 | if err != nil { 130 | logs.Error("下载封面失败 -> [cover=%s] [errmsg=%+v]", video.Cover, err) 131 | break 132 | } 133 | if err == nil { 134 | coverURL = strings.ReplaceAll("/"+strings.TrimPrefix(coverPath, savepath), "//", "/") 135 | } 136 | coverURL = "/cover" + coverURL 137 | 138 | name := strings.TrimPrefix(videoPath, savepath) 139 | 140 | // 将视频上传到S3服务器 141 | if urlStr, err := uploadFile(ctx, coverPath); err == nil { 142 | coverURL = urlStr 143 | } 144 | 145 | // 将封面上传到S3服务器 146 | if urlStr, err := uploadFile(ctx, videoPath); err == nil { 147 | video.PlayAddr = urlStr 148 | } 149 | 150 | user, err := models.NewUser().First(content.UserId) 151 | if err != nil { 152 | if errors.Is(err, orm.ErrNoRows) { 153 | user = models.NewUser() 154 | user.Id = 1 155 | } else { 156 | logs.Error("获取用户失败 -> %s - %+v", content, err) 157 | continue 158 | } 159 | } 160 | 161 | if baseDomain := web.AppConfig.DefaultString("douyin-base-url", ""); baseDomain != "" { 162 | if uri, err := url.ParseRequestURI(video.OriginCover); err == nil { 163 | originCover := strings.TrimPrefix(video.OriginCover, "https://") 164 | originCover = strings.TrimPrefix(originCover, "http://") 165 | originCover = strings.TrimPrefix(originCover, uri.Host) 166 | originCover = strings.ReplaceAll(originCover, uri.RawQuery, "") 167 | video.OriginCover = baseDomain + strings.ReplaceAll(originCover, "//", "/") 168 | } 169 | } 170 | if m, err := web.AppConfig.GetSection("nickname"); err == nil { 171 | if nickname, ok := m[video.Author.Id]; ok { 172 | video.Desc = "#" + nickname + " " + strings.TrimRight(video.Desc, ".") + " ." 173 | } 174 | } 175 | m := models.DouYinVideo{ 176 | UserId: user.Id, 177 | Nickname: video.Author.Nickname, 178 | Signature: video.Author.Signature, 179 | AvatarLarger: video.Author.AvatarLarger, 180 | AuthorId: video.Author.Id, 181 | AuthorShortId: video.Author.ShortId, 182 | VideoRawPlayAddr: video.VideoRawAddr, 183 | VideoPlayAddr: video.PlayAddr, 184 | VideoId: video.PlayId, 185 | AwemeId: video.VideoId, 186 | VideoCover: video.OriginCover, 187 | VideoLocalCover: coverURL, 188 | VideoLocalAddr: "/" + name, 189 | VideoBackAddr: string(""), 190 | Desc: video.Desc, 191 | RawLink: video.RawLink, 192 | } 193 | if err := m.Save(); err != nil { 194 | logs.Error("保存视频到数据库失败 -> 【%s】 - %+v", content, err) 195 | continue 196 | } 197 | 198 | if tagErr := models.NewDouYinTag().Create(video.Desc, m.VideoId); tagErr != nil { 199 | logs.Error("初始视频标签出错 -> %+v", tagErr) 200 | } 201 | 202 | if len(video.OriginCoverList) > 0 { 203 | expire, _ := utils.ParseExpireUnix(video.OriginCoverList[0]) 204 | cover := models.DouYinCover{ 205 | VideoId: m.VideoId, 206 | Cover: video.OriginCoverList[0], 207 | CoverImage: strings.Join(video.OriginCoverList, "|"), 208 | Expires: expire, 209 | } 210 | if err := cover.Save(m.VideoId); err != nil { 211 | logs.Error("保存封面失败:【%s】 - %+v", content, err) 212 | } 213 | } 214 | 215 | _, _ = downloadAvatar(ctx, &video) 216 | 217 | logs.Info("解析抖音视频成功 -> 【%s】- %s", content, m.VideoBackAddr) 218 | case cover, ok := <-_downloadQueue: 219 | if !ok { 220 | return 221 | } 222 | ExecDownloadQueue(cover) 223 | case <-ctx.Done(): 224 | return 225 | } 226 | } 227 | } 228 | 229 | func Register(content, wechatId string) error { 230 | if strings.HasPrefix(content, "注册#") { 231 | items := strings.Split(strings.TrimPrefix(content, "注册#"), "#") 232 | if len(items) != 3 || items[0] == "" || items[1] == "" || items[2] == "" { 233 | return errors.New("注册信息格式不正确") 234 | } 235 | if !strings.Contains(items[2], "@") { 236 | return errors.New("邮箱格式不正确") 237 | } 238 | user := models.NewUser() 239 | user.Account = items[0] 240 | password, err := bcrypt.GenerateFromPassword([]byte(items[1]), bcrypt.DefaultCost) 241 | if err != nil { 242 | logs.Error("加密密码失败 -> %+v", err) 243 | return errors.New("密码格式不正确") 244 | } 245 | user.Password = string(password) 246 | user.WechatId = wechatId 247 | user.Email = strings.TrimSpace(items[2]) 248 | err = user.Insert() 249 | if err != nil { 250 | logs.Error("注册用户失败 -> %+v - %+v", user, err) 251 | return errors.New("注册用户失败") 252 | } 253 | return nil 254 | } 255 | return ErrNoUserRegister 256 | } 257 | 258 | // 上传文件到S3服务器 259 | func uploadFile(ctx context.Context, filename string) (string, error) { 260 | if fileClient == nil { 261 | return filename, errors.New("file client is nil") 262 | } 263 | f, err := os.Open(filename) 264 | if err != nil { 265 | logs.Error("打开文件失败 -> %s - %+v", filename, err) 266 | return filename, err 267 | } 268 | defer f.Close() 269 | 270 | remoteFilename := strings.TrimPrefix(filename, savepath) 271 | 272 | urlStr, err := fileClient.WriteFile(ctx, f, strings.TrimPrefix(remoteFilename, "/")) 273 | if err != nil { 274 | logs.Error("上传文件失败 -> %s - %+v", filename, err) 275 | return "", err 276 | } 277 | return urlStr, nil 278 | } 279 | 280 | func downloadAvatar(ctx context.Context, video *douyin.Video) (string, error) { 281 | avatarURL := video.Author.AvatarLarger 282 | avatarPath, err := utils.DownloadCover(video.Author.Id, video.Author.AvatarLarger, savepath) 283 | if err == nil { 284 | avatarURL = strings.ReplaceAll("/"+strings.TrimPrefix(avatarPath, savepath), "//", "/") 285 | } 286 | avatarURL = "/cover" + avatarURL 287 | 288 | var user *models.DouYinUser 289 | var hashValue string 290 | if hashValue, err = calculateFileMD5(avatarPath); err == nil { 291 | user, err = models.NewDouYinUser().GetById(video.Author.Id) 292 | if err != nil && !errors.Is(err, orm.ErrNoRows) { 293 | logs.Error("查询用户信息失败 -> %+v", err) 294 | return avatarURL, err 295 | } 296 | if user != nil && user.HashValue == hashValue { 297 | return user.AvatarLarger, nil 298 | } 299 | } 300 | if user == nil { 301 | user = models.NewDouYinUser() 302 | user.Signature = video.Author.Signature 303 | user.AvatarLarger = avatarURL 304 | user.Created = time.Now() 305 | } 306 | user.AvatarLarger = avatarURL 307 | user.HashValue = hashValue 308 | user.Nickname = video.Author.Nickname 309 | user.AuthorId = video.Author.Id 310 | 311 | // 将封面上传到S3服务器 312 | if urlStr, err := uploadFile(ctx, avatarPath); err == nil { 313 | user.AvatarCDNURL = urlStr 314 | } 315 | if user.Id > 0 { 316 | err := user.Update() 317 | if err != nil { 318 | return "", err 319 | } 320 | } else if _, err = user.Create(); err != nil { 321 | return "", err 322 | } 323 | return user.AvatarLarger, nil 324 | } 325 | 326 | func calculateFileMD5(filePath string) (string, error) { 327 | // 打开文件 328 | file, err := os.Open(filePath) 329 | if err != nil { 330 | return "", err 331 | } 332 | defer file.Close() 333 | 334 | // 创建 MD5 哈希对象 335 | hash := md5.New() 336 | 337 | // 将文件内容写入哈希对象 338 | if _, err := io.Copy(hash, file); err != nil { 339 | return "", err 340 | } 341 | 342 | // 计算哈希值并返回十六进制表示 343 | return hex.EncodeToString(hash.Sum(nil)), nil 344 | } 345 | -------------------------------------------------------------------------------- /douyin/result.go: -------------------------------------------------------------------------------- 1 | package douyin 2 | 3 | type DouYinResult struct { 4 | Url string `json:"url"` 5 | Endpoint string `json:"endpoint"` 6 | TotalTime float64 `json:"total_time"` 7 | Status string `json:"status"` 8 | Message string `json:"message"` 9 | Type string `json:"type"` 10 | Platform string `json:"platform"` 11 | AwemeId string `json:"aweme_id"` 12 | OfficialApiUrl struct { 13 | UserAgent string `json:"User-Agent"` 14 | ApiUrl string `json:"api_url"` 15 | } `json:"official_api_url"` 16 | Desc string `json:"desc"` 17 | CreateTime int `json:"create_time"` 18 | Author struct { 19 | AvatarThumb struct { 20 | Height float64 `json:"height"` 21 | Uri string `json:"uri"` 22 | UrlList []string `json:"url_list"` 23 | Width float64 `json:"width"` 24 | } `json:"avatar_thumb"` 25 | CfList interface{} `json:"cf_list"` 26 | CloseFriendType int `json:"close_friend_type"` 27 | ContactsStatus int `json:"contacts_status"` 28 | ContrailList interface{} `json:"contrail_list"` 29 | CoverUrl []struct { 30 | Height float64 `json:"height"` 31 | Uri string `json:"uri"` 32 | UrlList []string `json:"url_list"` 33 | Width float64 `json:"width"` 34 | } `json:"cover_url"` 35 | CustomVerify string `json:"custom_verify"` 36 | DataLabelList interface{} `json:"data_label_list"` 37 | EndorsementInfoList interface{} `json:"endorsement_info_list"` 38 | EnterpriseVerifyReason string `json:"enterprise_verify_reason"` 39 | FamiliarVisitorUser interface{} `json:"familiar_visitor_user"` 40 | ImRoleIds interface{} `json:"im_role_ids"` 41 | IsAdFake bool `json:"is_ad_fake"` 42 | IsBan bool `json:"is_ban"` 43 | IsBlockedV2 bool `json:"is_blocked_v2"` 44 | IsBlockingV2 bool `json:"is_blocking_v2"` 45 | Nickname string `json:"nickname"` 46 | NotSeenItemIdList interface{} `json:"not_seen_item_id_list"` 47 | NotSeenItemIdListV2 interface{} `json:"not_seen_item_id_list_v2"` 48 | OfflineInfoList interface{} `json:"offline_info_list"` 49 | PersonalTagList interface{} `json:"personal_tag_list"` 50 | PreventDownload bool `json:"prevent_download"` 51 | RiskNoticeText string `json:"risk_notice_text"` 52 | SecUid string `json:"sec_uid"` 53 | ShareInfo struct { 54 | ShareDesc string `json:"share_desc"` 55 | ShareDescInfo string `json:"share_desc_info"` 56 | ShareQrcodeUrl struct { 57 | Uri string `json:"uri"` 58 | UrlList []string `json:"url_list"` 59 | } `json:"share_qrcode_url"` 60 | ShareTitle string `json:"share_title"` 61 | ShareTitleMyself string `json:"share_title_myself"` 62 | ShareTitleOther string `json:"share_title_other"` 63 | ShareUrl string `json:"share_url"` 64 | ShareWeiboDesc string `json:"share_weibo_desc"` 65 | } `json:"share_info"` 66 | ShortId string `json:"short_id"` 67 | Signature string `json:"signature"` 68 | SignatureExtra interface{} `json:"signature_extra"` 69 | SpecialFollowStatus int `json:"special_follow_status"` 70 | SpecialPeopleLabels interface{} `json:"special_people_labels"` 71 | Status int `json:"status"` 72 | TextExtra interface{} `json:"text_extra"` 73 | TotalFavorited int `json:"total_favorited"` 74 | Uid string `json:"uid"` 75 | UniqueId string `json:"unique_id"` 76 | UserAge int `json:"user_age"` 77 | UserCanceled bool `json:"user_canceled"` 78 | UserPermissions interface{} `json:"user_permissions"` 79 | VerificationType int `json:"verification_type"` 80 | } `json:"author"` 81 | Music struct { 82 | Album string `json:"album"` 83 | ArtistUserInfos interface{} `json:"artist_user_infos"` 84 | Artists []interface{} `json:"artists"` 85 | AuditionDuration int `json:"audition_duration"` 86 | Author string `json:"author"` 87 | AuthorDeleted bool `json:"author_deleted"` 88 | AuthorPosition interface{} `json:"author_position"` 89 | AuthorStatus int `json:"author_status"` 90 | AvatarLarge struct { 91 | Height int `json:"height"` 92 | Uri string `json:"uri"` 93 | UrlList []string `json:"url_list"` 94 | Width int `json:"width"` 95 | } `json:"avatar_large"` 96 | AvatarMedium struct { 97 | Height int `json:"height"` 98 | Uri string `json:"uri"` 99 | UrlList []string `json:"url_list"` 100 | Width int `json:"width"` 101 | } `json:"avatar_medium"` 102 | AvatarThumb struct { 103 | Height int `json:"height"` 104 | Uri string `json:"uri"` 105 | UrlList []string `json:"url_list"` 106 | Width int `json:"width"` 107 | } `json:"avatar_thumb"` 108 | BindedChallengeId int `json:"binded_challenge_id"` 109 | CanBackgroundPlay bool `json:"can_background_play"` 110 | Climax struct { 111 | StartPoint int `json:"start_point"` 112 | } `json:"climax"` 113 | CoverColorHsv struct { 114 | H int `json:"h"` 115 | S int `json:"s"` 116 | V int `json:"v"` 117 | } `json:"cover_color_hsv"` 118 | CoverHd struct { 119 | Height int `json:"height"` 120 | Uri string `json:"uri"` 121 | UrlList []string `json:"url_list"` 122 | Width int `json:"width"` 123 | } `json:"cover_hd"` 124 | CoverLarge struct { 125 | Height int `json:"height"` 126 | Uri string `json:"uri"` 127 | UrlList []string `json:"url_list"` 128 | Width int `json:"width"` 129 | } `json:"cover_large"` 130 | CoverMedium struct { 131 | Height int `json:"height"` 132 | Uri string `json:"uri"` 133 | UrlList []string `json:"url_list"` 134 | Width int `json:"width"` 135 | } `json:"cover_medium"` 136 | CoverThumb struct { 137 | Height int `json:"height"` 138 | Uri string `json:"uri"` 139 | UrlList []string `json:"url_list"` 140 | Width int `json:"width"` 141 | } `json:"cover_thumb"` 142 | DmvAutoShow bool `json:"dmv_auto_show"` 143 | Duration float64 `json:"duration"` 144 | EndTime int `json:"end_time"` 145 | ExternalSongInfo []interface{} `json:"external_song_info"` 146 | Extra string `json:"extra"` 147 | Id int64 `json:"id"` 148 | IdStr string `json:"id_str"` 149 | IsAudioUrlWithCookie bool `json:"is_audio_url_with_cookie"` 150 | IsCommerceMusic bool `json:"is_commerce_music"` 151 | IsDelVideo bool `json:"is_del_video"` 152 | IsMatchedMetadata bool `json:"is_matched_metadata"` 153 | IsOriginal bool `json:"is_original"` 154 | IsOriginalSound bool `json:"is_original_sound"` 155 | IsPgc bool `json:"is_pgc"` 156 | IsRestricted bool `json:"is_restricted"` 157 | IsVideoSelfSee bool `json:"is_video_self_see"` 158 | LunaInfo struct { 159 | HasCopyright bool `json:"has_copyright"` 160 | IsLunaUser bool `json:"is_luna_user"` 161 | } `json:"luna_info"` 162 | LyricShortPosition interface{} `json:"lyric_short_position"` 163 | Mid string `json:"mid"` 164 | MusicChartRanks interface{} `json:"music_chart_ranks"` 165 | MusicCollectCount int `json:"music_collect_count"` 166 | MusicCoverAtmosphereColorValue string `json:"music_cover_atmosphere_color_value"` 167 | MusicianUserInfos interface{} `json:"musician_user_infos"` 168 | OfflineDesc string `json:"offline_desc"` 169 | OwnerHandle string `json:"owner_handle"` 170 | OwnerId string `json:"owner_id"` 171 | OwnerNickname string `json:"owner_nickname"` 172 | PlayUrl struct { 173 | Height int `json:"height"` 174 | Uri string `json:"uri"` 175 | UrlKey string `json:"url_key"` 176 | UrlList []string `json:"url_list"` 177 | Width int `json:"width"` 178 | } `json:"play_url"` 179 | Redirect bool `json:"redirect"` 180 | SchemaUrl string `json:"schema_url"` 181 | SearchImpr struct { 182 | EntityId string `json:"entity_id"` 183 | } `json:"search_impr"` 184 | SecUid string `json:"sec_uid"` 185 | ShootDuration int `json:"shoot_duration"` 186 | Song struct { 187 | Artists interface{} `json:"artists"` 188 | Chorus struct { 189 | DurationMs int `json:"duration_ms"` 190 | StartMs int `json:"start_ms"` 191 | } `json:"chorus"` 192 | ChorusV3Infos interface{} `json:"chorus_v3_infos"` 193 | Id int64 `json:"id"` 194 | IdStr string `json:"id_str"` 195 | Title string `json:"title"` 196 | } `json:"song"` 197 | StrongBeatUrl struct { 198 | Height int `json:"height"` 199 | Uri string `json:"uri"` 200 | UrlList []string `json:"url_list"` 201 | Width int `json:"width"` 202 | } `json:"strong_beat_url"` 203 | TagList interface{} `json:"tag_list"` 204 | Title string `json:"title"` 205 | UnshelveCountries interface{} `json:"unshelve_countries"` 206 | UserCount int `json:"user_count"` 207 | VideoDuration int `json:"video_duration"` 208 | } `json:"music"` 209 | Statistics struct { 210 | AdmireCount int `json:"admire_count"` 211 | AwemeId string `json:"aweme_id"` 212 | CollectCount int `json:"collect_count"` 213 | CommentCount int `json:"comment_count"` 214 | DiggCount int `json:"digg_count"` 215 | PlayCount int `json:"play_count"` 216 | ShareCount int `json:"share_count"` 217 | } `json:"statistics"` 218 | CoverData struct { 219 | Cover struct { 220 | Height int `json:"height"` 221 | Uri string `json:"uri"` 222 | UrlList []string `json:"url_list"` 223 | Width int `json:"width"` 224 | } `json:"cover"` 225 | OriginCover struct { 226 | Height int `json:"height"` 227 | Uri string `json:"uri"` 228 | UrlList []string `json:"url_list"` 229 | Width int `json:"width"` 230 | } `json:"origin_cover"` 231 | DynamicCover struct { 232 | Height int `json:"height"` 233 | Uri string `json:"uri"` 234 | UrlList []string `json:"url_list"` 235 | Width int `json:"width"` 236 | } `json:"dynamic_cover"` 237 | } `json:"cover_data"` 238 | Hashtags []struct { 239 | End int `json:"end"` 240 | HashtagId string `json:"hashtag_id"` 241 | HashtagName string `json:"hashtag_name"` 242 | IsCommerce bool `json:"is_commerce"` 243 | Start int `json:"start"` 244 | Type int `json:"type"` 245 | } `json:"hashtags"` 246 | VideoData struct { 247 | WmVideoUrl string `json:"wm_video_url"` 248 | WmVideoUrlHQ string `json:"wm_video_url_HQ"` 249 | NwmVideoUrl string `json:"nwm_video_url"` 250 | NwmVideoUrlHQ string `json:"nwm_video_url_HQ"` 251 | } `json:"video_data"` 252 | Images []Image `json:"images"` 253 | } 254 | 255 | type Image struct { 256 | Height int `json:"height"` 257 | Width int `json:"width"` 258 | URLList []string `json:"url_list"` 259 | URI string `json:"uri"` 260 | } 261 | -------------------------------------------------------------------------------- /admin/static/js/video.js: -------------------------------------------------------------------------------- 1 | const wrapper = document.querySelector('.video-wrapper'); 2 | 3 | // 全局监听body下的play事件 4 | wrapper.addEventListener('play', (e) => { 5 | const video = e.target; 6 | if (video.tagName === 'VIDEO') { 7 | video.volume = 0.1; // 播放时设置音量 8 | } 9 | }, true); // 使用捕获阶段确保事件触发 10 | wrapper.addEventListener("click", (e) => { 11 | const video = e.target; 12 | if (video.tagName === 'VIDEO') { 13 | if (video.paused) { 14 | const playPromise = video.play(); 15 | console.log("播放结果", playPromise); 16 | e.preventDefault(); 17 | } else { 18 | video.pause(); 19 | e.preventDefault(); 20 | } 21 | } 22 | }); 23 | 24 | let videoItems = []; 25 | let currentIndex = 0; 26 | let currentPage = 1; 27 | const pageSize = 2; 28 | let isLoading = false; 29 | let hasMore = true; 30 | 31 | let startY = 0; 32 | let currentPosition = 0; 33 | let isAnimating = false; 34 | let isDragging = false; 35 | let wheelDelta = 0; 36 | let lastWheelTime = 0; 37 | 38 | // 新增视口高度计算函数 39 | function calculateViewport() { 40 | const vh = window.innerHeight * 0.01; 41 | document.documentElement.style.setProperty('--vh', `${vh}px`); 42 | 43 | // 更新所有视频项高度 44 | document.querySelectorAll('.video-item').forEach(item => { 45 | item.style.height = `${window.innerHeight}px`; 46 | }); 47 | 48 | // 更新容器高度 49 | const wrapper = document.querySelector('.video-wrapper'); 50 | wrapper.style.height = `${document.querySelectorAll('.video-item').length * window.innerHeight}px`; 51 | 52 | // 修正当前位置 53 | wrapper.style.transform = `translateY(-${currentIndex * window.innerHeight}px)`; 54 | } 55 | 56 | function back() { 57 | console.log(previousUrl) 58 | try { 59 | // 创建 URL 对象以便于比较协议、域名和端口 60 | const previousUrlObj = new URL(previousUrl); 61 | const currentUrlObj = new URL(currentUrl); 62 | // 比较协议、域名和端口是否相同 63 | const isSameOrigin = ( 64 | previousUrlObj.protocol === currentUrlObj.protocol && 65 | previousUrlObj.hostname === currentUrlObj.hostname && 66 | previousUrlObj.port === currentUrlObj.port 67 | ); 68 | if (isSameOrigin && previousUrl) { 69 | // 如果同源且上一页 URL 存在,则返回上一页 70 | window.history.back(); 71 | } else { 72 | // 否则返回首页,这里假设首页路径为根路径 '/' 73 | window.location.href = '/'; 74 | } 75 | } catch (e) { 76 | console.log(e); 77 | window.location.href = '/'; 78 | } 79 | } 80 | 81 | // 初始化时计算 82 | calculateViewport(); 83 | 84 | // 优化resize处理 85 | let resizeTimer; 86 | window.addEventListener('resize', () => { 87 | clearTimeout(resizeTimer); 88 | resizeTimer = setTimeout(() => { 89 | calculateViewport(); 90 | wrapper.style.transform = `translateY(-${currentIndex * window.innerHeight}px)`; 91 | }, 100); 92 | }); 93 | 94 | // 初始化位置 95 | updatePosition(currentIndex); 96 | 97 | // 触摸事件处理 98 | document.addEventListener('touchstart', handleTouchStart); 99 | document.addEventListener('touchmove', handleTouchMove); 100 | document.addEventListener('touchend', handleTouchEnd); 101 | 102 | // 鼠标滚轮事件处理 103 | document.addEventListener('wheel', handleWheel, {passive: false}); 104 | 105 | // 窗口大小变化处理 106 | window.addEventListener('resize', handleResize); 107 | 108 | function handleTouchStart(e) { 109 | if (isAnimating) return; 110 | startY = e.touches[0].clientY; 111 | currentPosition = -currentIndex * window.innerHeight; 112 | wrapper.classList.add('swipe-active'); 113 | isDragging = true; 114 | } 115 | 116 | function handleTouchMove(e) { 117 | // if (!isDragging) return; 118 | const deltaY = e.touches[0].clientY - startY; // 修正滑动方向 119 | // updateWrapperPosition(currentPosition + deltaY); 120 | // 添加边界弹性效果 121 | // const deltaY = startY - e.touches[0].clientY; 122 | const newPosition = currentPosition + deltaY; 123 | const maxPosition = 0; 124 | const minPosition = -(videoItems.length - 1) * window.innerHeight; 125 | let clampedPosition = newPosition; 126 | 127 | if (newPosition > maxPosition) { 128 | clampedPosition = maxPosition + (newPosition - maxPosition) * 0.3; 129 | } else if (newPosition < minPosition) { 130 | clampedPosition = minPosition + (newPosition - minPosition) * 0.3; 131 | } 132 | 133 | wrapper.style.transform = `translateY(${clampedPosition}px)`; 134 | } 135 | 136 | function handleTouchEnd(e) { 137 | if (!isDragging) return; 138 | isDragging = false; 139 | wrapper.classList.remove('swipe-active'); 140 | 141 | const endY = e.changedTouches[0].clientY; 142 | const deltaY = startY - endY; // 修正方向计算 143 | if (deltaY === 0) { 144 | const videoTarget = e.target.tagName; 145 | if (videoTarget === "VIDEO") { 146 | //如果滚动为0,则用户可能是点击了 147 | const video = videoItems[currentIndex].querySelector('video'); 148 | if (video.paused) { 149 | const playPromise = video.play(); 150 | console.log("播放结果", playPromise); 151 | } else { 152 | video.pause(); 153 | } 154 | e.preventDefault(); 155 | } 156 | } else { 157 | handleSwipe(deltaY); 158 | } 159 | } 160 | 161 | function handleWheel(e) { 162 | if (isAnimating) return; 163 | 164 | const now = Date.now(); 165 | const timeDiff = now - lastWheelTime; 166 | 167 | // 限制处理频率(最少50ms处理一次) 168 | if (timeDiff < 50) return; 169 | 170 | lastWheelTime = now; 171 | wheelDelta += e.deltaY; 172 | 173 | const threshold = 200; 174 | 175 | console.debug("鼠标滚动了", wheelDelta) 176 | if (Math.abs(wheelDelta) > threshold) { 177 | const direction = Math.sign(wheelDelta); 178 | wheelDelta = 0; 179 | handleSwipe(direction * threshold); 180 | } 181 | 182 | e.preventDefault(); 183 | } 184 | 185 | // 初始化加载第一页 186 | loadMoreVideos(); 187 | 188 | // 动态创建视频元素 189 | function createVideoElement(videoData) { 190 | videoID = videoData.video_id; 191 | const item = document.createElement('div'); 192 | item.className = 'video-item'; 193 | item.innerHTML = ` 194 | 201 | 206 |
207 |

@${videoData.nickname}

208 |

${videoData.desc}

209 |
210 | `; 211 | return item; 212 | } 213 | 214 | // 加载更多视频 215 | async function loadMoreVideos() { 216 | if (isLoading || !hasMore) return; 217 | 218 | showLoading(); 219 | isLoading = true; 220 | 221 | try { 222 | const params = new URLSearchParams({ 223 | video_id: videoID, 224 | action: "prev" 225 | }); 226 | // 实际API调用示例: 227 | // const response = await fetch(`/api/videos?page=${currentPage}&pageSize=${pageSize}`); 228 | // const mockData = await response.json(); 229 | let response = await fetch(`${nexURL}?${params.toString()}`); 230 | if (!response.ok) { 231 | throw new Error("请求失败"); 232 | } 233 | const mockData = await response.json(); 234 | if (mockData.errcode === 404) { 235 | hasMore = false; 236 | return; 237 | } 238 | wrapper.appendChild(createVideoElement(mockData.data)); 239 | 240 | 241 | videoItems = document.querySelectorAll('.video-item'); 242 | currentPage++; 243 | 244 | // 更新容器高度 245 | wrapper.style.height = `${videoItems.length * 100}vh`; 246 | 247 | } catch (error) { 248 | showError(); 249 | console.error('加载失败:', error); 250 | } finally { 251 | hideLoading(); 252 | isLoading = false; 253 | } 254 | } 255 | 256 | // 滑动处理函数(修改后) 257 | function handleSwipe(deltaY) { 258 | const threshold = 150; 259 | let targetIndex = currentIndex; 260 | 261 | if (Math.abs(deltaY) > threshold) { 262 | targetIndex = deltaY > 0 ? currentIndex + 1 : currentIndex - 1; 263 | } 264 | console.debug("鼠标滑动了", deltaY, targetIndex, currentIndex, videoItems.length); 265 | // 限制索引范围 266 | targetIndex = Math.max(0, Math.min(targetIndex, videoItems.length - 1)); 267 | 268 | 269 | if (targetIndex !== currentIndex) { 270 | videoItems[currentIndex].querySelector('video').pause(); 271 | currentIndex = targetIndex; 272 | isAnimating = true; 273 | animateTransition(); 274 | 275 | // 预加载检测 276 | if (currentIndex >= videoItems.length - 2 && hasMore) { 277 | loadMoreVideos(); 278 | } 279 | } else { 280 | animateRebound(); 281 | } 282 | } 283 | 284 | // 显示加载提示 285 | function showLoading() { 286 | document.getElementById('loadingIndicator').style.display = 'block'; 287 | } 288 | 289 | // 隐藏加载提示 290 | function hideLoading() { 291 | document.getElementById('loadingIndicator').style.display = 'none'; 292 | } 293 | 294 | // 显示错误提示 295 | function showError() { 296 | document.getElementById('errorIndicator').style.display = 'block'; 297 | } 298 | 299 | // 重试加载 300 | function retryLoading() { 301 | document.getElementById('errorIndicator').style.display = 'none'; 302 | loadMoreVideos(); 303 | } 304 | 305 | function updateWrapperPosition(yPos) { 306 | const maxPosition = 0; 307 | const minPosition = -(videoItems.length - 1) * window.innerHeight; 308 | 309 | const clampedPosition = Math.max(minPosition, Math.min(maxPosition, yPos)); 310 | console.debug("用户滑动了: yPost=", yPos, " minPosition=", minPosition, " clampedPosition=", clampedPosition); 311 | wrapper.style.transform = `translateY(${clampedPosition}px)`; 312 | } 313 | 314 | function animateTransition() { 315 | const targetY = currentIndex * window.innerHeight; 316 | // wrapper.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; 317 | // wrapper.style.transform = `translateY(-${targetY}px)`; 318 | 319 | wrapper.addEventListener('transitionend', () => { 320 | isAnimating = false; 321 | videoItems[currentIndex].querySelector('video').play(); 322 | }, {once: true}); 323 | 324 | wrapper.style.transition = 'transform 0.4s cubic-bezier(0.22, 0.61, 0.36, 1)'; 325 | wrapper.style.transform = `translateY(-${targetY}px)`; 326 | 327 | // 强制重绘修复iOS动画问题 328 | void wrapper.offsetHeight; 329 | } 330 | 331 | function animateRebound() { 332 | wrapper.style.transition = 'transform 0.3s ease-out'; 333 | wrapper.style.transform = `translateY(-${currentIndex * window.innerHeight}px)`; 334 | 335 | wrapper.addEventListener('transitionend', () => { 336 | isAnimating = false; 337 | }, {once: true}); 338 | } 339 | 340 | function handleResize() { 341 | updatePosition(currentIndex); 342 | } 343 | 344 | function updatePosition(index) { 345 | currentIndex = index; 346 | wrapper.style.transform = `translateY(-${index * window.innerHeight}px)`; 347 | } --------------------------------------------------------------------------------