├── 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 |
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 |
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 |
2 |
3 |
4 |
10 |
11 |
12 |
13 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/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 |
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 |
8 |
9 |
10 |
11 |
12 | {{if $.Nickname}}
13 | {{else}}
14 | @{{.Nickname}}
16 | {{end}}
17 | {{if .Desc}}
18 | {{str2html .Desc}}.
19 | {{end}}
20 |
21 |
22 |
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 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | 显示"{{.Nickname}}"的视频列表
43 |
44 | {{if .Desc}}
45 |
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 |
52 |
{{str2html .video.Desc}}
53 |
54 |
55 |
56 |
57 |
58 |
59 |
加载中...
60 |
61 | 加载失败,
62 |
63 |
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 |
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 |
34 |
35 |
36 |
37 |
38 |
39 |
42 |
56 |
57 |
58 |
59 |
简单说明
60 |
1. 本站只是提供解析的操作,所有视频的版权仍属于「字节跳动」。
61 |
2. 请勿用于任何商业用途,如有构成侵权的,本站概不负责,后果自负。
62 |
3. 突破抖音视频禁止保存视频、视频有效期一天、视频有水印的限制。
63 |
4. iOS用户以Safari为例,点击「查看视频」后,点一下左上角的第二个图标关闭全屏,然后点击下方中间的分享按钮,再点击「存储到文件」即可。安卓端由于没有设备,自行研究。
64 |
5. 本站解析之后的无水印视频不支持电脑端查看,抖音提供的URL并不支持电脑版的UA,不是我的问题。
65 |
66 |
67 |
68 |
69 |
70 |
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 |
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 | }
--------------------------------------------------------------------------------