├── cli
├── version
│ └── version.go
├── application
│ ├── func.go
│ ├── product.go
│ ├── login.go
│ └── course.go
└── cmds
│ ├── buy.go
│ ├── course.go
│ ├── cmds.go
│ ├── login.go
│ └── download.go
├── config
├── export.go
├── errors.go
├── geek.go
└── config.go
├── requester
├── requester.go
├── http_client.go
└── fetch.go
├── utils
├── chromedp_test.go
├── json.go
├── pool_test.go
├── pool.go
├── ffmpeg.go
├── utils_test.go
├── utils.go
└── chromedp.go
├── go.mod
├── service
├── func.go
├── user.go
├── errors.go
├── service.go
├── buy_product.go
├── response_handler.go
├── requester.go
├── course.go
└── course_types.go
├── main.go
├── LICENSE
├── .goreleaser.yml
├── downloader
├── pdf.go
├── types.go
└── downloader.go
├── aeswrap
├── aeswrap.go
└── aeswrap_test.go
├── login
└── login_client.go
├── README.md
├── go.sum
└── .gitignore
/cli/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | var (
4 | //Version version
5 | Version = "dev"
6 | )
7 |
--------------------------------------------------------------------------------
/config/export.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | type configJSONExport struct {
4 | AcitveUID int
5 | Geektimes Geektimes
6 | }
7 |
--------------------------------------------------------------------------------
/cli/application/func.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "github.com/udoless/geektime-downloader/config"
5 | "github.com/udoless/geektime-downloader/service"
6 | )
7 |
8 | func getService() *service.Service {
9 | return config.Instance.ActiveUserService()
10 | }
11 |
--------------------------------------------------------------------------------
/requester/requester.go:
--------------------------------------------------------------------------------
1 | package requester
2 |
3 | var (
4 | // UserAgent 浏览器标识
5 | UserAgent = "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
6 |
7 | //DefaultClient 默认 http 客户端
8 | DefaultClient = NewHTTPClient()
9 | )
10 |
--------------------------------------------------------------------------------
/utils/chromedp_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | func TestPrintToPdf(t *testing.T) {
9 | filename := "file.pdf"
10 | err := ColumnPrintToPDF(158248, filename, nil)
11 |
12 | if err != nil {
13 | t.Fatal("PrintToPDF test is failure", err)
14 | } else {
15 | os.Remove(filename)
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/utils/json.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "io"
5 |
6 | jsoniter "github.com/json-iterator/go"
7 | )
8 |
9 | //UnmarshalReader 将 r 中的 json 格式的数据, 解析到 v
10 | func UnmarshalReader(r io.Reader, v interface{}) error {
11 | d := jsoniter.NewDecoder(r)
12 | return d.Decode(v)
13 | }
14 |
15 | //UnmarshalJSON 将 r 中的 json 格式的数据, 解析到 v
16 | func UnmarshalJSON(data []byte, v interface{}) error {
17 | return jsoniter.Unmarshal(data, v)
18 | }
19 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/udoless/geektime-downloader
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/cheggaaa/pb v1.0.25
7 | github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a
8 | github.com/chromedp/chromedp v0.7.4
9 | github.com/json-iterator/go v1.1.9
10 | github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
11 | github.com/olekukonko/tablewriter v0.0.4
12 | github.com/sirupsen/logrus v1.5.0
13 | github.com/urfave/cli v1.22.4
14 | )
15 |
--------------------------------------------------------------------------------
/utils/pool_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "sync/atomic"
5 | "testing"
6 | )
7 |
8 | func TestPool(t *testing.T) {
9 | wgp := NewWaitGroupPool(10)
10 |
11 | number := 1000
12 | var loop int32
13 | for i := 0; i < number; i++ {
14 | wgp.Add()
15 | go func(loop *int32) {
16 | defer wgp.Done()
17 | atomic.AddInt32(loop, 1)
18 | }(&loop)
19 | }
20 | wgp.Wait()
21 |
22 | if int(loop) != number {
23 | t.Fatal("Pool test is failure")
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/service/func.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | )
7 |
8 | func deferResponseClose(s *http.Response) {
9 | if s != nil {
10 | defer s.Body.Close()
11 | }
12 | }
13 |
14 | //handleHTTPResponse handle
15 | func handleHTTPResponse(res *http.Response, err error) (io.ReadCloser, Error) {
16 | if err != nil {
17 | deferResponseClose(res)
18 | return nil, &ErrorInfo{Err: err}
19 | }
20 |
21 | if res.StatusCode == 452 {
22 | return nil, &ErrorInfo{Err: ErrLoginOffline}
23 | }
24 |
25 | return res.Body, nil
26 | }
27 |
--------------------------------------------------------------------------------
/cli/application/product.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "github.com/udoless/geektime-downloader/service"
5 | )
6 |
7 | //BuyProductAll product
8 | func BuyProductAll() (*service.ProductAll, error) {
9 | return getService().BuyProductAll()
10 | }
11 |
12 | //BuyColumns all columns
13 | func BuyColumns() (*service.Product, error) {
14 | all, err := BuyProductAll()
15 | return all.Columns, err
16 | }
17 |
18 | //BuyVideos all columns
19 | func BuyVideos() (*service.Product, error) {
20 | all, err := BuyProductAll()
21 | return all.Videos, err
22 | }
23 |
--------------------------------------------------------------------------------
/service/user.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | //User user info
4 | type User struct {
5 | UID int `json:"uid"`
6 | Nickname string `json:"nickname"`
7 | Avatar string `json:"avatar"`
8 | Cellphone string `json:"cellphone"`
9 | }
10 |
11 | //User user info
12 | func (s *Service) User() (*User, Error) {
13 | body, err := s.requestUser()
14 |
15 | if err != nil {
16 | return nil, err
17 | }
18 |
19 | defer body.Close()
20 |
21 | user := new(User)
22 | if err := handleJSONParse(body, &user); err != nil {
23 | return nil, err
24 | }
25 |
26 | return user, nil
27 | }
28 |
--------------------------------------------------------------------------------
/cli/application/login.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/udoless/geektime-downloader/login"
7 | )
8 |
9 | //Login login
10 | func Login(phone, password string) (gcess string, gcid string, serverID string, err error) {
11 | c := login.NewLoginClient()
12 | result := c.Login(phone, password)
13 | if !result.IsLoginSuccess() {
14 | return "", "", "", errors.New(result.Error.Msg)
15 | }
16 |
17 | return result.Data.GCID, result.Data.GCESS, result.Data.ServerID, nil
18 | }
19 |
20 | //LoginedCookies get logined cookies
21 | func LoginedCookies() map[string]string {
22 | return getService().Cookies()
23 | }
24 |
--------------------------------------------------------------------------------
/config/errors.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "errors"
4 |
5 | var (
6 | //ErrNotLogin 未登录帐号错误
7 | ErrNotLogin = errors.New("请先登录极客时间账户")
8 | //ErrHasLoginedNotLogin 有登录用户,但是当前并未有有效用户
9 | ErrHasLoginedNotLogin = errors.New("存在登录的用户,可以进行切换登录用户")
10 | //ErrConfigFilePathNotSet 未设置配置文件
11 | ErrConfigFilePathNotSet = errors.New("config file not set")
12 | //ErrConfigFileNotExist 未设置Config, 未初始化
13 | ErrConfigFileNotExist = errors.New("config file not exist")
14 | //ErrConfigFileNoPermission Config文件无权限访问
15 | ErrConfigFileNoPermission = errors.New("config file permission denied")
16 | //ErrConfigContentsParseError 解析Config数据错误
17 | ErrConfigContentsParseError = errors.New("config contents parse error")
18 | )
19 |
--------------------------------------------------------------------------------
/service/errors.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "errors"
4 |
5 | var (
6 | //ErrNotLogin not login
7 | ErrNotLogin = errors.New("当前未登录获取登录已失效,请先登录")
8 | //ErrLoginOffline not login
9 | ErrLoginOffline = errors.New("该账号已在其他同类设备登录,如非本人操作,则密码可能已经泄露,建议立即更换密码")
10 | )
11 |
12 | // Error 错误信息接口
13 | type Error interface {
14 | error
15 | IsUnlogin() bool
16 | }
17 |
18 | //ErrorInfo error info
19 | type ErrorInfo struct {
20 | Err error
21 | }
22 |
23 | //IsUnlogin 是否未登录
24 | func (e *ErrorInfo) IsUnlogin() bool {
25 | return e.Err == ErrNotLogin
26 | }
27 |
28 | func (e *ErrorInfo) Error() string {
29 | if e.Err != nil {
30 | return e.Err.Error()
31 | }
32 | return ""
33 | }
34 |
35 | func (e *ErrorInfo) String() string {
36 | return e.Error()
37 | }
38 |
--------------------------------------------------------------------------------
/requester/http_client.go:
--------------------------------------------------------------------------------
1 | package requester
2 |
3 | import (
4 | "net/http"
5 | "net/http/cookiejar"
6 | "time"
7 | )
8 |
9 | //HTTPClient client
10 | type HTTPClient struct {
11 | http.Client
12 | UserAgent string
13 | }
14 |
15 | //NewHTTPClient new client
16 | func NewHTTPClient() *HTTPClient {
17 | c := &HTTPClient{
18 | Client: http.Client{
19 | Timeout: 10 * time.Second,
20 | },
21 | }
22 |
23 | c.ResetCookieJar()
24 |
25 | return c
26 | }
27 |
28 | // SetUserAgent 设置 UserAgent 浏览器标识
29 | func (h *HTTPClient) SetUserAgent(ua string) {
30 | h.UserAgent = ua
31 | }
32 |
33 | //SetCookiejar 设置 Cookie
34 | func (h *HTTPClient) SetCookiejar(jar http.CookieJar) {
35 | h.Client.Jar = jar
36 | }
37 |
38 | //ResetCookieJar 重置cookie jar
39 | func (h *HTTPClient) ResetCookieJar() {
40 | h.Jar, _ = cookiejar.New(nil)
41 | }
42 |
43 | //SetTimeout 设置超时时间
44 | func (h *HTTPClient) SetTimeout(t time.Duration) {
45 | h.Client.Timeout = t
46 | }
47 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 |
8 | "github.com/sirupsen/logrus"
9 | "github.com/udoless/geektime-downloader/cli/cmds"
10 | "github.com/udoless/geektime-downloader/config"
11 | "github.com/urfave/cli"
12 | )
13 |
14 | func init() {
15 |
16 | err := config.Instance.Init()
17 | if err != nil {
18 | fmt.Println(err)
19 | }
20 | }
21 |
22 | func main() {
23 | f, err := os.OpenFile("log.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
24 | if err != nil {
25 | log.Fatalf("error opening file: %v", err)
26 | }
27 | defer f.Close()
28 |
29 | log.SetOutput(f)
30 |
31 | app := cmds.NewApp()
32 | app.Commands = []cli.Command{}
33 | app.Commands = append(app.Commands, cmds.NewLoginCommand()...)
34 | app.Commands = append(app.Commands, cmds.NewBuyCommand()...)
35 | app.Commands = append(app.Commands, cmds.NewCourseCommand()...)
36 |
37 | app.Action = cmds.DefaultAction
38 |
39 | if err := app.Run(os.Args); err != nil {
40 | logrus.Fatal(err)
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright 2020-present, udoless
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/utils/pool.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "math"
5 | "sync"
6 | )
7 |
8 | // WaitGroupPool pool of WaitGroup
9 | type WaitGroupPool struct {
10 | pool chan int
11 | wg *sync.WaitGroup
12 | }
13 |
14 | // NewWaitGroupPool creates a sized pool for WaitGroup
15 | func NewWaitGroupPool(size int) *WaitGroupPool {
16 | if size <= 0 {
17 | size = math.MaxInt32
18 | }
19 |
20 | return &WaitGroupPool{
21 | pool: make(chan int, size),
22 | wg: &sync.WaitGroup{},
23 | }
24 | }
25 |
26 | // Add adds delta, which may be negative, to the WaitGroup counter.
27 | // See sync.WaitGroup documentation for more information.
28 | func (p *WaitGroupPool) Add() {
29 | p.pool <- 1
30 | p.wg.Add(1)
31 | }
32 |
33 | // Done decrements the WaitGroup counter by one.
34 | // See sync.WaitGroup documentation for more information.
35 | func (p *WaitGroupPool) Done() {
36 | <-p.pool
37 | p.wg.Done()
38 | }
39 |
40 | // Wait blocks until the WaitGroup counter is zero.
41 | // See sync.WaitGroup documentation for more information.
42 | func (p *WaitGroupPool) Wait() {
43 | p.wg.Wait()
44 | }
45 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | project_name: geektime-dl
2 | env:
3 | - GO111MODULE=on
4 | builds:
5 | - env:
6 | - CGO_ENABLED=1
7 | binary: geektime-dl
8 | goos:
9 | - windows
10 | - darwin
11 | - linux
12 | goarch:
13 | - 386
14 | - amd64
15 | - arm
16 | - arm64
17 | ignore:
18 | - goos: linux
19 | goarch: arm
20 | goarm: 6
21 | - goos: darwin
22 | goarch: 386
23 | changelog:
24 | sort: asc
25 | filters:
26 | exclude:
27 | - '^docs'
28 | - '^tests'
29 | - Merge pull request
30 | - Merge branch
31 | archives:
32 | -
33 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}_v{{ .Arm }}{{ end }}'
34 | format: tar.gz
35 | format_overrides:
36 | - goos: windows
37 | format: zip
38 | files:
39 | - none*
40 | wrap_in_directory: false
41 | replacements:
42 | amd64: 64-bit
43 | 386: 32-bit
44 | arm: ARM
45 | arm64: ARM64
46 | darwin: macOS
47 | linux: Linux
48 | windows: Windows
49 | openbsd: OpenBSD
50 | netbsd: NetBSD
51 | freebsd: FreeBSD
52 | release:
53 | draft: true
54 |
--------------------------------------------------------------------------------
/downloader/pdf.go:
--------------------------------------------------------------------------------
1 | package downloader
2 |
3 | import (
4 | "fmt"
5 | "path/filepath"
6 | "strings"
7 |
8 | "github.com/udoless/geektime-downloader/utils"
9 | )
10 |
11 | var replacer = strings.NewReplacer(
12 | "/", "_",
13 | "\\", "_",
14 | "*", "_",
15 | ":", "_",
16 | "\"", "“",
17 | "<", "《",
18 | ">", "》",
19 | "|", "_",
20 | "?", "",
21 | )
22 |
23 | //PrintToPDF print to pdf
24 | func PrintToPDF(v Datum, cookies map[string]string, path string) error {
25 |
26 | name := utils.FileName(v.Title, "pdf")
27 | name = replacer.Replace(name)
28 |
29 | filename := filepath.Join(path, name)
30 | fmt.Printf("正在生成文件:【\033[37;1m%s\033[0m】 \n", name)
31 |
32 | _, exist, err := utils.FileSize(filename)
33 |
34 | if err != nil {
35 | fmt.Printf("\033[31;1m%s, err=%v\033[0m\n", "失败1", err)
36 | return err
37 | }
38 |
39 | if exist {
40 | fmt.Printf("\033[33;1m%s\033[0m\n", "已存在,跳过")
41 | return nil
42 | }
43 |
44 | err = utils.ColumnPrintToPDF(v.ID, filename, cookies)
45 |
46 | if err != nil {
47 | fmt.Printf("\033[31;1m%s, err=%v\033[0m\n", "失败2", err)
48 | return err
49 | }
50 |
51 | fmt.Printf("\033[32;1m%s\033[0m\n", "完成")
52 |
53 | return nil
54 | }
55 |
--------------------------------------------------------------------------------
/cli/cmds/buy.go:
--------------------------------------------------------------------------------
1 | package cmds
2 |
3 | import (
4 | "os"
5 | "strconv"
6 |
7 | "github.com/olekukonko/tablewriter"
8 | "github.com/udoless/geektime-downloader/cli/application"
9 | "github.com/urfave/cli"
10 | )
11 |
12 | //NewBuyCommand login command
13 | func NewBuyCommand() []cli.Command {
14 | return []cli.Command{
15 | {
16 | Name: "buy",
17 | Usage: "获取已购买过的专栏和视频课程",
18 | UsageText: appName + " buy",
19 | Action: buyAction,
20 | Before: authorizationFunc,
21 | },
22 | }
23 | }
24 |
25 | func buyAction(c *cli.Context) error {
26 | products, err := application.BuyProductAll()
27 |
28 | if err != nil {
29 | return err
30 | }
31 |
32 | table := tablewriter.NewWriter(os.Stdout)
33 | table.SetHeader([]string{"#", "ID", "类型", "名称", "作者"})
34 |
35 | i := 0
36 | for _, p := range products.Columns.List {
37 | table.Append([]string{strconv.Itoa(i), strconv.Itoa(p.Extra.ColumnID), products.Columns.Title, p.Title, p.Extra.AuthorName})
38 | i++
39 | }
40 |
41 | for _, p := range products.Videos.List {
42 | table.Append([]string{strconv.Itoa(i), strconv.Itoa(p.Extra.ColumnID), products.Videos.Title, p.Title, p.Extra.AuthorName})
43 | i++
44 | }
45 |
46 | table.Render()
47 |
48 | return nil
49 | }
50 |
--------------------------------------------------------------------------------
/service/service.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "net/http"
5 | "net/url"
6 |
7 | "github.com/udoless/geektime-downloader/requester"
8 | )
9 |
10 | var (
11 | geekBangCommURL = &url.URL{
12 | Scheme: "https",
13 | Host: "geekbang.org",
14 | }
15 | )
16 |
17 | //Service geek time service
18 | type Service struct {
19 | client *requester.HTTPClient
20 | }
21 |
22 | //NewService new service
23 | func NewService(gcid, gcess, serviceID string) *Service {
24 | client := requester.NewHTTPClient()
25 | client.ResetCookieJar()
26 | cookies := []*http.Cookie{}
27 | cookies = append(cookies, &http.Cookie{
28 | Name: "GCID",
29 | Value: gcid,
30 | Domain: "." + geekBangCommURL.Host,
31 | })
32 | cookies = append(cookies, &http.Cookie{
33 | Name: "GCESS",
34 | Value: gcess,
35 | Domain: "." + geekBangCommURL.Host,
36 | })
37 | cookies = append(cookies, &http.Cookie{
38 | Name: "SERVERID",
39 | Value: serviceID,
40 | Domain: "." + geekBangCommURL.Host,
41 | })
42 | client.Jar.SetCookies(geekBangCommURL, cookies)
43 |
44 | return &Service{client: client}
45 | }
46 |
47 | //Cookies get cookies string
48 | func (s *Service) Cookies() map[string]string {
49 | cookies := s.client.Jar.Cookies(geekBangCommURL)
50 |
51 | cstr := map[string]string{}
52 |
53 | for _, cookie := range cookies {
54 | cstr[cookie.Name] = cookie.Value
55 | }
56 |
57 | return cstr
58 | }
59 |
--------------------------------------------------------------------------------
/service/buy_product.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | // ProductAll all protuct
4 | type ProductAll struct {
5 | Columns *Product
6 | Videos *Product
7 | }
8 |
9 | //Product all product
10 | type Product struct {
11 | ID int `json:"id"`
12 | Title string `json:"title"`
13 | Page struct {
14 | More bool `json:"more"`
15 | Count int `json:"count"`
16 | } `json:"page"`
17 | List []struct {
18 | Title string `json:"title"`
19 | Conver string `json:"cover"`
20 | Type string `json:"type"`
21 | Extra struct {
22 | LastAid int `json:"last_aid"`
23 | ColumnID int `json:"column_id"`
24 | ColumnTitle string `json:"column_title"`
25 | ColumnSubtitle string `json:"column_subtitle"`
26 | AuthorName string `json:"author_name"`
27 | AuthorIntro string `json:"author_intro"`
28 | ColumnCover string `json:"column_cover"`
29 | ColumnType int `json:"column_type"`
30 | ArticleCount int `json:"article_count"`
31 | IsIncludeAudio bool `json:"is_include_audio"`
32 | } `json:"extra"`
33 | } `json:"list"`
34 | }
35 |
36 | //BuyProductAll 获取所有购买的课程信息
37 | func (s *Service) BuyProductAll() (*ProductAll, error) {
38 | body, err := s.requestBuyAll()
39 |
40 | if err != nil {
41 | return nil, err
42 | }
43 |
44 | defer body.Close()
45 |
46 | var products []*Product
47 |
48 | if err := handleJSONParse(body, &products); err != nil {
49 | return nil, err
50 | }
51 | productAll := &ProductAll{
52 | Columns: products[0],
53 | Videos: products[1],
54 | }
55 |
56 | return productAll, nil
57 | }
58 |
--------------------------------------------------------------------------------
/aeswrap/aeswrap.go:
--------------------------------------------------------------------------------
1 | package aeswrap
2 |
3 | import (
4 | "bytes"
5 | "crypto/aes"
6 | "crypto/cipher"
7 | )
8 |
9 | //@brief:填充明文
10 | func PKCS5Padding(plaintext []byte, blockSize int) []byte {
11 | padding := blockSize - len(plaintext)%blockSize
12 | padtext := bytes.Repeat([]byte{byte(padding)}, padding)
13 | return append(plaintext, padtext...)
14 | }
15 |
16 | //@brief:去除填充数据
17 | func PKCS5UnPadding(origData []byte) []byte {
18 | length := len(origData)
19 | unpadding := int(origData[length-1])
20 | return origData[:(length - unpadding)]
21 | }
22 |
23 | //@brief:AES加密
24 | func AesEncrypt(origData, key []byte) ([]byte, error) {
25 | block, err := aes.NewCipher(key)
26 | if err != nil {
27 | return nil, err
28 | }
29 |
30 | //AES分组长度为128位,所以blockSize=16,单位字节
31 | blockSize := block.BlockSize()
32 | origData = PKCS5Padding(origData, blockSize)
33 | blockMode := cipher.NewCBCEncrypter(block, key[:blockSize]) //初始向量的长度必须等于块block的长度16字节
34 | crypted := make([]byte, len(origData))
35 | blockMode.CryptBlocks(crypted, origData)
36 | return crypted, nil
37 | }
38 |
39 | //@brief:AES解密
40 | func AesDecrypt(crypted, key []byte) ([]byte, error) {
41 | block, err := aes.NewCipher(key)
42 | if err != nil {
43 | return nil, err
44 | }
45 |
46 | //AES分组长度为128位,所以blockSize=16,单位字节
47 | blockSize := block.BlockSize()
48 | blockMode := cipher.NewCBCDecrypter(block, key[:blockSize]) //初始向量的长度必须等于块block的长度16字节
49 | origData := make([]byte, len(crypted))
50 | blockMode.CryptBlocks(origData, crypted)
51 | origData = PKCS5UnPadding(origData)
52 | return origData, nil
53 | }
54 |
--------------------------------------------------------------------------------
/cli/cmds/course.go:
--------------------------------------------------------------------------------
1 | package cmds
2 |
3 | import (
4 | "os"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/olekukonko/tablewriter"
9 | "github.com/udoless/geektime-downloader/cli/application"
10 | "github.com/udoless/geektime-downloader/service"
11 | "github.com/urfave/cli"
12 | )
13 |
14 | //NewCourseCommand login command
15 | func NewCourseCommand() []cli.Command {
16 | return []cli.Command{
17 | {
18 | Name: "column",
19 | Usage: "获取专栏列表",
20 | UsageText: appName + " column",
21 | Action: columnAction,
22 | },
23 | {
24 | Name: "video",
25 | Usage: "获取视频课程列表",
26 | UsageText: appName + " video",
27 | Action: videoAction,
28 | },
29 | }
30 | }
31 |
32 | func columnAction(c *cli.Context) error {
33 | columns, err := application.Columns()
34 |
35 | if err != nil {
36 | return err
37 | }
38 |
39 | renderCourses(columns)
40 |
41 | return nil
42 | }
43 |
44 | func videoAction(c *cli.Context) error {
45 | videos, err := application.Videos()
46 |
47 | if err != nil {
48 | return err
49 | }
50 |
51 | renderCourses(videos)
52 |
53 | return nil
54 | }
55 |
56 | func renderCourses(courses []*service.Course) {
57 | table := tablewriter.NewWriter(os.Stdout)
58 | table.SetHeader([]string{"#", "ID", "名称", "时间", "作者", "购买"})
59 | table.SetAutoWrapText(false)
60 |
61 | for i, p := range courses {
62 | isBuy := ""
63 | if p.HadSub {
64 | isBuy = "是"
65 | }
66 | table.Append([]string{strconv.Itoa(i), strconv.Itoa(p.ID), p.ColumnTitle, time.Unix(int64(p.ColumnCtime), 0).Format("2006-01-02"), p.AuthorName, isBuy})
67 | }
68 |
69 | table.Render()
70 | }
71 |
--------------------------------------------------------------------------------
/aeswrap/aeswrap_test.go:
--------------------------------------------------------------------------------
1 | package aeswrap
2 |
3 | import (
4 | "encoding/base64"
5 | "fmt"
6 | "regexp"
7 | "testing"
8 | )
9 |
10 | func TestAes128(t *testing.T) {
11 | //key的长度必须是16、24或者32字节,分别用于选择AES-128, AES-192, or AES-256
12 | var aeskey = []byte("12345678abcdefgh")
13 | pass := []byte("vdncloud123456")
14 | xpass, err := AesEncrypt(pass, aeskey)
15 | if err != nil {
16 | fmt.Println(err)
17 | return
18 | }
19 |
20 | pass64 := base64.StdEncoding.EncodeToString(xpass)
21 | fmt.Printf("加密后:%v\n", pass64)
22 |
23 | bytesPass, err := base64.StdEncoding.DecodeString(pass64)
24 | if err != nil {
25 | fmt.Println(err)
26 | return
27 | }
28 |
29 | tpass, err := AesDecrypt(bytesPass, aeskey)
30 | if err != nil {
31 | fmt.Println(err)
32 | return
33 | }
34 | fmt.Printf("解密后:%s\n", tpass)
35 | }
36 |
37 | func TestRegex(t *testing.T) {
38 | a := "#EXT-X-KEY:METHOD=AES-128,URI=\"https://misc.geekbang.org/serv/v1/decrypt/decryptkms/?Ciphertext=MjUyNWM3ZTQtZGM4Mi00NzI4LTg3YjAtMjk1YWE5N2ViMDg5Sjh2TzJ1NERYU0IyazlEVFdLbGZZZitYMjBhL202U01BQUFBQUFBQUFBQXY2Y2cxNWQxM2pMZ1crWE8vWDR5WFdHUngxNldLbVlPVEp3enlTdC84RnFPYlJhK0g0QWVL&MediaId=c4dddb5fe1a64490939f0ec2e46186ef&MtsHlsUriToken=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2RlIjoiNDFlN2UzNzhlYjhjOGExYmZiODczODczNzQyYTIwZmZkNzIwN2ZhNCIsImV4cCI6MTY0OTIxNTc1MDQxMSwiZXh0cmEiOnsidmlkIjoiYzRkZGRiNWZlMWE2NDQ5MDkzOWYwZWMyZTQ2MTg2ZWYiLCJhaWQiOjIxODQsInVpZCI6MTI1Mjk0MCwicyI6IiJ9LCJzIjoyLCJ0IjoxLCJ2IjoxfQ.sacehfECZw3wyGj4kRFutptGOAT55hfq8aJJIOIkm3Y\""
39 | re := regexp.MustCompile("#EXT-X-KEY:METHOD=AES-128,URI=\"(.*)\"")
40 |
41 | fmt.Printf("%s\n", re.FindStringSubmatch(a)[1])
42 | }
43 |
--------------------------------------------------------------------------------
/utils/ffmpeg.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | )
9 |
10 | func runMergeCmd(cmd *exec.Cmd, paths []string, mergeFilePath string) error {
11 | var stderr bytes.Buffer
12 | cmd.Stderr = &stderr
13 | err := cmd.Run()
14 | if err != nil {
15 | return fmt.Errorf("%s\n%s", err, stderr.String())
16 | }
17 |
18 | if mergeFilePath != "" {
19 | os.Remove(mergeFilePath)
20 | }
21 | // remove parts
22 | for _, path := range paths {
23 | os.Remove(path)
24 | }
25 | return nil
26 | }
27 |
28 | // MergeAudioAndVideo merge audio and video
29 | func MergeAudioAndVideo(paths []string, mergedFilePath string) error {
30 | cmds := []string{
31 | "-y",
32 | }
33 | for _, path := range paths {
34 | cmds = append(cmds, "-i", path)
35 | }
36 | cmds = append(
37 | cmds, "-c:v", "copy", "-c:a", "copy",
38 | mergedFilePath,
39 | )
40 | return runMergeCmd(exec.Command("ffmpeg", cmds...), paths, "")
41 | }
42 |
43 | // MergeToMP4 merge video parts to MP4
44 | func MergeToMP4(paths []string, mergedFilePath string, filename string) error {
45 | mergeFilePath := filename + ".txt" // merge list file should be in the current directory
46 |
47 | // write ffmpeg input file list
48 | mergeFile, _ := os.Create(mergeFilePath)
49 | for _, path := range paths {
50 | mergeFile.Write([]byte(fmt.Sprintf("file '%s'\n", path)))
51 | }
52 | mergeFile.Close()
53 |
54 | cmd := exec.Command(
55 | "ffmpeg", "-y", "-f", "concat", "-safe", "false",
56 | "-i", mergeFilePath, "-c", "copy", "-bsf:a", "aac_adtstoasc", mergedFilePath,
57 | )
58 | return runMergeCmd(cmd, paths, mergeFilePath)
59 | }
60 |
--------------------------------------------------------------------------------
/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "os"
5 | "runtime"
6 | "testing"
7 | )
8 |
9 | func TestFilePath(t *testing.T) {
10 | type args struct {
11 | name string
12 | ext string
13 | escape bool
14 | }
15 | tests := []struct {
16 | name string
17 | args args
18 | want string
19 | }{
20 | {
21 | name: "normal test",
22 | args: args{
23 | name: "hello",
24 | ext: "txt",
25 | escape: false,
26 | },
27 | want: "hello.txt",
28 | },
29 | {
30 | name: "normal test",
31 | args: args{
32 | name: "hello:world",
33 | ext: "txt",
34 | escape: true,
35 | },
36 | want: "hello:world.txt",
37 | },
38 | }
39 | for _, tt := range tests {
40 | t.Run(tt.name, func(t *testing.T) {
41 | if got, _ := FilePath(tt.args.name, tt.args.ext, tt.args.escape); got != tt.want {
42 | t.Errorf("FilePath() = %v, want %v", got, tt.want)
43 | }
44 | })
45 | }
46 | }
47 |
48 | func TestMkdir(t *testing.T) {
49 | tests := []struct {
50 | name string
51 | path []string
52 | wantWin string
53 | wantUnix string
54 | }{
55 | {
56 | name: "a",
57 | path: []string{"a"},
58 | wantWin: "a",
59 | wantUnix: "a",
60 | },
61 | }
62 | for _, tt := range tests {
63 | t.Run(tt.name, func(t *testing.T) {
64 | got, _ := Mkdir(tt.path...)
65 |
66 | if runtime.GOOS == "windows" {
67 | if got != tt.wantWin {
68 | t.Errorf("Mkdir() = %v, want %v", got, tt.wantWin)
69 | }
70 | } else {
71 | if got != tt.wantUnix {
72 | t.Errorf("Mkdir() = %v, want %v", got, tt.wantUnix)
73 | }
74 | }
75 | os.RemoveAll(got)
76 | })
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/service/response_handler.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "errors"
5 | "io"
6 |
7 | "github.com/udoless/geektime-downloader/utils"
8 | )
9 |
10 | type resultData []byte
11 |
12 | func (rd *resultData) UnmarshalJSON(data []byte) error {
13 | *rd = data
14 |
15 | return nil
16 | }
17 |
18 | func (rd resultData) String() string {
19 | return string(rd)
20 | }
21 |
22 | type resultError struct {
23 | Code int `json:"code"`
24 | Msg string `json:"msg"`
25 | }
26 |
27 | func (re *resultError) UnmarshalJSON(data []byte) error {
28 | str := string(data)
29 | if str == "[]" {
30 | str = "{}"
31 | }
32 |
33 | type rError resultError
34 |
35 | e := new(rError)
36 | err := utils.UnmarshalJSON([]byte(str), &e)
37 | if err != nil {
38 | return err
39 | }
40 |
41 | *re = resultError(*e)
42 |
43 | return nil
44 | }
45 |
46 | // Result 从百度服务器解析的数据结构
47 | type Result struct {
48 | Code int `json:"code"`
49 | Data resultData `json:"data"`
50 | Error resultError `json:"error"`
51 | // Extra struct {
52 | // Cost float64 `json:"cost"`
53 | // RequestID string `json:"request-id"`
54 | // } `json:"extra"`
55 | }
56 |
57 | func (r *Result) isSuccess() bool {
58 | return r.Code == 0
59 | }
60 |
61 | func handleJSONParse(reader io.Reader, v interface{}) Error {
62 | result := new(Result)
63 |
64 | // b, _ := ioutil.ReadAll(reader)
65 | // a := string(b)
66 | // fmt.Println(a)
67 | err := utils.UnmarshalReader(reader, &result)
68 | if err != nil {
69 | return &ErrorInfo{Err: err}
70 | }
71 |
72 | if !result.isSuccess() {
73 | //未登录或者登录凭证无效
74 | if result.Error.Code == -3050 || result.Error.Code == -2000 {
75 | return &ErrorInfo{Err: ErrNotLogin}
76 | }
77 | return &ErrorInfo{Err: errors.New(result.Error.Msg)}
78 | }
79 |
80 | err = utils.UnmarshalJSON(result.Data, v)
81 | if err != nil {
82 | return &ErrorInfo{Err: err}
83 | }
84 |
85 | return nil
86 | }
87 |
--------------------------------------------------------------------------------
/cli/application/course.go:
--------------------------------------------------------------------------------
1 | package application
2 |
3 | import (
4 | "strings"
5 |
6 | "github.com/udoless/geektime-downloader/service"
7 | )
8 |
9 | //Columns 专栏列表
10 | func Columns() ([]*service.Course, error) {
11 | return getService().Columns()
12 | }
13 |
14 | //Videos 视频课程列表
15 | func Videos() ([]*service.Course, error) {
16 | return getService().Videos()
17 | }
18 |
19 | //CourseWithArticles course and articles info
20 | func CourseWithArticles(id int) (*service.Course, []*service.Article, error) {
21 | course, err := getService().ShowCourse(id)
22 | if err != nil {
23 | return nil, nil, err
24 | }
25 |
26 | course.ColumnTitle = strings.TrimSpace(course.ColumnTitle)
27 |
28 | articles, err := getService().Articles(id)
29 | if err != nil {
30 | return course, nil, err
31 | }
32 |
33 | return course, articles, nil
34 | }
35 |
36 | //GetVideoPlayInfo 获取视频播放信息
37 | func GetVideoPlayInfo(aid int, videoID string) (*service.VideoPlayInfo, error) {
38 | videoPlayAuth, err := VideoPlayAuth(aid, videoID)
39 | if err != nil {
40 | return nil, err
41 | }
42 |
43 | videoPlayInfo, err := VideoPlayInfo(videoPlayAuth.PlayAuth)
44 | if err != nil {
45 | return nil, err
46 | }
47 | return videoPlayInfo, nil
48 | }
49 |
50 | //VideoPlayAuth 获取视频的播放授权信息
51 | func VideoPlayAuth(aid int, videoID string) (*service.VideoPlayAuth, error) {
52 | videoPlayAuth, err := getService().VideoPlayAuth(aid, videoID)
53 |
54 | if err != nil {
55 | return nil, err
56 | }
57 |
58 | return videoPlayAuth, nil
59 | }
60 |
61 | //VideoPlayInfo 获取视频播放信息
62 | func VideoPlayInfo(playAuth string) (*service.VideoPlayInfo, error) {
63 | videoPlayInfo, err := getService().VideoPlayInfo(playAuth)
64 |
65 | if err != nil {
66 | return nil, err
67 | }
68 |
69 | return videoPlayInfo, nil
70 | }
71 |
72 | //VideoPlayInfo 获取视频播放信息
73 | func V3ArticleInfo(aid int) (*service.V3ArticleInfo, error) {
74 | v3ArticleInfo, err := getService().V3ArticleInfo(aid)
75 |
76 | if err != nil {
77 | return nil, err
78 | }
79 |
80 | return v3ArticleInfo, nil
81 | }
82 |
--------------------------------------------------------------------------------
/config/geek.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/udoless/geektime-downloader/service"
7 | )
8 |
9 | //User geek time user info
10 | type User struct {
11 | ID int `json:"id"`
12 | Name string `json:"name"`
13 | Avatar string `json:"avatar"`
14 | }
15 |
16 | //Geektime geek time info
17 | type Geektime struct {
18 | User
19 | PHONE string `json:"phone"`
20 | PASSWORD string `json:"password"`
21 | GCID string `json:"gcid"`
22 | GCESS string `json:"gcess"`
23 | ServerID string `json:"serverId"`
24 | Ticket string `json:"ticket"`
25 | CookieString string `json:"cookieString"`
26 | }
27 |
28 | //Service geek time service
29 | func (g *Geektime) Service() *service.Service {
30 | ser := service.NewService(g.GCID, g.GCESS, g.ServerID)
31 |
32 | return ser
33 | }
34 |
35 | //SetUserByGcidAndGcess set user
36 | func (c *ConfigsData) SetUserByGcidAndGcess(gcid, gcess, serverID, phone, password string) (*Geektime, error) {
37 | ser := service.NewService(gcid, gcess, serverID)
38 | user, err := ser.User()
39 | if err != nil {
40 | return nil, err
41 | }
42 |
43 | c.DeleteUser(&User{ID: user.UID})
44 |
45 | geektime := &Geektime{
46 | User: User{
47 | ID: user.UID,
48 | Name: user.Nickname,
49 | Avatar: user.Avatar,
50 | },
51 | PHONE: phone,
52 | PASSWORD: password,
53 | GCID: gcid,
54 | GCESS: gcess,
55 | ServerID: serverID,
56 | }
57 |
58 | c.Geektimes = append(c.Geektimes, geektime)
59 | // c.service = ser
60 |
61 | c.setActiveUser(geektime)
62 |
63 | return geektime, nil
64 | }
65 |
66 | //DeleteUser delete
67 | func (c *ConfigsData) DeleteUser(u *User) {
68 | for k, gk := range c.Geektimes {
69 | if gk.ID == u.ID {
70 | c.Geektimes = append(c.Geektimes[:k], c.Geektimes[k+1:]...)
71 | break
72 | }
73 | }
74 | }
75 |
76 | func (c *ConfigsData) setActiveUser(g *Geektime) {
77 | c.AcitveUID = g.ID
78 | c.activeUser = g
79 | }
80 |
81 | //LoginUserCount 登录用户数量
82 | func (c *ConfigsData) LoginUserCount() int {
83 | return len(c.Geektimes)
84 | }
85 |
86 | //SwitchUser switch user
87 | func (c *ConfigsData) SwitchUser(u *User) error {
88 | for _, g := range c.Geektimes {
89 | if g.ID == u.ID {
90 | c.setActiveUser(g)
91 | return nil
92 | }
93 | }
94 |
95 | return errors.New("用户不存在")
96 | }
97 |
--------------------------------------------------------------------------------
/downloader/types.go:
--------------------------------------------------------------------------------
1 | package downloader
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "regexp"
7 | "sort"
8 | "strconv"
9 |
10 | "github.com/olekukonko/tablewriter"
11 | )
12 |
13 | //URL for url information
14 | type URL struct {
15 | URL string `json:"url"`
16 | Size int `json:"size"`
17 | Ext string `json:"ext"`
18 | }
19 |
20 | //Data 课程信息
21 | type Data struct {
22 | Title string `json:"title"`
23 | Type string `json:"type"`
24 | Data []Datum `json:"articles"`
25 | }
26 |
27 | //Datum download information
28 | type Datum struct {
29 | ID int `json:"id"`
30 | Title string `json:"title"`
31 | Type string `json:"type"`
32 | IsCanDL bool `json:"is_can_dl"`
33 |
34 | Streams map[string]Stream `json:"streams"`
35 | sortedStreams []Stream
36 | }
37 |
38 | //Stream data
39 | type Stream struct {
40 | URLs []URL `json:"urls"`
41 | AesKeyBytes []byte
42 | Size int `json:"size"`
43 | Quality string `json:"quality"`
44 | name string
45 | }
46 |
47 | //VideoMediaMap 视频大小信息
48 | type VideoMediaMap struct {
49 | Size int `json:"size"`
50 | }
51 |
52 | //EmptyData empty data list
53 | var EmptyData = make([]Datum, 0)
54 |
55 | //PrintInfo print info
56 | func (data *Data) PrintInfo() {
57 | if len(data.Data) == 0 {
58 | fmt.Println(data.Type + "目录为空")
59 | return
60 | }
61 |
62 | table := tablewriter.NewWriter(os.Stdout)
63 |
64 | header := []string{"#", "ID", "类型", "名称"}
65 | for key := range data.Data[0].Streams {
66 | header = append(header, key)
67 | }
68 | header = append(header, "下载")
69 |
70 | table.SetHeader(header)
71 | table.SetAutoWrapText(false)
72 | i := 0
73 | for _, p := range data.Data {
74 | reg, _ := regexp.Compile(" \\| ")
75 | title := reg.ReplaceAllString(p.Title, " ")
76 |
77 | isCanDL := ""
78 | if p.IsCanDL {
79 | isCanDL = " ✔"
80 | }
81 |
82 | value := []string{strconv.Itoa(i), strconv.Itoa(p.ID), p.Type, title}
83 |
84 | if len(p.Streams) > 0 {
85 | for _, stream := range p.Streams {
86 | value = append(value, fmt.Sprintf("%.2fM", float64(stream.Size)/1024/1024))
87 | }
88 | } else {
89 | for range data.Data[0].Streams {
90 | value = append(value, " -")
91 | }
92 | }
93 |
94 | value = append(value, isCanDL)
95 |
96 | table.Append(value)
97 | i++
98 | }
99 | table.Render()
100 | }
101 |
102 | func (v *Datum) genSortedStreams() {
103 | for k, data := range v.Streams {
104 | if data.Size == 0 {
105 | data.calculateTotalSize()
106 | }
107 | data.name = k
108 | v.Streams[k] = data
109 | v.sortedStreams = append(v.sortedStreams, data)
110 | }
111 | if len(v.Streams) > 1 {
112 | sort.Slice(
113 | v.sortedStreams, func(i, j int) bool { return v.sortedStreams[i].Size > v.sortedStreams[j].Size },
114 | )
115 | }
116 | }
117 |
118 | func (stream *Stream) calculateTotalSize() {
119 |
120 | if stream.Size > 0 {
121 | return
122 | }
123 |
124 | size := 0
125 | for _, url := range stream.URLs {
126 | size += url.Size
127 | }
128 | stream.Size = size
129 | }
130 |
--------------------------------------------------------------------------------
/service/requester.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "io"
5 | "net/url"
6 | )
7 |
8 | //获取用户信息
9 | func (s *Service) requestUser() (io.ReadCloser, Error) {
10 | res, err := s.client.Req("POST", "https://account.geekbang.org/account/user", nil, map[string]string{"Origin": "https://account.geekbang.org"})
11 | return handleHTTPResponse(res, err)
12 | }
13 |
14 | //所有购买的课程
15 | func (s *Service) requestBuyAll() (io.ReadCloser, Error) {
16 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v1/my/products/all", nil, map[string]string{"Origin": "https://account.geekbang.org"})
17 | return handleHTTPResponse(res, err)
18 | }
19 |
20 | //所有课程
21 | func (s *Service) requestCourses(couseType int) (io.ReadCloser, Error) {
22 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v1/column/newAll", map[string]int{"type": couseType}, map[string]string{"Origin": "https://time.geekbang.org"})
23 | return handleHTTPResponse(res, err)
24 | }
25 |
26 | //获取课程信息
27 | func (s *Service) requestCourseDetail(ids []int) (io.ReadCloser, Error) {
28 | ii := map[string]interface{}{"ids": ids}
29 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v1/column/details", ii, map[string]string{"Origin": "https://time.geekbang.org"})
30 | return handleHTTPResponse(res, err)
31 | }
32 |
33 | //课程详细信息
34 | func (s *Service) requestCourseIntro(id int) (io.ReadCloser, Error) {
35 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v1/column/intro", map[string]interface{}{"cid": id, "with_groupbuy": true}, map[string]string{"Origin": "https://time.geekbang.org"})
36 | return handleHTTPResponse(res, err)
37 | }
38 |
39 | //课程的文章列表信息
40 | func (s *Service) requestCourseArticles(id int) (io.ReadCloser, Error) {
41 | data := map[string]interface{}{
42 | "cid": id,
43 | "order": "earliest",
44 | "prev": 0,
45 | "sample": false,
46 | "size": 500,
47 | }
48 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v1/column/articles", data, map[string]string{"Origin": "https://time.geekbang.org"})
49 | return handleHTTPResponse(res, err)
50 | }
51 |
52 | //获取文章信息
53 | func (s *Service) requestArticleInfo(aid int) (io.ReadCloser, Error) {
54 | data := map[string]interface{}{
55 | "id": aid,
56 | }
57 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v3/article/info", data, map[string]string{"Origin": "https://time.geekbang.org"})
58 | return handleHTTPResponse(res, err)
59 | }
60 |
61 | //获取视频的播放授权信息
62 | func (s *Service) requestVideoPlayAuth(aid int, videoID string) (io.ReadCloser, Error) {
63 | data := map[string]interface{}{
64 | "source_type": 1,
65 | "aid": aid,
66 | "video_id": videoID,
67 | }
68 | res, err := s.client.Req("POST", "https://time.geekbang.org/serv/v3/source_auth/video_play_auth", data, map[string]string{"Origin": "https://time.geekbang.org"})
69 | return handleHTTPResponse(res, err)
70 | }
71 |
72 | //获取视频的播放信息
73 | func (s *Service) requestVideoPlayInfo(playAuth string) (io.ReadCloser, Error) {
74 | res, err := s.client.Req("GET", "http://ali.mantv.top/play/info?playAuth="+url.QueryEscape(playAuth), nil, nil)
75 | return handleHTTPResponse(res, err)
76 | }
77 |
--------------------------------------------------------------------------------
/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "net/url"
7 | "os"
8 | "path/filepath"
9 | "regexp"
10 | "runtime"
11 | "strings"
12 |
13 | "github.com/udoless/geektime-downloader/requester"
14 | )
15 |
16 | // MAXLENGTH Maximum length of file name
17 | const MAXLENGTH = 80
18 |
19 | //FileName filter invalid string
20 | func FileName(name string, ext string) string {
21 | rep := strings.NewReplacer("\n", " ", "/", " ", "|", "-", ": ", ":", ":", ":", "'", "’", "\t", " ")
22 | name = rep.Replace(name)
23 |
24 | if runtime.GOOS == "windows" {
25 | rep := strings.NewReplacer("\"", " ", "?", " ", "*", " ", "\\", " ", "<", " ", ">", " ", ":", " ", ":", " ")
26 | name = rep.Replace(name)
27 | }
28 |
29 | name = strings.TrimSpace(name)
30 |
31 | limitedName := LimitLength(name, MAXLENGTH)
32 | if ext != "" {
33 | return fmt.Sprintf("%s.%s", limitedName, ext)
34 | }
35 | return limitedName
36 | }
37 |
38 | //LimitLength cut string
39 | func LimitLength(s string, length int) string {
40 | ellipses := "..."
41 |
42 | str := []rune(s)
43 | if len(str) > length {
44 | s = string(str[:length-len(ellipses)]) + ellipses
45 | }
46 |
47 | return s
48 | }
49 |
50 | // FilePath gen valid file path
51 | func FilePath(name, ext string, escape bool) (string, error) {
52 | var outputPath string
53 |
54 | var fileName string
55 | if escape {
56 | fileName = FileName(name, ext)
57 | } else {
58 | fileName = fmt.Sprintf("%s.%s", name, ext)
59 | }
60 | outputPath = filepath.Join(fileName)
61 | return outputPath, nil
62 | }
63 |
64 | //Mkdir mkdir path
65 | func Mkdir(elem ...string) (string, error) {
66 | path := filepath.Join(elem...)
67 |
68 | err := os.MkdirAll(path, os.ModePerm)
69 |
70 | return path, err
71 | }
72 |
73 | // FileSize return the file size of the specified path file
74 | func FileSize(filePath string) (int, bool, error) {
75 | file, err := os.Stat(filePath)
76 | if err != nil {
77 | if os.IsNotExist(err) {
78 | return 0, false, nil
79 | }
80 | return 0, false, err
81 | }
82 | return int(file.Size()), true, nil
83 | }
84 |
85 | var aesRegex = regexp.MustCompile("#EXT-X-KEY:METHOD=AES-128,URI=\"(.*)\"")
86 |
87 | // M3u8URLs get all ts urls from m3u8 url
88 | func M3u8URLsAndAesKey(uri string) ([]string, []byte, error) {
89 | if len(uri) == 0 {
90 | return nil, nil, errors.New("M3u8地址为空")
91 | }
92 |
93 | html, err := requester.HTTPGet(uri)
94 | if err != nil {
95 | return nil, nil, err
96 | }
97 | lines := strings.Split(string(html), "\n")
98 | var urls []string
99 | var aesBytes []byte
100 | for _, line := range lines {
101 | line = strings.TrimSpace(line)
102 | if strings.HasPrefix(line, "#EXT-X-KEY") {
103 | aesApi := aesRegex.FindStringSubmatch(line)[1]
104 | aesBytes, err = requester.HTTPGet(aesApi)
105 | if err != nil {
106 | return nil, nil, err
107 | }
108 | continue
109 | }
110 | if line != "" && !strings.HasPrefix(line, "#") {
111 | if strings.HasPrefix(line, "http") {
112 | urls = append(urls, line)
113 | } else {
114 | base, err := url.Parse(uri)
115 | if err != nil {
116 | continue
117 | }
118 | u, err := url.Parse(line)
119 | if err != nil {
120 | continue
121 | }
122 | urls = append(urls, fmt.Sprintf("%s", base.ResolveReference(u)))
123 | }
124 | }
125 | }
126 | return urls, aesBytes, nil
127 | }
128 |
--------------------------------------------------------------------------------
/cli/cmds/cmds.go:
--------------------------------------------------------------------------------
1 | package cmds
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "time"
9 |
10 | "github.com/sirupsen/logrus"
11 |
12 | "github.com/udoless/geektime-downloader/cli/version"
13 | "github.com/udoless/geektime-downloader/config"
14 | "github.com/udoless/geektime-downloader/utils"
15 | "github.com/urfave/cli"
16 | )
17 |
18 | var (
19 | _debug bool
20 | _info bool
21 | _stream string
22 | _pdf bool
23 | _mp3 bool
24 | appName = filepath.Base(os.Args[0])
25 | configSaveFunc = func(c *cli.Context) error {
26 | err := config.Instance.Save()
27 | if err != nil {
28 | return errors.New("保存配置错误:" + err.Error())
29 | }
30 | return nil
31 | }
32 | authorizationFunc = func(c *cli.Context) error {
33 | if config.Instance.AcitveUID <= 0 {
34 | if len(config.Instance.Geektimes) > 0 {
35 | return config.ErrHasLoginedNotLogin
36 | }
37 | return config.ErrNotLogin
38 | }
39 |
40 | return nil
41 | }
42 | )
43 |
44 | //NewApp cli app
45 | func NewApp() *cli.App {
46 | app := cli.NewApp()
47 | app.Name = appName
48 | app.Usage = "极客时间下载客户端"
49 | app.Version = fmt.Sprintf("%s", version.Version)
50 | cli.VersionPrinter = func(c *cli.Context) {
51 | fmt.Printf("%s version %s\n", app.Name, app.Version)
52 | }
53 | app.Flags = []cli.Flag{
54 | cli.BoolFlag{
55 | Name: "debug,d",
56 | Usage: "Turn on debug logs",
57 | Destination: &_debug,
58 | },
59 | cli.BoolFlag{
60 | Name: "info, i",
61 | Usage: "只输出视频信息",
62 | Destination: &_info,
63 | },
64 | cli.StringFlag{
65 | Name: "stream, s",
66 | Usage: "选择要下载的指定类型",
67 | Destination: &_stream,
68 | },
69 | cli.BoolFlag{
70 | Name: "pdf, p",
71 | Usage: "下载专栏PDF文档",
72 | Destination: &_pdf,
73 | },
74 | cli.BoolFlag{
75 | Name: "mp3, m",
76 | Usage: "下载专栏MP3音频",
77 | Destination: &_mp3,
78 | },
79 | }
80 |
81 | app.Before = func(c *cli.Context) error {
82 | if _debug {
83 | logrus.SetLevel(logrus.DebugLevel)
84 | }
85 | return nil
86 | }
87 |
88 | return app
89 | }
90 |
91 | //DefaultAction default action
92 | func DefaultAction(c *cli.Context) error {
93 | _, err := config.Instance.ActiveUserService().User()
94 | if err != nil {
95 | return err
96 | }
97 | loginCh := make(chan struct{})
98 | loginRespCh := make(chan struct{})
99 | go func() {
100 | for {
101 | select {
102 | case <-loginCh:
103 | fmt.Println("Relogin....")
104 | err := loginByPhoneAndPassword(config.Instance.ActiveUser().PHONE, config.Instance.ActiveUser().PASSWORD)
105 | if err != nil {
106 | fmt.Printf("login failed: %s\n", err)
107 | continue
108 | }
109 | err = configSaveFunc(c)
110 | if err != nil {
111 | fmt.Printf("config save failed: %s\n", err)
112 | continue
113 | }
114 | err = config.Instance.Init()
115 | if err != nil {
116 | fmt.Printf("config reinit failed: %s\n", err)
117 | continue
118 | }
119 | loginRespCh <- struct{}{}
120 | }
121 | time.Sleep(3 * time.Second)
122 | }
123 | }()
124 | utils.LoginCh = loginCh
125 | utils.LoginRespCh = loginRespCh
126 |
127 | if len(c.Args()) == 0 {
128 | cli.ShowAppHelp(c)
129 | return nil
130 | }
131 |
132 | dlc := &NewDownloadCommand()[0]
133 | if dlc != nil {
134 | return dlc.Run(c)
135 | }
136 |
137 | return nil
138 | }
139 |
--------------------------------------------------------------------------------
/login/login_client.go:
--------------------------------------------------------------------------------
1 | package login
2 |
3 | import (
4 | "net/http/cookiejar"
5 | "net/url"
6 | "regexp"
7 | "strings"
8 |
9 | jsoniter "github.com/json-iterator/go"
10 | "github.com/udoless/geektime-downloader/requester"
11 | )
12 |
13 | //Client login client
14 | type Client struct {
15 | *requester.HTTPClient
16 | }
17 |
18 | // Result 从百度服务器解析的数据结构
19 | type Result struct {
20 | Code int `json:"code"`
21 | Data struct {
22 | UID int `json:"uid"`
23 | Name string `json:"nickname"`
24 | Avatar string `json:"avatar"`
25 | GCID string `json:"gcid"`
26 | GCESS string `json:"gcess"`
27 | ServerID string `json:"serverId"`
28 | Ticket string `json:"ticket"`
29 | CookieString string `json:"cookieString"`
30 | } `json:"data"`
31 | Error struct {
32 | Code int `json:"code"`
33 | Msg string `json:"msg"`
34 | } `json:"error"`
35 | Extra struct {
36 | Cost float64 `json:"cost"`
37 | RequestID string `json:"request-id"`
38 | } `json:"extra"`
39 | }
40 |
41 | //NewLoginClient new login client
42 | func NewLoginClient() *Client {
43 | c := &Client{
44 | HTTPClient: requester.NewHTTPClient(),
45 | }
46 |
47 | c.InitLoginPage()
48 |
49 | return c
50 | }
51 |
52 | //InitLoginPage init
53 | func (c *Client) InitLoginPage() {
54 | res, _ := c.Get("https://account.geekbang.org/signin?redirect=https%3A%2F%2Ftime.geekbang.org%2F")
55 | defer (func() {
56 | if res != nil {
57 | res.Body.Close()
58 | }
59 | })()
60 | }
61 |
62 | //Login by phone and dpassword
63 | func (c *Client) Login(phone, password string) *Result {
64 | result := &Result{}
65 | post := map[string]string{
66 | "country": "86",
67 | "cellphone": phone,
68 | "password": password,
69 | // "captcha": "",
70 | "remeber": "1",
71 | "platform": "3",
72 | "appid": "1",
73 | }
74 |
75 | header := map[string]string{
76 | "Referer": "https://account.geekbang.org/signin?redirect=https%3A%2F%2Ftime.geekbang.org%2F",
77 | "Accept": "application/json",
78 | "Connection": "keep-alive",
79 | }
80 | body, err := c.Fetch("POST", "https://account.geekbang.org/account/ticket/login", post, header)
81 | if err != nil {
82 | result.Code = -1
83 | result.Error.Code = -1
84 | result.Error.Msg = "网络请求失败, " + err.Error()
85 |
86 | return result
87 | }
88 |
89 | rex, _ := regexp.Compile("\\[\\]")
90 | body = rex.ReplaceAll(body, []byte("{}"))
91 |
92 | if err = jsoniter.Unmarshal(body, &result); err != nil {
93 | result.Code = -1
94 | result.Error.Code = -1
95 | result.Error.Msg = "发送登录请求错误: " + err.Error()
96 |
97 | return result
98 | }
99 |
100 | if result.IsLoginSuccess() {
101 | result.parseCookies("https://account.geekbang.org", c.Jar.(*cookiejar.Jar))
102 | }
103 |
104 | return result
105 | }
106 |
107 | //parseCookies 解析cookie
108 | func (r *Result) parseCookies(targetURL string, jar *cookiejar.Jar) {
109 | url, _ := url.Parse(targetURL)
110 | cookies := jar.Cookies(url)
111 |
112 | cookieArr := []string{}
113 | for _, cookie := range cookies {
114 | switch cookie.Name {
115 | case "GCID":
116 | r.Data.GCID = cookie.Value
117 | case "GCESS":
118 | r.Data.GCESS = cookie.Value
119 | case "SERVERID":
120 | r.Data.ServerID = cookie.Value
121 | }
122 | cookieArr = append(cookieArr, cookie.String())
123 | }
124 | r.Data.CookieString = strings.Join(cookieArr, ";")
125 | }
126 |
127 | //IsLoginSuccess 是否登陆成功
128 | func (r *Result) IsLoginSuccess() bool {
129 | return r.Code == 0
130 | }
131 |
--------------------------------------------------------------------------------
/service/course.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "github.com/udoless/geektime-downloader/utils"
5 | )
6 |
7 | //Columns 获取专栏
8 | func (s *Service) Columns() ([]*Course, error) {
9 | return s.getCourses(1)
10 | }
11 |
12 | //Videos 获取专栏
13 | func (s *Service) Videos() ([]*Course, error) {
14 | return s.getCourses(3)
15 | }
16 |
17 | //获取课程信息
18 | func (s *Service) getCourses(courseType int) ([]*Course, error) {
19 | ids, err := s.courses(courseType)
20 |
21 | if err != nil {
22 | return nil, err
23 | }
24 |
25 | return s.getCourseDetail(ids)
26 | }
27 |
28 | func (s *Service) courses(courseType int) ([]int, error) {
29 | body, err := s.requestCourses(courseType)
30 |
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | defer body.Close()
36 |
37 | courses := new(CourseList)
38 | if err := handleJSONParse(body, &courses); err != nil {
39 | return nil, err
40 | }
41 |
42 | var ids []int
43 | for _, item := range courses.List {
44 | ids = append(ids, item.ID)
45 | }
46 |
47 | return ids, nil
48 | }
49 |
50 | func (s *Service) getCourseDetail(ids []int) ([]*Course, error) {
51 | body, err := s.requestCourseDetail(ids)
52 | if err != nil {
53 | return nil, err
54 | }
55 |
56 | defer body.Close()
57 |
58 | var courses []*Course
59 | if err := handleJSONParse(body, &courses); err != nil {
60 | return nil, err
61 | }
62 |
63 | return courses, nil
64 | }
65 |
66 | //ShowCourse 获取课程信息
67 | func (s *Service) ShowCourse(id int) (*Course, error) {
68 | body, err := s.requestCourseIntro(id)
69 | if err != nil {
70 | return nil, err
71 | }
72 | defer body.Close()
73 |
74 | course := new(Course)
75 | if err := handleJSONParse(body, &course); err != nil {
76 | return nil, err
77 | }
78 |
79 | return course, nil
80 | }
81 |
82 | //Articles get course articles
83 | func (s *Service) Articles(id int) ([]*Article, error) {
84 | body, err := s.requestCourseArticles(id)
85 | if err != nil {
86 | return nil, err
87 | }
88 | defer body.Close()
89 |
90 | articleResult := &articleResult{}
91 | if err := handleJSONParse(body, articleResult); err != nil {
92 | return nil, err
93 | }
94 |
95 | return articleResult.Articles, nil
96 | }
97 |
98 | //VideoPlayAuth 获取视频的播放授权信息
99 | func (s *Service) VideoPlayAuth(aid int, videoID string) (*VideoPlayAuth, error) {
100 | body, err := s.requestVideoPlayAuth(aid, videoID)
101 | if err != nil {
102 | return nil, err
103 | }
104 | defer body.Close()
105 |
106 | videoPlayAuth := &VideoPlayAuth{}
107 | if err := handleJSONParse(body, videoPlayAuth); err != nil {
108 | return nil, err
109 | }
110 |
111 | return videoPlayAuth, nil
112 | }
113 |
114 | //VideoPlayInfo 获取视频播放信息
115 | func (s *Service) VideoPlayInfo(playAuth string) (*VideoPlayInfo, error) {
116 | body, err := s.requestVideoPlayInfo(playAuth)
117 | if err != nil {
118 | return nil, err
119 | }
120 | defer body.Close()
121 |
122 | videoPlayInfo := &VideoPlayInfo{}
123 | if err := utils.UnmarshalReader(body, &videoPlayInfo); err != nil {
124 | return nil, err
125 | }
126 |
127 | return videoPlayInfo, nil
128 | }
129 |
130 | //ArticleInfo 获取文章信息
131 | func (s *Service) V3ArticleInfo(aid int) (*V3ArticleInfo, error) {
132 | body, err := s.requestArticleInfo(aid)
133 | if err != nil {
134 | return nil, err
135 | }
136 | defer body.Close()
137 |
138 | v3ArticleInfo := &V3ArticleInfo{}
139 | if err := utils.UnmarshalReader(body, &v3ArticleInfo); err != nil {
140 | return nil, err
141 | }
142 |
143 | return v3ArticleInfo, nil
144 | }
145 |
--------------------------------------------------------------------------------
/requester/fetch.go:
--------------------------------------------------------------------------------
1 | package requester
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io"
9 | "io/ioutil"
10 | "net/http"
11 | "strconv"
12 | "strings"
13 | "time"
14 | )
15 |
16 | //HTTPGet 简单实现 http 访问 GET 请求
17 | func HTTPGet(urlStr string) ([]byte, error) {
18 | res, err := DefaultClient.Get(urlStr)
19 |
20 | if res != nil {
21 | defer res.Body.Close()
22 | }
23 |
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | return ioutil.ReadAll(res.Body)
29 | }
30 |
31 | // Req 参见 *HTTPClient.Req, 使用默认 http 客户端
32 | func Req(method string, urlStr string, post interface{}, header map[string]string) (*http.Response, error) {
33 | return DefaultClient.Req(method, urlStr, post, header)
34 | }
35 |
36 | // Fetch 参见 *HTTPClient.Fetch, 使用默认 http 客户端
37 | func Fetch(method string, urlStr string, post interface{}, header map[string]string) ([]byte, error) {
38 | return DefaultClient.Fetch(method, urlStr, post, header)
39 | }
40 |
41 | // Headers return the HTTP Headers of the url
42 | func Headers(url string) (http.Header, error) {
43 | res, err := Req(http.MethodGet, url, nil, nil)
44 | if err != nil {
45 | return nil, err
46 | }
47 | return res.Header, nil
48 | }
49 |
50 | // Size get size of the url
51 | func Size(url string) (int, error) {
52 | h, err := Headers(url)
53 | if err != nil {
54 | return 0, err
55 | }
56 | s := h.Get("Content-Length")
57 | if s == "" {
58 | return 0, errors.New("Content-Length is not present")
59 | }
60 | size, err := strconv.Atoi(s)
61 | if err != nil {
62 | return 0, err
63 | }
64 | return size, nil
65 | }
66 |
67 | // Req 实现 http/https 访问,
68 | // 根据给定的 method (GET, POST, HEAD, PUT 等等),
69 | // urlStr (网址),
70 | // post (post 数据),
71 | // header (header 请求头数据), 进行网站访问。
72 | // 返回值分别为 *http.Response, 错误信息
73 | func (h *HTTPClient) Req(method string, urlStr string, post interface{}, header map[string]string) (*http.Response, error) {
74 | var (
75 | req *http.Request
76 | obody io.Reader
77 | )
78 |
79 | if post != nil {
80 | switch value := post.(type) {
81 | case io.Reader:
82 | obody = value
83 | case map[string]string, map[string]int, map[string]interface{}, []int, []string:
84 | postData, err := json.Marshal(value)
85 | if err != nil {
86 | return nil, err
87 | }
88 | header["Content-Type"] = "application/json"
89 | obody = bytes.NewReader(postData)
90 | case string:
91 | obody = strings.NewReader(value)
92 | case []byte:
93 | obody = bytes.NewReader(value)
94 | default:
95 | return nil, fmt.Errorf("request.Req: unknow post type: %s", post)
96 | }
97 | }
98 |
99 | req, err := http.NewRequest(method, urlStr, obody)
100 | if err != nil {
101 | return nil, err
102 | }
103 |
104 | // 设置浏览器标识
105 | req.Header.Set("User-Agent", UserAgent)
106 |
107 | if header != nil {
108 | for k, v := range header {
109 | req.Header.Set(k, v)
110 | }
111 | }
112 |
113 | h.SetTimeout(20 * time.Minute)
114 |
115 | return h.Client.Do(req)
116 | }
117 |
118 | // Fetch 实现 http/https 访问,
119 | // 根据给定的 method (GET, POST, HEAD, PUT 等等),
120 | // urlStr (网址),
121 | // post (post 数据),
122 | // header (header 请求头数据), 进行网站访问。
123 | // 返回值分别为 []byte, 错误信息
124 | func (h *HTTPClient) Fetch(method string, urlStr string, post interface{}, header map[string]string) ([]byte, error) {
125 | res, err := h.Req(method, urlStr, post, header)
126 |
127 | if res != nil {
128 | defer res.Body.Close()
129 | }
130 |
131 | if err != nil {
132 | return nil, err
133 | }
134 |
135 | return ioutil.ReadAll(res.Body)
136 | }
137 |
--------------------------------------------------------------------------------
/service/course_types.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import "encoding/json"
4 |
5 | //CourseList 课程列表基础信息
6 | type CourseList struct {
7 | List []struct {
8 | ID int `json:"id"`
9 | ColumnCtime int `json:"column_ctime"`
10 | ColumnGroupbuy int `json:"column_groupbuy"`
11 | ColumnPrice int `json:"column_price"`
12 | ColumnPriceMarket int `json:"column_price_market"`
13 | ColumnSku int `json:"column_sku"`
14 | ColumnType int `json:"column_type"`
15 | HadSub bool `json:"had_sub"`
16 | IsChannel int `json:"is_channel"`
17 | IsExperience bool `json:"is_experience"`
18 | LastAid int `json:"last_aid"`
19 | LastChapterID int `json:"last_chapter_id"`
20 | PriceType int `json:"price_type"`
21 | SubCount int `json:"sub_count"`
22 | } `json:"list"`
23 | }
24 |
25 | //Course 课程信息
26 | type Course struct {
27 | ID int `json:"id"`
28 | Authorintro string `json:"author_intro"`
29 | AuthorName string `json:"author_name"`
30 | ChannelBackAmount int `json:"channel_back_amount"`
31 | ColumnBgcolor string `json:"column_bgcolor"`
32 | ColumnCover string `json:"column_cover"`
33 | ColumnCtime int `json:"column_ctime"`
34 | ColumnPrice int `json:"column_price"`
35 | ColumnPriceMarket int `json:"column_price_market"`
36 | ColumnPriceSale int `json:"column_price_sale"`
37 | ColumnSku int `json:"column_sku"`
38 | ColumnSubtitle string `json:"column_subtitle"`
39 | ColumnTitle string `json:"column_title"`
40 | ColumnType int `json:"column_type"`
41 | ColumnTnit string `json:"column_unit"`
42 | HadSub bool `json:"had_sub"`
43 | IsChannel bool `json:"is_channel"`
44 | IsExperience bool `json:"is_experience"`
45 | IsOnboard bool `json:"is_onboard"`
46 | PriceType int `json:"price_type"`
47 | SubCount int `json:"sub_count"`
48 | ShowChapter bool `json:"show_chapter"`
49 | UpdateFrequency string `json:"update_frequency"`
50 | }
51 |
52 | //Article 课程文章信息
53 | type Article struct {
54 | ID int `json:"id"`
55 | ArticleTitle string `json:"article_title"`
56 | ArticleSummary string `json:"article_summary"`
57 | ArticleCover string `json:"article_cover"`
58 | ArticleTime int `json:"article_ctime"`
59 | ChapterID int `json:"chapter_id string"`
60 | IncludeAudio bool `json:"include_audio"`
61 | //Is can preview
62 | ColumnHadSub bool `json:"column_had_sub"`
63 | ArticleCouldPreview bool `json:"article_could_preview"`
64 | //Audio info
65 | AudioDownloadURL string `json:"audio_download_url"`
66 | AudioSize int `json:"audio_size"`
67 | //Viode info
68 | VideoMediaMap json.RawMessage `json:"video_media_map"`
69 | VideoID string `json:"video_id"`
70 | VideoCover string `json:"video_cover"`
71 | }
72 |
73 | //VideoPlayAuth 视频的播放授权信息
74 | type VideoPlayAuth struct {
75 | PlayAuth string `json:"play_auth"`
76 | }
77 |
78 | //VideoPlayInfo 视频播放信息
79 | type VideoPlayInfo struct {
80 | VideoBase struct {
81 | VideoID string `json:"VideoId"`
82 | Title string `json:"Title"`
83 | CoverURL string `josn:"CoverURL"`
84 | } `json:"VideoBase"`
85 | PlayInfoList struct {
86 | PlayInfo []struct {
87 | URL string `json:"PlayURL"`
88 | Size int64 `json:"Size"`
89 | Definition string `json:"Definition"`
90 | } `json:"PlayInfo"`
91 | } `json:"PlayInfoList"`
92 | }
93 |
94 | type articleResult struct {
95 | Articles []*Article `json:"list"`
96 | Page struct {
97 | Count int `json:"count"`
98 | More bool `json:"more"`
99 | } `json:"page"`
100 | }
101 |
102 | //V3ArticleInfo V3文章信息
103 | type V3ArticleInfo struct {
104 | Data struct {
105 | Info struct {
106 | Video struct {
107 | HlsMedias []struct {
108 | Quality string `json:"quality"`
109 | Url string `json:"url"`
110 | Size int `json:"size"`
111 | } `json:"hls_medias"`
112 | } `json:"video"`
113 | } `json:"info"`
114 | } `json:"data"`
115 | }
116 |
117 | //IsColumn 是否专栏
118 | func (course *Course) IsColumn() bool {
119 | return course.ColumnType == 1
120 | }
121 |
122 | //IsVideo 是否视频
123 | func (course *Course) IsVideo() bool {
124 | return course.ColumnType == 3
125 | }
126 |
127 | //IsCanPreview 是否能看
128 | func (article *Article) IsCanPreview() bool {
129 | return article.ColumnHadSub || article.ArticleCouldPreview
130 | }
131 |
--------------------------------------------------------------------------------
/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "sync"
7 | "unsafe"
8 |
9 | jsoniter "github.com/json-iterator/go"
10 | "github.com/udoless/geektime-downloader/service"
11 | )
12 |
13 | const (
14 | // EnvConfigDir 配置路径环境变量
15 | EnvConfigDir = "GEEKTIME_GO_CONFIG_DIR"
16 | // ConfigName 配置文件名
17 | ConfigName = "config.json"
18 | )
19 |
20 | var (
21 | configFilePath = filepath.Join(GetConfigDir(), ConfigName)
22 |
23 | //Instance 配置信息 全局调用
24 | Instance = NewConfig(configFilePath)
25 | )
26 |
27 | //ConfigsData 配置数据
28 | type ConfigsData struct {
29 | AcitveUID int
30 | Geektimes Geektimes
31 | DownloadPath string
32 |
33 | activeUser *Geektime
34 | configFilePath string
35 | configFile *os.File
36 | fileMu sync.Mutex
37 | service *service.Service
38 | }
39 |
40 | //Init 初始化配置
41 | func (c *ConfigsData) Init() error {
42 | if c.configFilePath == "" {
43 | return ErrConfigFilePathNotSet
44 | }
45 |
46 | //初始化默认配置
47 | c.initDefaultConfig()
48 | //从配置文件中加载配置
49 | err := c.loadConfigFromFile()
50 | if err != nil {
51 | return err
52 | }
53 |
54 | //初始化登陆用户信息
55 | err = c.initActiveUser()
56 | if err != nil {
57 | return nil
58 | }
59 |
60 | if c.activeUser != nil {
61 | c.service = c.activeUser.Service()
62 | }
63 |
64 | return nil
65 | }
66 |
67 | func (c *ConfigsData) initActiveUser() error {
68 | //如果已经初始化过,则跳过
69 | if c.AcitveUID > 0 && c.activeUser != nil && c.activeUser.ID == c.AcitveUID {
70 | return nil
71 | }
72 |
73 | if c.AcitveUID == 0 && c.activeUser != nil {
74 | c.AcitveUID = c.activeUser.ID
75 | return nil
76 | }
77 |
78 | if c.AcitveUID > 0 {
79 | for _, geek := range c.Geektimes {
80 | if geek.ID == c.AcitveUID {
81 | c.activeUser = geek
82 | return nil
83 | }
84 | }
85 | }
86 |
87 | if len(c.Geektimes) > 0 {
88 | return ErrHasLoginedNotLogin
89 | }
90 |
91 | return ErrNotLogin
92 | }
93 |
94 | //Save 保存配置
95 | func (c *ConfigsData) Save() error {
96 | err := c.lazyOpenConfigFile()
97 | if err != nil {
98 | return err
99 | }
100 |
101 | c.fileMu.Lock()
102 | defer c.fileMu.Unlock()
103 |
104 | //todo 保存配置的数据
105 | data, err := jsoniter.MarshalIndent((*configJSONExport)(unsafe.Pointer(c)), "", " ")
106 |
107 | if err != nil {
108 | panic(err)
109 | }
110 |
111 | // 减掉多余的部分
112 | err = c.configFile.Truncate(int64(len(data)))
113 | if err != nil {
114 | return err
115 | }
116 |
117 | _, err = c.configFile.Seek(0, os.SEEK_SET)
118 | if err != nil {
119 | return err
120 | }
121 |
122 | _, err = c.configFile.Write(data)
123 | if err != nil {
124 | return err
125 | }
126 |
127 | return nil
128 | }
129 |
130 | func (c *ConfigsData) initDefaultConfig() {
131 | //todo 默认配置
132 | }
133 |
134 | func (c *ConfigsData) loadConfigFromFile() error {
135 | err := c.lazyOpenConfigFile()
136 | if err != nil {
137 | return err
138 | }
139 |
140 | info, err := c.configFile.Stat()
141 | if err != nil {
142 | return err
143 | }
144 |
145 | if info.Size() == 0 {
146 | return c.Save()
147 | }
148 |
149 | c.fileMu.Lock()
150 | defer c.fileMu.Unlock()
151 |
152 | _, err = c.configFile.Seek(0, os.SEEK_SET)
153 | if err != nil {
154 | return nil
155 | }
156 |
157 | //从配置文件中加载配置
158 | decoder := jsoniter.NewDecoder(c.configFile)
159 | decoder.Decode((*configJSONExport)(unsafe.Pointer(c)))
160 |
161 | return nil
162 | }
163 |
164 | func (c *ConfigsData) lazyOpenConfigFile() (err error) {
165 | if c.configFile != nil {
166 | return nil
167 | }
168 | c.fileMu.Lock()
169 | os.MkdirAll(filepath.Dir(c.configFilePath), 0700)
170 | c.configFile, err = os.OpenFile(c.configFilePath, os.O_CREATE|os.O_RDWR, 0600)
171 | c.fileMu.Unlock()
172 |
173 | if err != nil {
174 | if os.IsPermission(err) {
175 | return ErrConfigFileNoPermission
176 | }
177 | if os.IsExist(err) {
178 | return ErrConfigFileNotExist
179 | }
180 | return err
181 | }
182 |
183 | return nil
184 | }
185 |
186 | //NewConfig new config
187 | func NewConfig(configFilePath string) *ConfigsData {
188 | c := &ConfigsData{
189 | configFilePath: configFilePath,
190 | }
191 |
192 | return c
193 | }
194 |
195 | //Geektimes 极客时间用户
196 | type Geektimes []*Geektime
197 |
198 | //GetConfigDir 配置文件夹
199 | func GetConfigDir() string {
200 | configDir, ok := os.LookupEnv(EnvConfigDir)
201 | if ok {
202 | if filepath.IsAbs(configDir) {
203 | return configDir
204 | }
205 | }
206 |
207 | home, ok := os.LookupEnv("HOME")
208 | if ok {
209 | return filepath.Join(home, ".config", "geekbang")
210 | }
211 |
212 | return filepath.Join("/tmp", "geekbang")
213 | }
214 |
215 | //ActiveUser active user
216 | func (c *ConfigsData) ActiveUser() *Geektime {
217 | return c.activeUser
218 | }
219 |
220 | //ActiveUserService user service
221 | func (c *ConfigsData) ActiveUserService() *service.Service {
222 | if c.service == nil {
223 | c.service = c.activeUser.Service()
224 | }
225 | return c.service
226 | }
227 |
--------------------------------------------------------------------------------
/cli/cmds/login.go:
--------------------------------------------------------------------------------
1 | package cmds
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "os"
7 | "strconv"
8 |
9 | "github.com/olekukonko/tablewriter"
10 | "github.com/udoless/geektime-downloader/cli/application"
11 | "github.com/udoless/geektime-downloader/config"
12 | "github.com/urfave/cli"
13 | )
14 |
15 | //Login login data
16 | type Login struct {
17 | phone string
18 | password string
19 | gcid string
20 | gcess string
21 | serverID string
22 | }
23 |
24 | //IsByPhoneAndPassword 通过手机号和密码登录
25 | func (l *Login) IsByPhoneAndPassword() bool {
26 | return l.phone != "" && l.password != ""
27 | }
28 |
29 | //IsByCookie cookie login
30 | func (l *Login) IsByCookie() bool {
31 | return l.gcid != "" && l.gcess != ""
32 | }
33 |
34 | //LoginConfig config
35 | var LoginConfig Login
36 |
37 | //NewLoginCommand login command
38 | func NewLoginCommand() []cli.Command {
39 | return []cli.Command{
40 | {
41 | Name: "login",
42 | Usage: "Login geektime",
43 | UsageText: appName + " login [OPTIONS]",
44 | Action: loginAction,
45 | Flags: []cli.Flag{
46 | cli.StringFlag{
47 | Name: "phone",
48 | Usage: "登录手机号",
49 | Destination: &LoginConfig.phone,
50 | },
51 | cli.StringFlag{
52 | Name: "password",
53 | Usage: "登录密码",
54 | Destination: &LoginConfig.password,
55 | },
56 | cli.StringFlag{
57 | Name: "gcid",
58 | Usage: "GCID Cookie",
59 | Destination: &LoginConfig.gcid,
60 | },
61 | cli.StringFlag{
62 | Name: "gcess",
63 | Usage: "GCESS Cookie",
64 | Destination: &LoginConfig.gcess,
65 | },
66 | cli.StringFlag{
67 | Name: "serverId",
68 | Usage: "SERVERID Cookie",
69 | Destination: &LoginConfig.serverID,
70 | },
71 | },
72 | After: configSaveFunc,
73 | },
74 | {
75 | Name: "who",
76 | Usage: "获取当前帐号",
77 | UsageText: appName + " who",
78 | Description: "获取当前帐号的信息",
79 | Action: whoAction,
80 | Before: authorizationFunc,
81 | },
82 | {
83 | Name: "users",
84 | Usage: "获取帐号列表",
85 | UsageText: appName + " users",
86 | Description: "获取当前已登录的帐号列表",
87 | Action: usersAction,
88 | },
89 | {
90 | Name: "su",
91 | Usage: "切换极客时间帐号",
92 | UsageText: appName + " su [UID]",
93 | Description: "切换已登录的极客时间账号",
94 | Action: suAction,
95 | After: configSaveFunc,
96 | },
97 | }
98 | }
99 |
100 | func loginAction(c *cli.Context) error {
101 | //通过手机号和密码登录
102 | var (
103 | gcid = LoginConfig.gcid
104 | gcess = LoginConfig.gcess
105 | serverID = LoginConfig.serverID
106 | err error
107 | )
108 | if LoginConfig.IsByPhoneAndPassword() {
109 | err = loginByPhoneAndPassword(LoginConfig.phone, LoginConfig.password)
110 | if err != nil {
111 | return err
112 | }
113 | return nil
114 | } else if gcid != "" && gcess != "" {
115 | geektime, err := config.Instance.SetUserByGcidAndGcess(gcid, gcess, serverID, LoginConfig.phone, LoginConfig.password)
116 | if err != nil {
117 | return err
118 | }
119 |
120 | fmt.Println("极客时间账号登录成功:", geektime.Name)
121 |
122 | return nil
123 | }
124 |
125 | return errors.New("请输入登录凭证信息")
126 | }
127 |
128 | func loginByPhoneAndPassword(phone string, password string) error {
129 | gcid, gcess, serverID, err := application.Login(phone, password)
130 | if err != nil {
131 | return err
132 | }
133 |
134 | geektime, err := config.Instance.SetUserByGcidAndGcess(gcid, gcess, serverID, phone, password)
135 | if err != nil {
136 | return err
137 | }
138 |
139 | fmt.Println("极客时间账号登录成功:", geektime.Name)
140 |
141 | return nil
142 | }
143 |
144 | func whoAction(c *cli.Context) error {
145 | activeUser := config.Instance.ActiveUser()
146 | fmt.Printf("当前帐号 uid: %d, 用户名: %s, 头像地址: %s \n", activeUser.ID, activeUser.Name, activeUser.Avatar)
147 |
148 | return nil
149 | }
150 |
151 | func usersAction(c *cli.Context) error {
152 | table := tablewriter.NewWriter(os.Stdout)
153 | table.SetHeader([]string{"#", "UID", "昵称"})
154 | i := 0
155 | for _, g := range config.Instance.Geektimes {
156 | table.Append([]string{strconv.Itoa(i), strconv.Itoa(g.ID), g.Name})
157 | i++
158 | }
159 | table.Render()
160 |
161 | return nil
162 | }
163 |
164 | func suAction(c *cli.Context) error {
165 | if config.Instance.LoginUserCount() == 0 {
166 | return errors.New("未登录任何极客时间账号,不能切换")
167 | }
168 |
169 | if c.NArg() == 0 {
170 | cli.HandleAction(usersAction, c)
171 | return errors.New("请选择登录的用户UID")
172 | }
173 |
174 | i, err := strconv.Atoi(c.Args().Get(0))
175 | if err != nil {
176 | cli.HandleAction(usersAction, c)
177 | return errors.New("请输入用户UID")
178 | }
179 |
180 | err = config.Instance.SwitchUser(&config.User{ID: i})
181 |
182 | if err != nil {
183 | return err
184 | }
185 |
186 | fmt.Printf("成功切换登录用户:%s\n", config.Instance.ActiveUser().Name)
187 |
188 | return nil
189 | }
190 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## geektime-dl
2 |
3 |
4 | 👾 Geektime-dl 是使用Go构建的快速、简单的 [极客时间](https://time.geekbang.org/) 下载器。支持下载文章为PDF,也支持音频,视频下载。
5 |
6 |
7 | - [安装](#安装)
8 | - [必要条件](#必要条件)
9 | - [使用`go get`安装](#%e4%bd%bf%e7%94%a8go-get%e5%ae%89%e8%a3%85)
10 | - [入门](#%e5%85%a5%e9%97%a8)
11 | - [视频和专栏的下载](#视频和专栏的下载)
12 | - [查看视频或专栏列表](#查看视频或专栏列表)
13 | - [可恢复继续下载](#可恢复继续下载)
14 | - [登录](#登录)
15 | - [参考仓库](#参考仓库)
16 | - [License](#license)
17 |
18 | ## 安装
19 |
20 | ### 必要条件
21 |
22 | 以下为必须安装依赖:
23 |
24 | * **[FFmpeg](https://www.ffmpeg.org)**
25 |
26 | > **Note**: FFmpeg的使用是为了最后视频文件合并成需要的格式。
27 |
28 | * **[Google-Chrome](https://www.google.cn/intl/zh-CN/chrome/)**
29 |
30 | > **Note**: 借助[`chromedp/chromedp`](https://github.com/chromedp/chromedp)工具导出页面为PDF文档,该功能需要谷歌浏览器的支持。
31 |
32 | ### 使用`go get`安装
33 |
34 | 安装Geektime-dl,可以使用如下`go get`命令,或者从[Releases](https://github.com/udoless/geektime-downloader/releases) 页面下载二进制文件.
35 |
36 | ```bash
37 | $ go get github.com/udoless/geektime-downloader
38 | ```
39 |
40 | ## 入门
41 |
42 | 使用方法:
43 |
44 | ```bash
45 | #下载
46 | geektime-dl [OPTIONS] 课程ID [目录ID]
47 | #查看专栏、视频,登录及其他命令操作
48 | geektime-dl [OPTIONS] command
49 | ```
50 |
51 | 包含命令
52 |
53 | ```text
54 | login 登录极客时间
55 | who 获取当前帐号
56 | users 获取帐号列表
57 | su 切换极客时间帐号
58 | buy 获取已购买过的专栏和视频课程
59 | column 获取专栏列表
60 | video 获取视频课程列表
61 | help, h 帮助
62 | ```
63 |
64 | ### 登录
65 |
66 | 通过账号密码登录:
67 |
68 | ```console
69 | $ geektime-dl login --phone xxxxxx --password xxxxxx
70 | 极客时间账号登录成功: XXX
71 | ```
72 |
73 | 通过Cookie登录:
74 |
75 | ```console
76 | $ geektime-dl login --gcid xxxxxx --gcess xxxxxx --serverId 'xxxxxxx'
77 | 极客时间账号登录成功: XXX
78 | ```
79 |
80 | ### 视频和专栏的下载
81 |
82 | 只能下载已购买或者免费部分的视频、专栏。
83 |
84 | ```console
85 | $ geektime-dl 66
86 | 01 - 什么是微服务架构? 107.55 MiB / 107.54 MiB [==================================] 100.01% 1.42 MiB/s 1m15s
87 | 02 - 架构师如何权衡微服务的利弊? 92.10 MiB / 92.09 MiB [============================] 100.01% 1.69 MiB/s 54s
88 | 03 - 康威法则和微服务给架构师怎样的启示? 69.38 MiB / 69.38 MiB [=====================] 100.01% 1.68 MiB/s 41s
89 | 04 - 企业应该在什么时候开始考虑引入微服务? 114.20 MiB / 114.20 MiB [==================] 100.00% 1.41 MiB/s 1m21s
90 | 05 - 什么样的组织架构更适合微服务? 121.10 MiB / 121.09 MiB [========================] 100.00% 1.66 MiB/s 1m13s
91 | 06 - 如何理解阿里巴巴提出的微服务中台战略?65.23 MiB / 126.82 MiB [==========>---------] 51.43% 1.68 MiB/s 1m15s
92 | ```
93 |
94 | 只需下载课程中的某个目录
95 |
96 | ```console
97 | $ geektime-dl 66 2276
98 | 16 - 微服务监控系统分层和监控架构 11.22 MiB / 97.55 MiB [======>--------------------] 28.51% 1.30 MiB/s 01m06s
99 | ```
100 |
101 | 下载专栏时,可以同时下载专栏文章内容为PDF文档(`需要谷歌浏览器支持`)
102 |
103 | ```console
104 | 04 - 静态容器:办公用品如何表达你的内容? 13.94 MiB / 13.94 MiB [===================] 100.00% 2.23 MiB/s 6s
105 | 正在生成文件:【04 - 静态容器:办公用品如何表达你的内容?.pdf】 完成
106 | ```
107 |
108 | > **注**: `如果生成文件提示失败,可以重复执行命令针对失败的文件再次生成`,已生成的文件不会重复生成。如果尝试多次都是失败,可以Issues提问。
109 |
110 | 查看课程中可下载的目录
111 |
112 | ```console
113 | $ geektime-dl -i 66
114 | +----+------+------+----------------------------------------------+---------+---------+---------+------+
115 | | # | ID | 类型 | 名称 | SD | LD | HD | 下载 |
116 | +----+------+------+----------------------------------------------+---------+---------+---------+------+
117 | | 0 | 2184 | 视频 | 01 什么是微服务架构? | 86.52M | 53.45M | 107.54M | ✔ |
118 | | 1 | 2185 | 视频 | 02 架构师如何权衡微服务的利弊? | 71.43M | 44.12M | 92.09M | ✔ |
119 | | 2 | 2154 | 视频 | 03 康威法则和微服务给架构师怎样的启示? | 54.32M | 33.57M | 69.38M | ✔ |
120 | | 3 | 2186 | 视频 | 04 企业应该在什么时候开始考虑引入微服务? | 90.07M | 55.67M | 114.20M | ✔ |
121 | | 4 | 2187 | 视频 | 05 什么样的组织架构更适合微服务? | 90.22M | 55.79M | 121.09M | ✔ |
122 | | 5 | 2188 | 视频 | 06 如何理解阿里巴巴提出的微服务中台战略? | 126.82M | 100.05M | 61.79M | ✔ |
123 | | 6 | 2189 | 视频 | 07 如何给出一个清晰简洁的服务分层方式? | 45.89M | 62.07M | 61.95M | ✔ |
124 | | 7 | 2222 | 视频 | 08 微服务总体技术架构体系是怎样设计的? | 85.67M | 52.91M | 109.83M | ✔ |
125 | | 8 | 2269 | 视频 | 09 微服务最经典的三种服务发现机制 | 94.00M | 73.18M | 45.21M | ✔ |
126 | ```
127 |
128 | ### 查看视频或专栏列表
129 |
130 | ```bash
131 | #查看专栏列表
132 | $ geektime-dl column
133 | +----+-----+---------------------------+------------+------------------+------+
134 | | # | ID | 名称 | 时间 | 作者 | 购买 |
135 | +----+-----+---------------------------+------------+------------------+------+
136 | | 0 | 42 | 技术与商业案例解读 | 2017-09-07 | 徐飞 | |
137 | | 1 | 43 | AI技术内参 | 2017-09-11 | 洪亮劼 | |
138 | | 2 | 48 | 左耳听风 | 2017-09-20 | 陈皓 | 是 |
139 | | 3 | 49 | 朱赟的技术管理课 | 2017-11-09 | 朱赟 | 是 |
140 | | 4 | 50 | 邱岳的产品手记 | 2017-11-16 | 邱岳 | |
141 | | 5 | 62 | 人工智能基础课 | 2017-12-01 | 王天一 | 是 |
142 | | 6 | 63 | 赵成的运维体系管理课 | 2017-12-13 | 赵成 | |
143 | | 7 | 74 | 推荐系统三十六式 | 2018-02-23 | 刑无刀 | |
144 | | 8 | 76 | 深入浅出区块链 | 2018-03-19 | 陈浩 | 是 |
145 |
146 |
147 | #查看视频列表
148 | $ geektime-dl video
149 | +----+-----+------------------------------------------+------------+--------------+------+
150 | | # | ID | 名称 | 时间 | 作者 | 购买 |
151 | +----+-----+------------------------------------------+------------+--------------+------+
152 | | 0 | 66 | 微服务架构核心20讲 | 2018-01-08 | 杨波 | 是 |
153 | | 1 | 77 | 9小时搞定微信小程序开发 | 2018-03-22 | 高磊 | |
154 | | 2 | 84 | 微服务架构实战160讲 | 2018-05-03 | 杨波 | 是 |
155 | | 3 | 98 | 零基础学Python | 2018-05-25 | 尹会生 | |
156 | ```
157 |
158 | ### 可恢复继续下载
159 |
160 | Ctrl+C 中断下载。
161 |
162 | 存在 `.download` 临时文件,使用相同的参数执行 `geektime-dl` 命令,则下载进度将从上一个会话恢复。
163 |
164 |
165 | ## 参考仓库
166 |
167 | * [geektime-dl](https://github.com/mmzou/geektime-dl)
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
2 | github.com/cheggaaa/pb v1.0.25 h1:tFpebHTkI7QZx1q1rWGOKhbunhZ3fMaxTvHDWn1bH/4=
3 | github.com/cheggaaa/pb v1.0.25/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s=
4 | github.com/chromedp/cdproto v0.0.0-20210104223854-2cc87dae3ee3 h1:XeGYLuu3Yu3/2/FLDXyObe6lBYtUFDTJgjjNPcfcU40=
5 | github.com/chromedp/cdproto v0.0.0-20210104223854-2cc87dae3ee3/go.mod h1:55pim6Ht4LJKdVLlyFJV/g++HsEA1hQxPbB5JyNdZC0=
6 | github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a h1:B6EyBXuMsFyrUoBrNXdt+Vf3vQNpN4DU/Xv96R4BdFg=
7 | github.com/chromedp/cdproto v0.0.0-20210713064928-7d28b402946a/go.mod h1:At5TxYYdxkbQL0TSefRjhLE3Q0lgvqKKMSFUglJ7i1U=
8 | github.com/chromedp/chromedp v0.6.0 h1:jjzHzXW5pNdKt1D9cEDAKZM/yZ2EwL/hLyGbCUFldBI=
9 | github.com/chromedp/chromedp v0.6.0/go.mod h1:Yay7TUDCNOQBK8EJDUon6AUaQI12VEBOuULcGtY4uDY=
10 | github.com/chromedp/chromedp v0.7.4 h1:U+0d3WbB/Oj4mDuBOI0P7S3PJEued5UZIl5AJ3QulwU=
11 | github.com/chromedp/chromedp v0.7.4/go.mod h1:dBj+SXuQHznp6ZPwZeDDEBZKwclUwDLbZ0hjMialMYs=
12 | github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
13 | github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
14 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY=
15 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
16 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
18 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
19 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
20 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
21 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
22 | github.com/gobwas/ws v1.0.4 h1:5eXU1CZhpQdq5kXbKb+sECH5Ia5KiO6CYzIzdlVx6Bs=
23 | github.com/gobwas/ws v1.0.4/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
24 | github.com/gobwas/ws v1.1.0 h1:7RFti/xnNkMJnrK7D1yQ/iCIB5OrrY/54/H930kIbHA=
25 | github.com/gobwas/ws v1.1.0/go.mod h1:nzvNcVha5eUziGrbxFCo6qFIojQHjJV5cLYIbezhfL0=
26 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
27 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
28 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
29 | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
30 | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
31 | github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08/go.mod h1:dFWs1zEqDjFtnBXsd1vPOZaLsESovai349994nHx3e0=
32 | github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
33 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
34 | github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
35 | github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
36 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
37 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
38 | github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
39 | github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
40 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
41 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
42 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
43 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
44 | github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
45 | github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
46 | github.com/orisano/pixelmatch v0.0.0-20210112091706-4fa4c7ba91d5/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
48 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
49 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
50 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
51 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
52 | github.com/sirupsen/logrus v1.5.0 h1:1N5EYkVAPEywqZRJd7cwnRtCb6xJx7NH3T3WUTF980Q=
53 | github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo=
54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
55 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
56 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
57 | github.com/urfave/cli v1.22.4 h1:u7tSpNPPswAFymm8IehJhy4uJMlUuU/GmqSkvJ1InXA=
58 | github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
59 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
60 | golang.org/x/sys v0.0.0-20201207223542-d4d67f95c62d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
61 | golang.org/x/sys v0.0.0-20210104204734-6f8348627aad h1:MCsdmFSdEd4UEa5TKS5JztCRHK/WtvNei1edOj5RSRo=
62 | golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
63 | golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea h1:+WiDlPBBaO+h9vPNZi8uJ3k4BkKQB7Iow3aqwHVA5hI=
64 | golang.org/x/sys v0.0.0-20210525143221-35b2ab0089ea/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
65 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
66 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
67 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/vim,jetbrains,vscode,git,go,tags,backup,test,emacs
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=vim,jetbrains,vscode,git,go,tags,backup,test,emacs
3 |
4 | ### Backup ###
5 | *.bak
6 | *.gho
7 | *.ori
8 | *.orig
9 | *.tmp
10 |
11 | ### Git ###
12 | # Created by git for backups. To disable backups in Git:
13 | # $ git config --global mergetool.keepBackup false
14 |
15 | # Created by git when using merge tools for conflicts
16 | *.BACKUP.*
17 | *.BASE.*
18 | *.LOCAL.*
19 | *.REMOTE.*
20 | *_BACKUP_*.txt
21 | *_BASE_*.txt
22 | *_LOCAL_*.txt
23 | *_REMOTE_*.txt
24 |
25 | ### Go ###
26 | # Binaries for programs and plugins
27 | *.exe
28 | *.exe~
29 | *.dll
30 | *.so
31 | *.dylib
32 |
33 | # Test binary, built with `go test -c`
34 | *.test
35 |
36 | # Output of the go coverage tool, specifically when used with LiteIDE
37 | *.out
38 |
39 | # Dependency directories (remove the comment below to include it)
40 | # vendor/
41 |
42 | ### Go Patch ###
43 | /vendor/
44 | /Godeps/
45 |
46 | ### JetBrains ###
47 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
48 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
49 | .idea
50 |
51 | # User-specific stuff
52 | .idea/**/workspace.xml
53 | .idea/**/tasks.xml
54 | .idea/**/usage.statistics.xml
55 | .idea/**/dictionaries
56 | .idea/**/shelf
57 |
58 | # Generated files
59 | .idea/**/contentModel.xml
60 |
61 | # Sensitive or high-churn files
62 | .idea/**/dataSources/
63 | .idea/**/dataSources.ids
64 | .idea/**/dataSources.local.xml
65 | .idea/**/sqlDataSources.xml
66 | .idea/**/dynamic.xml
67 | .idea/**/uiDesigner.xml
68 | .idea/**/dbnavigator.xml
69 |
70 | # Gradle
71 | .idea/**/gradle.xml
72 | .idea/**/libraries
73 |
74 | # Gradle and Maven with auto-import
75 | # When using Gradle or Maven with auto-import, you should exclude module files,
76 | # since they will be recreated, and may cause churn. Uncomment if using
77 | # auto-import.
78 | # .idea/artifacts
79 | # .idea/compiler.xml
80 | # .idea/jarRepositories.xml
81 | # .idea/modules.xml
82 | # .idea/*.iml
83 | # .idea/modules
84 | # *.iml
85 | # *.ipr
86 |
87 | # CMake
88 | cmake-build-*/
89 |
90 | # Mongo Explorer plugin
91 | .idea/**/mongoSettings.xml
92 |
93 | # File-based project format
94 | *.iws
95 |
96 | # IntelliJ
97 | out/
98 |
99 | # mpeltonen/sbt-idea plugin
100 | .idea_modules/
101 |
102 | # JIRA plugin
103 | atlassian-ide-plugin.xml
104 |
105 | # Cursive Clojure plugin
106 | .idea/replstate.xml
107 |
108 | # Crashlytics plugin (for Android Studio and IntelliJ)
109 | com_crashlytics_export_strings.xml
110 | crashlytics.properties
111 | crashlytics-build.properties
112 | fabric.properties
113 |
114 | # Editor-based Rest Client
115 | .idea/httpRequests
116 |
117 | # Android studio 3.1+ serialized cache file
118 | .idea/caches/build_file_checksums.ser
119 |
120 | ### JetBrains Patch ###
121 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
122 |
123 | # *.iml
124 | # modules.xml
125 | # .idea/misc.xml
126 | # *.ipr
127 |
128 | # Sonarlint plugin
129 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
130 | .idea/**/sonarlint/
131 |
132 | # SonarQube Plugin
133 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
134 | .idea/**/sonarIssues.xml
135 |
136 | # Markdown Navigator plugin
137 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
138 | .idea/**/markdown-navigator.xml
139 | .idea/**/markdown-navigator-enh.xml
140 | .idea/**/markdown-navigator/
141 |
142 | # Cache file creation bug
143 | # See https://youtrack.jetbrains.com/issue/JBR-2257
144 | .idea/$CACHE_FILE$
145 |
146 | # CodeStream plugin
147 | # https://plugins.jetbrains.com/plugin/12206-codestream
148 | .idea/codestream.xml
149 |
150 | ### Tags ###
151 | # Ignore tags created by etags, ctags, gtags (GNU global) and cscope
152 | TAGS
153 | .TAGS
154 | !TAGS/
155 | tags
156 | .tags
157 | !tags/
158 | gtags.files
159 | GTAGS
160 | GRTAGS
161 | GPATH
162 | GSYMS
163 | cscope.files
164 | cscope.out
165 | cscope.in.out
166 | cscope.po.out
167 |
168 |
169 | ### Test ###
170 | ### Ignore all files that could be used to test your code and
171 | ### you wouldn't want to push
172 |
173 | # Reference https://en.wikipedia.org/wiki/Metasyntactic_variable
174 |
175 | # Most common
176 | *foo
177 | *bar
178 | *fubar
179 | *foobar
180 | *baz
181 |
182 | # Less common
183 | *qux
184 | *quux
185 | *bongo
186 | *bazola
187 | *ztesch
188 |
189 | # UK, Australia
190 | *wibble
191 | *wobble
192 | *wubble
193 | *flob
194 | *blep
195 | *blah
196 | *boop
197 | *beep
198 |
199 | # Japanese
200 | *hoge
201 | *piyo
202 | *fuga
203 | *hogera
204 | *hogehoge
205 |
206 | # Portugal, Spain
207 | *fulano
208 | *sicrano
209 | *beltrano
210 | *mengano
211 | *perengano
212 | *zutano
213 |
214 | # France, Italy, the Netherlands
215 | *toto
216 | *titi
217 | *tata
218 | *tutu
219 | *pipppo
220 | *pluto
221 | *paperino
222 | *aap
223 | *noot
224 | *mies
225 |
226 | # Other names that would make sense
227 | *tests
228 | *testsdir
229 | *testsfile
230 | *testsfiles
231 | *test
232 | *testdir
233 | *testfile
234 | *testfiles
235 | *testing
236 | *testingdir
237 | *testingfile
238 | *testingfiles
239 | *temp
240 | *tempdir
241 | *tempfile
242 | *tempfiles
243 | *tmp
244 | *tmpdir
245 | *tmpfile
246 | *tmpfiles
247 | *lol
248 |
249 | ### Vim ###
250 | # Swap
251 | [._]*.s[a-v][a-z]
252 | !*.svg # comment out if you don't need vector files
253 | [._]*.sw[a-p]
254 | [._]s[a-rt-v][a-z]
255 | [._]ss[a-gi-z]
256 | [._]sw[a-p]
257 |
258 | # Session
259 | Session.vim
260 | Sessionx.vim
261 |
262 | # Temporary
263 | .netrwhist
264 | *~
265 | # Auto-generated tag files
266 | # Persistent undo
267 | [._]*.un~
268 |
269 | ### Emacs ###
270 | # -*- mode: gitignore; -*-
271 | *~
272 | \#*\#
273 | /.emacs.desktop
274 | /.emacs.desktop.lock
275 | *.elc
276 | auto-save-list
277 | tramp
278 | .\#*
279 |
280 | # Org-mode
281 | .org-id-locations
282 | *_archive
283 | ltximg/**
284 |
285 | # flymake-mode
286 | *_flymake.*
287 |
288 | # eshell files
289 | /eshell/history
290 | /eshell/lastdir
291 |
292 | # elpa packages
293 | /elpa/
294 |
295 | # reftex files
296 | *.rel
297 |
298 | # AUCTeX auto folder
299 | /auto/
300 |
301 | # cask packages
302 | .cask/
303 | dist/
304 |
305 | # Flycheck
306 | flycheck_*.el
307 |
308 | # server auth directory
309 | /server/
310 |
311 | # projectiles files
312 | .projectile
313 |
314 | # directory configuration
315 | .dir-locals.el
316 |
317 | # network security
318 | /network-security.data
319 |
320 | ### vscode ###
321 | .vscode/*
322 | !.vscode/settings.json
323 | !.vscode/tasks.json
324 | !.vscode/launch.json
325 | !.vscode/extensions.json
326 | *.code-workspace
327 |
328 | # End of https://www.toptal.com/developers/gitignore/api/vim,jetbrains,vscode,git,go,tags,backup,test
329 |
330 | # Start by iam
331 |
332 | # log
333 | *.log
334 |
335 | # Output of backend and frontend
336 | /_output
337 | /_debug
338 |
339 | # Misc
340 | .DS_Store
341 | *.env
342 | .env.*
343 | dist
344 |
345 | # files used by the developer
346 | .idea.md
347 | .todo.md
348 | .note.md
349 |
350 | # config files, may contain sensitive information
351 |
352 | configs/*.yaml
353 |
354 | .vscode
355 | test/
356 | downloads/
357 | .DS_Store
358 | log.log
359 | .idea
360 | .goreleaser.yml
--------------------------------------------------------------------------------
/utils/chromedp.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io/ioutil"
8 | "log"
9 | "strconv"
10 | "strings"
11 | "sync"
12 | "time"
13 |
14 | "github.com/chromedp/cdproto/cdp"
15 | "github.com/chromedp/cdproto/network"
16 | "github.com/chromedp/cdproto/page"
17 | "github.com/chromedp/cdproto/runtime"
18 | "github.com/chromedp/chromedp"
19 | "github.com/chromedp/chromedp/device"
20 | )
21 |
22 | var LoginCh chan struct{}
23 | var LoginRespCh chan struct{}
24 | var ErrRetry = errors.New("retry error")
25 |
26 | //ColumnPrintToPDF print pdf
27 | func ColumnPrintToPDF(aid int, filename string, cookies map[string]string) error {
28 | var buf []byte
29 | // create chrome instance
30 | ctx, cancel := chromedp.NewContext(
31 | context.Background(),
32 | chromedp.WithLogf(log.Printf),
33 | )
34 | defer cancel()
35 |
36 | // create a timeout
37 | ctx, cancel = context.WithTimeout(ctx, 120*time.Second)
38 | defer cancel()
39 |
40 | var wg sync.WaitGroup
41 | urlMap := make(map[string]struct{})
42 | chromedp.ListenTarget(ctx, func(ev interface{}) {
43 | switch e := ev.(type) {
44 | case *network.EventRequestWillBeSent:
45 | if e.Type == "Image" && strings.Contains(e.Request.URL, "geekbang") && !strings.Contains(e.Request.URL, "aliyun") {
46 | //fmt.Printf("【network.EventRequestWillBeSent】%s, %s\n", e.Type, e.Request.URL)
47 | urlMap[e.Request.URL] = struct{}{}
48 | wg.Add(1)
49 | }
50 | case *network.EventResponseReceived:
51 | if e.Type == "Image" && strings.Contains(e.Response.URL, "geekbang") && !strings.Contains(e.Response.URL, "aliyun") {
52 | //fmt.Printf("【network.EventResponseReceived】%s, %s\n", e.Type, e.Response.URL)
53 | delete(urlMap, e.Response.URL)
54 | wg.Done()
55 | }
56 | }
57 | })
58 | imgLoadedCh := make(chan struct{})
59 |
60 | // url := `https://time.geekbang.org/column/article/169881`
61 | url := `https://time.geekbang.org/column/article/` + strconv.Itoa(aid)
62 | err := chromedp.Run(ctx,
63 | chromedp.Tasks{
64 | network.SetExtraHTTPHeaders(network.Headers(map[string]interface{}{
65 | "User-Agent": "Nimei_" + time.Now().String(),
66 | })),
67 | chromedp.Emulate(device.IPhone7),
68 | enableLifeCycleEvents(),
69 | setCookies(cookies),
70 | navigateAndWaitFor(url, "firstImagePaint"),
71 | // chromedp.WaitVisible("img", chromedp.ByQueryAll),
72 | chromedp.WaitReady("img", chromedp.ByQueryAll),
73 | chromedp.ActionFunc(func(ctx context.Context) error {
74 | s := `
75 | document.querySelector('.iconfont').parentElement.parentElement.style.display='none';
76 | document.querySelector('.Index_white_1gqaD>div.iconfont').style.display='none';
77 | var audioBar = document.querySelector('.audio-float-bar');
78 | if(audioBar){
79 | audioBar.style.display='none'
80 | }
81 | var bottom = document.querySelector('.sub-bottom-wrapper');
82 | if(bottom){
83 | bottom.style.display='none'
84 | }
85 | [...document.querySelectorAll('ul>li>div>div>div:nth-child(2)>span')].map(e=>e.click());
86 | `
87 | _, exp, err := runtime.Evaluate(s).Do(ctx)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | if exp != nil {
93 | return exp
94 | }
95 |
96 | return nil
97 | }),
98 | chromedp.ActionFunc(func(ctx context.Context) error {
99 | s := `
100 | var divs = document.getElementsByTagName('div');
101 | for (var i = 0; i < divs.length; ++i){
102 | if(divs[i].innerText === "打开APP"){
103 | divs[i].parentNode.parentNode.style.display="none";
104 | break;
105 | }
106 | }
107 | `
108 | _, exp, err := runtime.Evaluate(s).Do(ctx)
109 | if err != nil {
110 | return err
111 | }
112 |
113 | if exp != nil {
114 | return exp
115 | }
116 |
117 | return nil
118 | }),
119 | //尽量确保图片都已加载
120 | /*
121 | chromedp.ActionFunc(func(ctx context.Context) error {
122 | s := `
123 | var maxTry = 3
124 | var loaded=false;
125 | var t = setInterval(function(){
126 | var img=document.getElementsByTagName('img');
127 | for(var i=0;i ")
255 | }
256 | }
257 | })
258 |
259 | select {
260 | case <-ch:
261 | return nil
262 | case <-ctx.Done():
263 | return ctx.Err()
264 | }
265 | }
266 |
--------------------------------------------------------------------------------
/cli/cmds/download.go:
--------------------------------------------------------------------------------
1 | package cmds
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "strconv"
8 | "strings"
9 |
10 | "github.com/udoless/geektime-downloader/cli/application"
11 | "github.com/udoless/geektime-downloader/downloader"
12 | "github.com/udoless/geektime-downloader/service"
13 | "github.com/udoless/geektime-downloader/utils"
14 | "github.com/urfave/cli"
15 | )
16 |
17 | //NewDownloadCommand login command
18 | func NewDownloadCommand() []cli.Command {
19 | return []cli.Command{
20 | {
21 | Name: "",
22 | Usage: "",
23 | UsageText: "",
24 | Action: downloadAction,
25 | Before: authorizationFunc,
26 | },
27 | }
28 | }
29 |
30 | func downloadAction(c *cli.Context) error {
31 | args := c.Parent().Args()
32 | cid, err := strconv.Atoi(args.First())
33 | if err != nil {
34 | cli.ShowCommandHelp(c, "download")
35 | return errors.New("请输入课程ID")
36 | }
37 |
38 | //课程目录ID
39 | aid := 0
40 | if len(args) > 1 {
41 | aid, err = strconv.Atoi(args.Get(1))
42 | if err != nil {
43 | return errors.New("课程目录ID错误")
44 | }
45 | }
46 |
47 | course, articles, err := application.CourseWithArticles(cid)
48 | if err != nil {
49 | return err
50 | }
51 |
52 | downloadData := extractDownloadData(course, articles, aid)
53 | // printExtractDownloadData(downloadData)
54 |
55 | if _info {
56 | downloadData.PrintInfo()
57 | return nil
58 | }
59 |
60 | // 专栏下载时,如果没有指定pdf和mp3,则默认同时下载
61 | if course.IsColumn() && !_pdf && !_mp3 {
62 | _pdf = true
63 | _mp3 = true
64 | }
65 |
66 | myErrors := make([]error, 0)
67 |
68 | // 视频或者音频下载
69 | if course.IsVideo() || (course.IsColumn() && _mp3) {
70 | sub := "MP4"
71 | if course.IsColumn() {
72 | sub = "MP3"
73 | }
74 |
75 | // 创建文件夹
76 | path, err := utils.Mkdir(utils.FileName(course.ColumnTitle, ""), sub)
77 | if err != nil {
78 | return err
79 | }
80 |
81 | for _, datum := range downloadData.Data {
82 | if !datum.IsCanDL {
83 | continue
84 | }
85 | if err := downloader.Download(datum, _stream, path); err != nil {
86 | myErrors = append(myErrors, err)
87 | }
88 | }
89 |
90 | if len(myErrors) > 0 {
91 | return myErrors[0]
92 | }
93 | }
94 |
95 | //如果是专栏,则需要打印内容
96 | if course.IsColumn() && _pdf {
97 | path, err := utils.Mkdir(utils.FileName(course.ColumnTitle, ""), "PDF")
98 | if err != nil {
99 | return err
100 | }
101 | cookies := application.LoginedCookies()
102 | for _, datum := range downloadData.Data {
103 | if !datum.IsCanDL {
104 | continue
105 | }
106 | for {
107 | if err := downloader.PrintToPDF(datum, cookies, path); err != nil {
108 | if errors.Is(err, utils.ErrRetry) {
109 | cookies = application.LoginedCookies()
110 | continue
111 | }
112 | myErrors = append(myErrors, err)
113 | }
114 | break
115 | }
116 | }
117 | }
118 |
119 | if len(myErrors) > 0 {
120 | return myErrors[0]
121 | }
122 |
123 | return nil
124 | }
125 |
126 | //生成下载数据
127 | func extractDownloadData(course *service.Course, articles []*service.Article, aid int) downloader.Data {
128 |
129 | downloadData := downloader.Data{
130 | Title: course.ColumnTitle,
131 | }
132 |
133 | if course.IsColumn() {
134 | downloadData.Type = "专栏"
135 | downloadData.Data = extractColumnDownloadData(articles, aid)
136 | } else if course.IsVideo() {
137 | downloadData.Type = "视频"
138 | downloadData.Data = extractVideoDownloadData(articles, aid)
139 | }
140 |
141 | return downloadData
142 | }
143 |
144 | //生成专栏下载数据
145 | func extractColumnDownloadData(articles []*service.Article, aid int) []downloader.Datum {
146 | data := downloader.EmptyData
147 |
148 | key := "df"
149 | for _, article := range articles {
150 | if aid > 0 && article.ID != aid {
151 | continue
152 | }
153 | urls := []downloader.URL{}
154 | if article.AudioDownloadURL != "" {
155 | urls = []downloader.URL{
156 | {
157 | URL: article.AudioDownloadURL,
158 | Size: article.AudioSize,
159 | Ext: "mp3",
160 | },
161 | }
162 | }
163 |
164 | streams := map[string]downloader.Stream{
165 | key: {
166 | URLs: urls,
167 | Size: article.AudioSize,
168 | Quality: key,
169 | },
170 | }
171 |
172 | data = append(data, downloader.Datum{
173 | ID: article.ID,
174 | Title: article.ArticleTitle,
175 | IsCanDL: article.IsCanPreview(),
176 | Streams: streams,
177 | Type: "专栏",
178 | })
179 | }
180 |
181 | return data
182 | }
183 |
184 | //生成视频下载数据
185 | func extractVideoDownloadData(articles []*service.Article, aid int) []downloader.Datum {
186 | data := downloader.EmptyData
187 |
188 | videoIds := map[int]string{}
189 |
190 | videoData := make([]*downloader.Datum, 0)
191 |
192 | for _, article := range articles {
193 | if aid > 0 && article.ID != aid {
194 | continue
195 | }
196 |
197 | videoIds[article.ID] = article.VideoID
198 |
199 | videoMediaMaps := &map[string]downloader.VideoMediaMap{}
200 | utils.UnmarshalJSON(article.VideoMediaMap, videoMediaMaps)
201 |
202 | urls := []downloader.URL{}
203 |
204 | streams := map[string]downloader.Stream{}
205 | for key, videoMediaMap := range *videoMediaMaps {
206 | streams[key] = downloader.Stream{
207 | URLs: urls,
208 | Size: videoMediaMap.Size,
209 | Quality: key,
210 | }
211 | }
212 |
213 | datum := &downloader.Datum{
214 | ID: article.ID,
215 | Title: article.ArticleTitle,
216 | IsCanDL: article.IsCanPreview(),
217 | Streams: streams,
218 | Type: "视频",
219 | }
220 |
221 | videoData = append(videoData, datum)
222 | }
223 | if !_info {
224 | wgp := utils.NewWaitGroupPool(10)
225 | for _, datum := range videoData {
226 | wgp.Add()
227 | go func(datum *downloader.Datum, streams map[int]string) {
228 | defer func() {
229 | wgp.Done()
230 | }()
231 | if datum.IsCanDL {
232 | v3ArticleInfo, err := application.V3ArticleInfo(datum.ID)
233 | if err != nil {
234 | panic(err)
235 | }
236 | for _, info := range v3ArticleInfo.Data.Info.Video.HlsMedias {
237 | if info.Quality != "hd" {
238 | continue
239 | }
240 | if urls, aesBytes, err := utils.M3u8URLsAndAesKey(info.Url); err == nil {
241 | key := strings.ToLower(info.Quality)
242 | stream := datum.Streams[key]
243 | stream.AesKeyBytes = aesBytes
244 | stream.Size = info.Size
245 | for _, url := range urls {
246 | stream.URLs = append(stream.URLs, downloader.URL{
247 | URL: url,
248 | Ext: "ts",
249 | })
250 | }
251 | datum.Streams[key] = stream
252 | } else {
253 | fmt.Println("M3u8URLsAndAesKey error")
254 | panic(err)
255 | }
256 | }
257 |
258 | for k, v := range datum.Streams {
259 | if len(v.URLs) == 0 {
260 | delete(datum.Streams, k)
261 | }
262 | }
263 | }
264 | }(datum, videoIds)
265 | }
266 | wgp.Wait()
267 | }
268 | /*
269 | if !_info {
270 | wgp := utils.NewWaitGroupPool(10)
271 | for _, datum := range videoData {
272 | wgp.Add()
273 | go func(datum *downloader.Datum, streams map[int]string) {
274 | defer func() {
275 | wgp.Done()
276 | }()
277 | if datum.IsCanDL {
278 | playInfo, _ := application.GetVideoPlayInfo(datum.ID, streams[datum.ID])
279 | for _, info := range playInfo.PlayInfoList.PlayInfo {
280 | if urls, err := utils.M3u8URLs(info.URL); err == nil {
281 | key := strings.ToLower(info.Definition)
282 | stream := datum.Streams[key]
283 | for _, url := range urls {
284 | stream.URLs = append(stream.URLs, downloader.URL{
285 | URL: url,
286 | Ext: "ts",
287 | })
288 | }
289 | datum.Streams[key] = stream
290 | }
291 | }
292 |
293 | for k, v := range datum.Streams {
294 | if len(v.URLs) == 0 {
295 | delete(datum.Streams, k)
296 | }
297 | }
298 | }
299 | }(datum, videoIds)
300 | }
301 | wgp.Wait()
302 | }
303 |
304 | */
305 |
306 | for _, d := range videoData {
307 | data = append(data, *d)
308 | }
309 |
310 | return data
311 | }
312 |
313 | func printExtractDownloadData(v interface{}) {
314 | jsonData, err := json.MarshalIndent(v, "", " ")
315 | if err != nil {
316 | fmt.Println(err)
317 | } else {
318 | fmt.Printf("%s\n", jsonData)
319 | }
320 | }
321 |
--------------------------------------------------------------------------------
/downloader/downloader.go:
--------------------------------------------------------------------------------
1 | package downloader
2 |
3 | import (
4 | "encoding/json"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "net/http"
10 | "os"
11 | "path/filepath"
12 | "strings"
13 | "sync"
14 | "time"
15 |
16 | "github.com/cheggaaa/pb"
17 | "github.com/udoless/geektime-downloader/aeswrap"
18 | "github.com/udoless/geektime-downloader/requester"
19 | "github.com/udoless/geektime-downloader/utils"
20 | )
21 |
22 | func progressBar(size int, prefix string) *pb.ProgressBar {
23 | bar := pb.New(size).SetUnits(pb.U_BYTES).SetRefreshRate(time.Second * 1)
24 | bar.ShowSpeed = true
25 | bar.ShowFinalTime = true
26 | bar.SetMaxWidth(1000)
27 |
28 | if prefix != "" {
29 | bar.Prefix(prefix)
30 | }
31 |
32 | return bar
33 | }
34 |
35 | //Download download data
36 | func Download(v Datum, stream string, path string) error {
37 | if !v.IsCanDL {
38 | return errors.New("该课程目录未付费,或者不支持下载")
39 | }
40 |
41 | //按大到小排序
42 | v.genSortedStreams()
43 |
44 | title := utils.FileName(v.Title, "")
45 | title = replacer.Replace(title)
46 |
47 | if stream == "" {
48 | if len(v.sortedStreams) == 0 {
49 | vStr, _ := json.Marshal(v)
50 | panic("文章解析异常:" + string(vStr))
51 | }
52 | stream = v.sortedStreams[0].name
53 | }
54 | data, ok := v.Streams[strings.ToLower(stream)]
55 | if !ok {
56 | return fmt.Errorf("指定要下载的类型不存在:%s", stream)
57 | }
58 |
59 | //判断下载连接是否存在
60 | if len(data.URLs) == 0 {
61 | return nil
62 | }
63 |
64 | filePreName := filepath.Join(path, title)
65 | fileName, err := utils.FilePath(filePreName, "mp4", false)
66 |
67 | if err != nil {
68 | return err
69 | }
70 |
71 | _, mergedFileExists, err := utils.FileSize(fileName)
72 | if err != nil {
73 | return err
74 | }
75 |
76 | // After the merge, the file size has changed, so we do not check whether the size matches
77 | if mergedFileExists {
78 | // fmt.Printf("%s: file already exists, skipping\n", mergedFilePath)
79 | return nil
80 | }
81 |
82 | bar := progressBar(data.Size, title)
83 | bar.Start()
84 |
85 | // chunkSizeMB := 1
86 | chunkSizeMB := 0
87 |
88 | if len(data.URLs) == 1 {
89 | err := Save(data.URLs[0], filePreName, bar, chunkSizeMB, data.AesKeyBytes)
90 | if err != nil {
91 | return err
92 | }
93 | bar.Finish()
94 | return nil
95 | }
96 |
97 | wgp := utils.NewWaitGroupPool(8)
98 |
99 | errs := make([]error, 0)
100 | lock := sync.Mutex{}
101 | parts := make([]string, len(data.URLs))
102 |
103 | for index, url := range data.URLs {
104 | if len(errs) > 0 {
105 | break
106 | }
107 |
108 | partFileName := fmt.Sprintf("%s[%d]", filePreName, index)
109 | partFilePath, err := utils.FilePath(partFileName, url.Ext, false)
110 | if err != nil {
111 | return err
112 | }
113 | parts[index] = partFilePath
114 |
115 | wgp.Add()
116 | go func(url URL, fileName string, bar *pb.ProgressBar) {
117 | defer wgp.Done()
118 | err := Save(url, fileName, bar, chunkSizeMB, data.AesKeyBytes)
119 | if err != nil {
120 | lock.Lock()
121 | errs = append(errs, err)
122 | lock.Unlock()
123 | }
124 | }(url, partFileName, bar)
125 | }
126 |
127 | wgp.Wait()
128 |
129 | if len(errs) > 0 {
130 | return errs[0]
131 | }
132 |
133 | bar.Finish()
134 |
135 | if v.Type != "视频" {
136 | return nil
137 | }
138 |
139 | // merge
140 | // fmt.Printf("Merging video parts into %s\n", mergedFilePath)
141 | err = utils.MergeToMP4(parts, fileName, title)
142 |
143 | return err
144 | }
145 |
146 | // Save save url file
147 | func Save(
148 | urlData URL, fileName string, bar *pb.ProgressBar, chunkSizeMB int, aesKeyBytes []byte,
149 | ) error {
150 | if urlData.Size == 0 {
151 | size, err := requester.Size(urlData.URL)
152 | if err != nil {
153 | return err
154 | }
155 | urlData.Size = size
156 | }
157 |
158 | var err error
159 | filePath, err := utils.FilePath(fileName, urlData.Ext, false)
160 | if err != nil {
161 | return err
162 | }
163 | fileSize, exists, err := utils.FileSize(filePath)
164 | if err != nil {
165 | return err
166 | }
167 | if bar == nil {
168 | bar = progressBar(urlData.Size, fileName)
169 | bar.Start()
170 | }
171 | // Skip segment file
172 | // TODO: Live video URLs will not return the size
173 | if exists && fileSize == urlData.Size {
174 | bar.Add(fileSize)
175 | return nil
176 | }
177 | tempFilePath := filePath + ".download"
178 | tempFileSize, _, err := utils.FileSize(tempFilePath)
179 |
180 | if err != nil {
181 | return err
182 | }
183 | headers := map[string]string{}
184 | var (
185 | file *os.File
186 | fileError error
187 | )
188 | if tempFileSize > 0 {
189 | // range start from 0, 0-1023 means the first 1024 bytes of the file
190 | headers["Range"] = fmt.Sprintf("bytes=%d-", tempFileSize)
191 | file, fileError = os.OpenFile(tempFilePath, os.O_APPEND|os.O_WRONLY, 0644)
192 | bar.Add(tempFileSize)
193 | } else {
194 | file, fileError = os.Create(tempFilePath)
195 | }
196 | if fileError != nil {
197 | return fileError
198 | }
199 |
200 | // close and rename temp file at the end of this function
201 | defer func() {
202 | // must close the file before rename or it will cause
203 | // `The process cannot access the file because it is being used by another process.` error.
204 | file.Close()
205 | if err == nil {
206 | os.Rename(tempFilePath, filePath)
207 | }
208 | }()
209 |
210 | if chunkSizeMB > 0 {
211 | var start, end, chunkSize int
212 | chunkSize = chunkSizeMB * 1024 * 1024
213 | remainingSize := urlData.Size
214 | if tempFileSize > 0 {
215 | start = tempFileSize
216 | remainingSize -= tempFileSize
217 | }
218 | chunk := remainingSize / chunkSize
219 | if remainingSize%chunkSize != 0 {
220 | chunk++
221 | }
222 | var i = 1
223 | for ; i <= chunk; i++ {
224 | end = start + chunkSize - 1
225 | headers["Range"] = fmt.Sprintf("bytes=%d-%d", start, end)
226 | temp := start
227 | for i := 0; ; i++ {
228 | var written int
229 | var err error
230 | if urlData.Ext == "ts" {
231 | written, err = writeFileWithDecAes(urlData.URL, file, headers, bar, aesKeyBytes)
232 | } else {
233 | written, err = writeFile(urlData.URL, file, headers, bar)
234 | }
235 | if err == nil {
236 | break
237 | } else if i+1 >= 3 {
238 | return err
239 | }
240 | temp += written
241 | headers["Range"] = fmt.Sprintf("bytes=%d-%d", temp, end)
242 | time.Sleep(1 * time.Second)
243 | }
244 | start = end + 1
245 | }
246 | } else {
247 | temp := tempFileSize
248 | for i := 0; ; i++ {
249 | var written int
250 | var err error
251 | if urlData.Ext == "ts" {
252 | written, err = writeFileWithDecAes(urlData.URL, file, headers, bar, aesKeyBytes)
253 | } else {
254 | written, err = writeFile(urlData.URL, file, headers, bar)
255 | }
256 | if err == nil {
257 | break
258 | } else if i+1 >= 3 {
259 | return err
260 | }
261 | temp += written
262 | headers["Range"] = fmt.Sprintf("bytes=%d-", temp)
263 | time.Sleep(1 * time.Second)
264 | }
265 | }
266 |
267 | return nil
268 | }
269 |
270 | func writeFile(
271 | url string, file *os.File, headers map[string]string, bar *pb.ProgressBar,
272 | ) (int, error) {
273 | res, err := requester.Req(http.MethodGet, url, nil, headers)
274 | if err != nil {
275 | return 0, err
276 | }
277 | defer res.Body.Close()
278 |
279 | writer := io.MultiWriter(file, bar)
280 | // Note that io.Copy reads 32kb(maximum) from input and writes them to output, then repeats.
281 | // So don't worry about memory.
282 | written, copyErr := io.Copy(writer, res.Body)
283 | if copyErr != nil && copyErr != io.EOF {
284 | return int(written), fmt.Errorf("file copy error: %s", copyErr)
285 | }
286 | return int(written), nil
287 | }
288 |
289 | func writeFileWithDecAes(
290 | url string, file *os.File, headers map[string]string, bar *pb.ProgressBar, aesKeyBytes []byte,
291 | ) (int, error) {
292 | res, err := requester.Req(http.MethodGet, url, nil, headers)
293 | if err != nil {
294 | return 0, err
295 | }
296 | defer res.Body.Close()
297 |
298 | bodyBytes, err := ioutil.ReadAll(res.Body)
299 | if err != nil {
300 | return 0, err
301 | }
302 | bodyBytes, err = aeswrap.AesDecrypt(bodyBytes, aesKeyBytes)
303 | if err != nil {
304 | return 0, err
305 | }
306 |
307 | writer := io.MultiWriter(file, bar)
308 | written, err := writer.Write(bodyBytes)
309 | if err != nil {
310 | return 0, err
311 | }
312 | return written, nil
313 | }
314 |
--------------------------------------------------------------------------------