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

от автора

В моём окружении часто отправляют гифки с котами. К сожалению, рано или поздно запас заканчивается, и приходится идти и искать новые.
Недавно я пошёл искать новые, после чего мне пригла идея автоматизировать данный процесс. Делать мне тогда было нечего, и я пошёл писать для этого простую 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. Спасибо за внимание!)


ссылка на оригинал статьи https://habr.com/ru/post/668162/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *