Пятничное: пишем консольную утилиту на Go для добычи гифок с котами

Kate

Administrator
Команда форума
В моём окружении часто отправляют гифки с котами. К сожалению, рано или поздно запас заканчивается, и приходится идти и искать новые.

Недавно я пошёл искать новые, после чего мне пришла идея автоматизировать данный процесс. Делать мне тогда было нечего, и я пошёл писать для этого простую cli-программу на Go.

Авторы: Mike Lehmann, Mike Switzerland, CC-BY-SA-2.5
Авторы: Mike Lehmann, Mike Switzerland, CC-BY-SA-2.5Дисклеймер

Получаем котеек​

Для начала нам надо выбрать, откуда брать наши гифки. По запросу в поисковике я обнаружил сайт, выдающий случайные гифки с котами, с логичным и лаконичным названием randomcatgifs.com

Скриншот сайта
Скриншот сайта
Интерфейс довольно простой, а гифка выдаётся сервером, что позволяет спокойно воспользоваться скрейпингом для получения ссылки. Смотрим в исходный код одной из генераций и...

<video autoplay="" loop="" playsinline="" muted="" poster="https://randomcatgifs.com/media/playfulornatecentipede-poster.jpg" preload="none">
<source src="https://randomcatgifs.com/media/playfulornatecentipede.mp4" type="video/mp4">
<source src="https://randomcatgifs.com/media/playfulornatecentipede.webm" type="video/webm"><p>Please use a modern browser to view this cat gif.</p>
</video>
Оказывается, вместо гифки нам дают видео. Это, конечно, хорошо, так как mp4 без звука весит меньше, чем такой же гиф, а также имеет лучшее качество, однако я планировал получать именно GIF-анимацию, поэтому надо будет добавить отдельный шаг с конвертацией.

Первым делом напишем функции, делающими основную задачу — получение видеоданных, сохранение их и конвертация в GIF. Я решил их вынести в пакет lib на случай, если мы будем делать другую версию программы (к примеру, захотим прикрутить GUI)

Насмотревшись на красивые библиотеки, я решил, что всё будет крутиться вокруг некой абстракции — клиента, позволяющего настроить работу будущих функций:

Код структуры клиента и функции NewClient
const (
defaultBaseURL = "https://randomcatgifs.com"
defaultTempDir = "temp"
)


type Client struct {
HTTPClient *http.Client
BaseURL string
TempDir string // надо будет позже для конвертации
UserAgent string
Debug bool
}

type ClientOption func(*Client)

/* ... */

// NewClient возвращает указатель на Client
func NewClient(opts ...ClientOption) *Client {
cl := &Client{
HTTPClient: http.DefaultClient,
BaseURL: defaultBaseURL,
TempDir: defaultTempDir,
}

for _, opt := range opts {
opt(cl)
}

return cl
}
Теперь нам добыть видео. Для скрейпинга возьмём библиотеку goquery, умеющую в jQuery-подобный синтаксис.
Код получения ссылки на видео и самого видео
package lib

import (
"context"
"crypto/md5"
"encoding/hex"
"fmt"
goq "github.com/PuerkitoBio/goquery"
"io/ioutil"
"net/http"
"os"
"path/filepath"
)

// в коде присутствуют имена ошибок по типу ErrStatusNotOK или ErrNilQueryPointer.
// эти ошибки объявлены отдельно в файле errors.go

func (c *Client) GetVideoURL(ctx context.Context) (string, error) {
req, err := http.NewRequest(http.MethodGet, c.BaseURL, nil)
if err != nil {
return "", err
}

req = req.WithContext(ctx)

if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", ErrStatusNotOK
}
defer resp.Body.Close()

doc, err := goq.NewDocumentFromReader(resp.Body)
if err != nil {
return "", err
}
query := doc.Find("source") // ищем теги <source>
if query == nil {
return "", ErrNilQueryPointer
} else if query.Nodes == nil {
if c.Debug {
// отладочная информация
fmt.Printf("%v, %v\n", *query, query.Nodes)
}
return "", ErrNilNodesArray
} else if len(query.Nodes) == 0 {
return "", ErrEmptyNodesArray
}
node := query.Last().Get(0) // берём последний тег из списка (в последнем находится webm-файл с котом)
if node == nil {
return "", ErrNilNodePointer
} else if node.Attr == nil {
return "", ErrNilAttrArray
} else if len(node.Attr) == 0 {
return "", ErrEmptyAttrArray
}
var url string
for _, attr := range node.Attr {
if attr.Key == "src" {
url = attr.Val
continue
}
}
if url == "" {
return "", ErrSrcAttrNotFound
}
return url, nil
}

func (c *Client) GetVideo(ctx context.Context) ([]byte, error) {
url, err := c.GetVideoURL(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
if c.UserAgent != "" {
req.Header.Set("User-Agent", c.UserAgent)
}

resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, ErrStatusNotOK
}
defer resp.Body.Close()

dat, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return dat, nil
}
Посмотрев на варианты конвертации видео в гифку на Golang, я понял, что всё основано на ffmpeg, так что надо сохранить видео в темповую папку.
Код сохранения в temp-директорию
func (c *Client) SaveVideoToTemp(dat []byte) (string, error) {
// в качестве имени видео будет использоваться первые шесть символов хеша
hash := md5.Sum(dat)
filename := fmt.Sprintf(
"%s/%s.webm",
c.TempDir,
hex.EncodeToString(hash[:])[:6],
)
if _, err := os.Stat(filename); os.IsNotExist(err) {
err := os.MkdirAll(filepath.Dir(filename), 0770)
if err != nil {
return "", err
}
}
f, err := os.Create(filename)
if err != nil {
return "", err
}
defer f.Close()
_, err = f.Write(dat)
if err != nil {
return "", err
}
return filename, nil
}

