Автоматизация сбора купонов для бесплатной литературы

Предыстория

На ЛитРес есть система бонусов и купонов, которые появляются с завидной регулярностью. Чтобы сделать приятное жене, да и в целом может найти себе интересную книжку, начал мониторить сайт в котором появляются свежие купоны и скидывал их в телеграм. Но буквально спустя несколько дней мне это дело надоело и я решил автоматизировать данный процесс так, чтобы он мог быть доступен всем кто этого хочет.

Реализация

Так как я постоянно постил новые купоны в телеграм, да и в целом мне нравится этот инструмент, решил создать еще одного бота для телеграма, благо дело для него уже создано достаточное количество библиотек. Возьмем в качестве языка golang и библиотеку telegram-bot-api. Так же нам нужно выбрать ресурс с которого можно было бы тянуть информацию, у меня на примете было несколько сайтов и я думал в целом написать универсальный парсер, но в какой-то момент мне стало лень, и я решил остановить свой выбор на одном ресурсе. Для того чтобы хранить купоны даже после рестарта, решил воспользоваться простой базой sqlite3. В ней будем хранить информацию о купонах, а так же информацию о зарегистрированных пользователях в телеграм боте, так же информацию о том какие купоны пользователь уже получил и какие ещё нет.

Выглядит это примерно так

image

Парсинг сайта

Парсингом сайта у нас займется библиотека goquery — работает примерно так же как и jquery.
Оперируя структурой goquery.Document вытягиваем html теги нужные нам. Получаем дату, код, ссылку и описание купона. Купоны бывают разные поэтому нам нужно взять описание чтобы точно знать что он дает. Время на сайте отображается в нескольких форматах значит его мы будем преобразовывать unixtime, для того чтобы потом можно было удобно проверять скор действия купонов. Ссылку тоже изменим, для того чтобы она отработала и перевела нас на сайт ЛитРес, иначе возникнет ошибка. Связана она с тем, что ссылка ведет на сайт из которого мы вытягиваем купоны, и если мы перешли туда воспользовавшись прямой ссылкой не через сайт то js на этой странице не отрабатывает и мы не получаем желаемого результата.

Telegram bot

Как настраивать бота с помощью BotFather я рассказывать не буду, на сайте telegram есть отличная инструкция. В telegram api есть несколько способов получать события произошедшие с ботом, первый это простой http запрос который отдаст все в виде струтуры и второй это websocket. Изначально я не собирался использовать свой сервак по этому воспользовался первым способом, благо дело что в telegram-bot-api есть updater и разницу вы не почувствуете.

Создание бота

type SNBot struct {
    cfg *Config
    bot *tgbotapi.BotAPI
    upd tgbotapi.UpdatesChannel
}

func New(cfg *Config) (*SNBot, error) {
    bot, err := tgbotapi.NewBotAPI(cfg.Token)
    if err != nil {
        return nil, err
    }
    level.Info(cfg.Logger).Log("msg", "Authorized on account", "bot-name", bot.Self.UserName)
    u := tgbotapi.NewUpdate(0)
    u.Timeout = cfg.UpdateTime
    updates, err := bot.GetUpdatesChan(u)
    if err != nil {
        return nil, err
    }
    return &SNBot{
        cfg: cfg,
        bot: bot,
        upd: updates,
    }, nil
}

После создания базы, бота и парсера мы должны задать время через которое будет работать наш бот и делать рассылку, для этого воспользуемся gocron. Сначала парсер опрашивает сайт потом task которую мы создали в gocron собирает все чаты из storage и отправляет ещё не отправленные купоны из storage в чат юзера.

Task function

