В моём окружении часто отправляют гифки с котами. К сожалению, рано или поздно запас заканчивается, и приходится идти и искать новые.
Недавно я пошёл искать новые, после чего мне пришла идея автоматизировать данный процесс. Делать мне тогда было нечего, и я пошёл писать для этого простую cli-программу на Go.
Дисклеймер
Я не говорю, что в этом проекте идеальные (или вообще хорошие) решения и код. Я просто делюсь опытом и восстанавливаю карму :)
Получаем котеек
Для начала нам надо выбрать, откуда брать наши гифки. По запросу в поисковике я обнаружил сайт, выдающий случайные гифки с котами, с логичным и лаконичным названием 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(¬DeleteTempFile, "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. Спасибо за внимание!)
Комментарии (8)
Schokn-Itrch
27.05.2022 13:46Можно gifsci прикрутить, чтобы котики были более мимимишными.
dikey_0ficial Автор
27.05.2022 14:28погуглил. интересная штука)
однако использование ffmpeg позволяет сделать интересную вещь — конвертировать в, допустим, mp4, если файл, в который будет выводиться, будеть иметь соответствующее расширение. спасибо, подумаю, может всё же стоит заменить)Schokn-Itrch
27.05.2022 17:02А я предлагал gifsci не вместо, а вместе :) gifsci просто оптимизирует палитру и дизеринг. Стоит просто сравнить ;)
Hidden text
Оригинал
ffmpeg (5 127 454)
gifski --quality 78 (5 115 418)
Xardas225
27.05.2022 18:40-1Мб у кого-то тоже такой баг на сайте randomcatgifs.com.
Когда попадаешь вот на эту гифку, то сайт зацикливается и показывает только 2 гифки.dikey_0ficial Автор
27.05.2022 18:41Насколько я понял, ссылка по кнопке определена, а не рандомна, значит тут замкнутый круг)
aktuba
Для чего такие мучения, если есть готовые апи? Ну например (думаю, бесплатного лимита хватит):
dikey_0ficial Автор
хм, не знал о таком. спасибо!)
P.S. впрочем, это нельзя назвать мучением — всё довольно просто)