Взяв одну из обёрток, я получил совсем простой код конвертации:
Код конвертации видео
package lib

import ffmpeg "github.com/u2takey/ffmpeg-go"

func (c *Client) Convert(from, to string, overwrite bool) error {
cmd := ffmpeg.Input(from, ffmpeg.KwArgs{}).Output(to)
if overwrite {
cmd = cmd.OverWriteOutput()
}
if c.Debug {
// для получения отладочной информации
cmd = cmd.ErrorToStdOut()
}
return cmd.Run()
}
И на этом все необходимые функции для получения котиков сделаны.

Пишем CLI​

Так как у нас довольно маленькая программа, то можно делать с помощью пакета flag из стандартной библиотеки.
Первая часть программы — инициализация — довольно проста:
Инициализация
package main

import (
"context"
"flag"
"fmt"
"gitea.com/dikey0ficial/kotogif/lib"
"io"
"log"
"os"
runtimeDebug "runtime/debug"
"time"
)

var (
// лог информации для отладки. По-умолчанию весь вывод этого лога идёт в io.Discard, что аналогично направлению в /dev/null
debl = log.New(io.Discard, "[DEBUG]\t", log.Ldate|log.Ltime|log.Lshortfile)
errl = log.New(os.Stderr, "[ERROR]\t", log.Ldate|log.Ltime|log.Lshortfile)
debug, help, notDeleteTempFile, overwrite, verMode bool
tmp, baseURL, output, useragent string
timeout int
)

const defaultUserAgent = `Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:98.0) Gecko/20100101 Firefox/98.0`

func init() {
// назначаем флаги
flag.BoolVar(&help, "help", false, "shows help; equals help command; ignores other flags")
flag.StringVar(&output, "output", "output.gif", "output file; output.gif")
flag.StringVar(&output, "o", "output.gif", "alias for --output")
flag.StringVar(&tmp, "tmp", "temp", "temp directory")
flag.BoolVar(&notDeleteTempFile, "not-del-temp", false, "doesn't delete temp file if put")
flag.BoolVar(&overwrite, "overwrite", false, "overwrites output file if it exists")
flag.StringVar(&baseURL, "url", "https://randomcatgifs.com/", "url of site (idk why could anyone need to set it)")
flag.StringVar(&useragent, "useragent", defaultUserAgent, "User-Agent header content")
flag.IntVar(&timeout, "timeout", 10, "count of seconds to get gifs")
flag.IntVar(&timeout, "t", 10, "alias for --timeout")
flag.BoolVar(&debug, "debug", false, "turns on debug log")
flag.BoolVar(&verMode, "version", false, "prints version end exits")

flag.Parse()

if help || (len(flag.Args()) > 0 && flag.Args()[0] == "help") {
fmt.Printf("Syntax: %s [flags]\n", os.Args[0])
flag.Usage()
os.Exit(0)
}

if verMode {
// получаем версию модуля, в которой был сбилдена программа
var version string
if bInfo, ok := runtimeDebug.ReadBuildInfo(); ok && bInfo.Main.Version != "(devel)" {
version = bInfo.Main.Version
} else {
version = "unknown/not-versioned build"
}
fmt.Println(version)
os.Exit(0)

}

if len(flag.Args()) > 0 {
errl.Println("have too much args.")
fmt.Printf("Syntax: %s [flags]\n", os.Args[0])
os.Exit(1)
}

if timeout <= 0 {
errl.Println("timeout must be greater than zero")
os.Exit(1)
}

if !debug {
/*
по-умолчанию библиотека для работы с ffmpeg выводит свою
итоговую команду с помощью log (похоже, забыли удалить/закомментировать это)
поэтому мы убираем вывод log'а, если мы не хотим видеть отладочную информацию
*/
log.SetOutput(io.Discard)
} else {
debl.SetOutput(os.Stderr)
}
}
Ну и основная часть, в которой мы получаем-сохраняем-конвертируем-удаляем исходное видео:
Код функции main()
func main() {
var client = lib.NewClient(
lib.BaseURL(baseURL),
lib.TempDir(tmp),
lib.UserAgent(useragent),
)
client.Debug = debug
context, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
defer cancel()
video, err := client.GetVideo(context)
if err != nil {
errl.Printf("%v\n", err)
// os.Exit вместо return, чтобы выдать код
os.Exit(1)
}
vidpath, err := client.SaveVideoToTemp(video)
if err != nil {
errl.Printf("%v\n", err)
os.Exit(1)
}
err = client.Convert(vidpath, output, overwrite)
if err != nil {
var addition string
if err.Error() == "exit status 1" {
// такая ошибка часто появляется из-за существования файла, в который хотят сохранить гиф
addition = ". (This error often happens when file already exists)"
}
errl.Printf("%v%s\n", err, addition)
os.Exit(1)
}
if !notDeleteTempFile {
err := os.Remove(vidpath)
if err != nil {
errl.Printf("%v\n", err)
os.Exit(1)
}
}
// выводим имя файла, в который сохраняем.
// не то, чтобы это было сильно полезно,
// но это будет приятным (или нет) бонусом, если
// понадобится что-то делать с получившимся
// файлом после сохранения
fmt.Printf("%s\n", output)
}

Результат​

После установки ffmpeg и добавления его в PATH, если ещё не был добавлен, наша программа запускается и прекрасно работает:
Скриншот примера работы программы
Скриншот примера работы программы
Гифка, полученная предыдущей командой
Гифка, полученная предыдущей командой
Теперь у нас есть простой способ получить новую порцию котов)
Если кому интересно почитать исходный код или попробовать самому — вот репозиторий на Gitea. Спасибо за внимание!)

 
Сверху