Предыстория
На ЛитРес есть система бонусов и купонов, которые появляются с завидной регулярностью. Чтобы сделать приятное жене, да и в целом может найти себе интересную книжку, начал мониторить сайт в котором появляются свежие купоны и скидывал их в телеграм. Но буквально спустя несколько дней мне это дело надоело и я решил автоматизировать данный процесс так, чтобы он мог быть доступен всем кто этого хочет.
Реализация
Так как я постоянно постил новые купоны в телеграм, да и в целом мне нравится этот инструмент, решил создать еще одного бота для телеграма, благо дело для него уже создано достаточное количество библиотек. Возьмем в качестве языка golang и библиотеку telegram-bot-api. Так же нам нужно выбрать ресурс с которого можно было бы тянуть информацию, у меня на примете было несколько сайтов и я думал в целом написать универсальный парсер, но в какой-то момент мне стало лень, и я решил остановить свой выбор на одном ресурсе. Для того чтобы хранить купоны даже после рестарта, решил воспользоваться простой базой sqlite3. В ней будем хранить информацию о купонах, а так же информацию о зарегистрированных пользователях в телеграм боте, так же информацию о том какие купоны пользователь уже получил и какие ещё нет.
Выглядит это примерно так
Парсинг сайта
Парсингом сайта у нас займется библиотека 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 в чат юзера.
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Код--->: %s\nВремя истечения: %v\nОписание: %s\n\n", 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. Ну и в целом добавить возможность выбора времени рассылки и выбора купонов, ведь на сайте есть не только купоны от ЛитРес. Но это все по необходимости, пока что таких заявок не поступало.
Ссылки
- Сам проект
- Сайт с купонами
- Имя бота @xFreeCouponBot