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