func task(bot *snbot.SNBot, s *storage.Storage, c *collector.Collector, logger kitlog.Logger) {
    c.Collect(collector.ConditionQuery{
        URI: "https://lovikod.ru/knigi/promokody-litres",
    })
    chats, err := s.GetChat()
    if err != nil {
        level.Error(logger).Log("msg", "failed get chats", "err", err)
    }
    for _, id := range chats {
        records, err := s.GetNotUseCoupon(id)
        if err != nil {
            level.Error(logger).Log("msg", "failed get coupons", "err", err)
            return
        }
        var msg string
        for i, rec := range records {
            msg = fmt.Sprintf("%v%v:t%s nКод--->: %snВремя истечения: %vnОписание: %snn", msg, i+1, rec.Link, rec.Code, time.Unix(rec.Date, 0).Format("02.01.2006"), rec.Description)
        }
        if len(msg) != 0 {
            err = bot.Send(id, msg)
            if err != nil {
                level.Error(logger).Log("msg", "failed send message", "err", err)
                continue
            }
            err = s.MarkAsRead(id, records)
            if err != nil {
                level.Error(logger).Log("msg", "failed marked as read", "err", err)
                continue
            }
        }
    }
    level.Info(logger).Log("msg", "send all chats new coupons")
}

Из-за того что количество купонов бывает намного больше, чем разрешено отправлять в чат, пришлось сделать искусственное ограничение на количество купонов за один раз до пяти и добавить спец кнопку, которая позволяет получить все ещё не полученные купоны.

Функция отправки сообщения

func (s *SNBot) Send(chatID int64, msg string) error {
    level.Error(s.cfg.Logger).Log("msg", "try send", "chatID", chatID)
    var numericKeyboard = tgbotapi.NewReplyKeyboard(
        tgbotapi.NewKeyboardButtonRow(
            tgbotapi.NewKeyboardButton("/print5"),
        ),
    )
    m := tgbotapi.NewMessage(chatID, msg)
    m.ReplyMarkup = numericKeyboard
    _, err := s.bot.Send(m)
    if err != nil {
        if err.Error() == errBlockedByUser {
            s.cfg.Storage.UpdChatActivity(chatID, false)
        }
        return err
    }
    return nil
}

 Создание Dockerfile

Какое нынче приложение да и без докера, создадим  докер образ.

# build binary
FROM golang:1.10.3-alpine3.8 AS build
RUN apk add --no-cache linux-headers gcc g++
ARG VERSION=dev
WORKDIR /go/src/github.com/wenkaler/xfreehack
COPY . /go/src/github.com/wenkaler/xfreehack
RUN CGO_ENABLED=1 go build 
    -o /out/xfree 
    -ldflags "-X main.serviceVersion=$VERSION" 
    github.com/wenkaler/xfreehack/cmd

# copy to alpine image
FROM alpine:3.8
WORKDIR /app
RUN mkdir /db
COPY --from=build /out/xfree /app
RUN apk add --no-cache tzdata
RUN apk --no-cache add ca-certificates
ENV TZ Europe/Moscow
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
CMD ["/app/xfree"]

Systemd

В итоге я все же купил виртуалку. Но как оказалось на ней стоит ядро версии 2.6, поэтому я не стал париться с его upgrad-ом и просто все запустил через systemd. Зачем? — просто докер не может быть установлен на систему с ядром ниже версии 3.

[Unit]
Description=Xfree service
After=network.target
After=network-online.target

[Service]
ExecStart=/urs/local/bin/xfree
Environment="TELEGRAM_TOKEN=$TELEGRAM_TOKEN" "PATH_DB=/db/xfree.db"
TimeoutSec=30
Restart=on-failure
RestartSec=30

[Install]
WantedBy=multi-user.target

Вывод

 Теперь жена довольна, что может получать и бесплатные книги на ЛитРес, ну а мне было просто интересно решить эту проблему.Есть ещё что можно улучшить, добавить систему оповещения если не сработает ф-я MarkAsRead (пока что такого не было, но мало ли), так же он сейчас отменяет подписку и больше не рассылает сообщения людям которые отписались от него и нужно вернуть их в активное состояние после повторного нажатия команды /start. Ну и в целом добавить возможность выбора времени рассылки и выбора купонов, ведь на сайте есть не только купоны от ЛитРес. Но это все по необходимости, пока что таких заявок не поступало.

Ссылки

  1. Сам проект
  2. Сайт с купонами
  3. Имя бота @xFreeCouponBot

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр