Пятничное: пишем консольную утилиту на 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/


Интересные статьи

Интересные статьи

Привет Хабр!Во время локдауна я решил поупражняться в разработке под Android. Начать решил с простой гиперказуальной игры с элементами дуэли. Взаимодействие игроков решил реализовать через отдельный с...
С чего всё началось Начнём с постановки проблемы. Дано: один ноутбук. Новый ноутбук, геймерский. С RGB-подсветкой. Вот такой примерно ноутбук: Картинка взята с lenovo.com Есть ещё програ...
В данном посте мы коснемся написания action'ов и reducer'а. Для начала рассмотрим типичный 'flow', в котором мы выполняем следующие операции (далее переработаем все так, чтобы наш код отвечал при...
Однажды, в понедельник, мне пришла в голову мысль — "а покопаюсь ка я в новом ядре" (новым относительно, но об этом позже). Мысль не появилась на ровном месте, а предпосылками для нее стали: ...
Мне стало скучно писать на Python, захотелось чего-нибудь необычного. Решил попробовать Haskell. Языка я не знаю, однако просто писать консольные учебные программы, типа вычисления факториала, не...