234 lines
6.9 KiB
Go
234 lines
6.9 KiB
Go
// Package celebratoday implements client for celebratoday.com service.
|
|
package celebratoday
|
|
|
|
import (
|
|
"aleesa-webapp-go/internal/config"
|
|
"aleesa-webapp-go/internal/log"
|
|
"aleesa-webapp-go/internal/pcachedb"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/carlmjohnson/requests"
|
|
"golang.org/x/net/html"
|
|
)
|
|
|
|
type (
|
|
// Holy структура, описывающая искомый json-чик.
|
|
Holy struct {
|
|
AtContext string `json:"@context"`
|
|
AtType string `json:"@type"`
|
|
MainEntity []struct {
|
|
AtType string `json:"@type"`
|
|
Name string `json:"name"`
|
|
AcceptedAnswer struct {
|
|
AtType string `json:"@type"`
|
|
Text string `json:"text"`
|
|
UpVoteCount int `json:"upvoteCount"`
|
|
URL string `json:"url,omitempty"`
|
|
} `json:"acceptedAnswer"`
|
|
SuggestedAnswer []struct {
|
|
AtType string `json:"@type"`
|
|
Text string `json:"text"`
|
|
UpVoteCount int `json:"upvoteCount"`
|
|
URL string `json:"url,omitempty"`
|
|
} `json:"suggestedAnswer"`
|
|
} `json:"mainEntity"`
|
|
}
|
|
)
|
|
|
|
// Client обёртка для клиента сервиса celebratoday.com, работающая с кэшем. Запросы надо делать
|
|
// именно через неё.
|
|
func Client(cfg *config.MyConfig) (string, error) {
|
|
tsNow := time.Now()
|
|
|
|
// eTime TTL кэша. На текущий момент +1 час к текущему времени.
|
|
eTime := tsNow.Unix() + 3600
|
|
|
|
tsStr := pcachedb.GetValue(cfg, "cache", "celebratoday_timestamp")
|
|
|
|
// Кэш пустой, запросов пока не было.
|
|
if tsStr == "" {
|
|
log.Debugf("CT no cache entry for celebratoday.com")
|
|
|
|
answer, err := APIClient(cfg)
|
|
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := UpdateCache(cfg, eTime, answer); err != nil {
|
|
log.Errorf("%s", err)
|
|
}
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
tsCache, err := strconv.ParseInt(tsStr, 10, 64)
|
|
|
|
if err != nil {
|
|
log.Debug("KSP cache miss")
|
|
log.Warnf("KSP-Cache: unable to parse string as int64 (sha256 collision?): %s", err)
|
|
|
|
answer, err := APIClient(cfg)
|
|
|
|
// Бинго! у нас ещё и ошибка при обращении к "api" celebratoday.com, здесь мы ничего сделать не можем.
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Если всё хорошо, надо обновить кэш.
|
|
if err := UpdateCache(cfg, eTime, answer); err != nil {
|
|
log.Errorf("%s", err)
|
|
}
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
// Текущее время в Unix timestamp.
|
|
tsNowUnix := tsNow.Unix()
|
|
|
|
// Кэш просрочен.
|
|
if tsCache < tsNowUnix {
|
|
log.Debug("KSP cache miss")
|
|
|
|
answer, err := APIClient(cfg)
|
|
|
|
// Здесь мы ничего сделать не можем.
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Если всё хорошо, надо обновить кэш.
|
|
if err := UpdateCache(cfg, eTime, answer); err != nil {
|
|
log.Errorf("%s", err)
|
|
}
|
|
|
|
return answer, nil
|
|
}
|
|
|
|
// TODO: как-то научиться определять ситуацию с отсутствующим значением в кэше.
|
|
// TODO: Научиться определять ситуацию, когда значением является unix timestamp.
|
|
log.Debug("CT cache hit")
|
|
|
|
return pcachedb.GetValue(cfg, "cache", "celebratoday_value"), nil
|
|
}
|
|
|
|
// APIClient клиент сервиса https://celebratoday.com/.
|
|
func APIClient(cfg *config.MyConfig) (string, error) {
|
|
var (
|
|
ctx = context.Background()
|
|
url = "https://celebratoday.com/ru/today"
|
|
respBody string
|
|
)
|
|
|
|
// Формальная попытка притвориться браузером.
|
|
_, _, day := time.Now().Date()
|
|
userAgent := cfg.UserAgents[day]
|
|
|
|
if err := requests.URL(url).UserAgent(userAgent).
|
|
Header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8").
|
|
Header("Accept-Language", "en-US,en;q=0.5").
|
|
Header("User-Agent", "ru-RU").
|
|
Header("Accept-Charset", "utf-8").ToString(&respBody).Fetch(ctx); err != nil {
|
|
return "", fmt.Errorf("unable to GET %s: %w", url, err)
|
|
}
|
|
|
|
holidays, err := parser(respBody)
|
|
|
|
if err != nil {
|
|
return "", fmt.Errorf("Unable to parse response from celebratoday.com: %w", err)
|
|
}
|
|
|
|
if len(holidays) == 0 {
|
|
return "", errors.New("unable to parse response from celebratoday.com: no holidays found")
|
|
}
|
|
|
|
slices.Sort(holidays)
|
|
|
|
holidays = slices.Compact(holidays)
|
|
|
|
return "* " + strings.Join(holidays, "\n* "), nil
|
|
}
|
|
|
|
// UpdateCache сохраняет в кэш ответ от сервиса celebratoday и временной штамп.
|
|
func UpdateCache(cfg *config.MyConfig, eTime int64, value string) error {
|
|
key := "celebratoday_timestamp"
|
|
timestamp := strconv.FormatInt(eTime, 10)
|
|
|
|
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, timestamp); err != nil {
|
|
return fmt.Errorf("CT-Cache: unable to save timestamp to cache: %w", err)
|
|
}
|
|
|
|
key = "celebratoday_value"
|
|
|
|
if err := pcachedb.SaveKeyWithValue(cfg, "cache", key, value); err != nil {
|
|
return fmt.Errorf("CT-Cache: unable to save answer to cache: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// parser разбирает буфер с html-ем, выискивая праздники.
|
|
func parser(data string) ([]string, error) {
|
|
var (
|
|
holidays = make([]string, 0, 1)
|
|
err error
|
|
s = make([]Holy, 0, 1)
|
|
)
|
|
|
|
doc, err := html.Parse(strings.NewReader(data))
|
|
|
|
if err != nil {
|
|
return holidays, err
|
|
}
|
|
|
|
traverse(doc, &s)
|
|
|
|
// А теперь пробежимся по полученной структуре и вытянем из неё все строки с праздниками.
|
|
for _, ass := range s {
|
|
for _, entity := range ass.MainEntity {
|
|
for _, sugestedAnswer := range entity.SuggestedAnswer {
|
|
holidays = append(holidays, sugestedAnswer.Text)
|
|
}
|
|
}
|
|
}
|
|
|
|
return holidays, err
|
|
}
|
|
|
|
// traverse рекурсивная функция, ищущая <script type="application/ld+json"> в структуре документа. Там внутри будет json
|
|
// со списком праздников и это и есть цель нашего поиска.
|
|
func traverse(n *html.Node, h *[]Holy) {
|
|
// Если найденный элемент в поле Data содержит script (то есть это тэг <script>).
|
|
if n.Type == html.ElementNode && n.Data == "script" {
|
|
// Ищем атрибут type со значением application/ld+json.
|
|
for _, attr := range n.Attr {
|
|
if attr.Key == "type" && attr.Val == "application/ld+json" {
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
if c.Type == html.TextNode {
|
|
// Добавлять рановато, надо вначале попробовать распарсить этот json
|
|
// используя целевую структуру.
|
|
var holiday Holy
|
|
|
|
if err := json.Unmarshal([]byte(c.Data), &holiday); err == nil {
|
|
*h = append(*h, holiday)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
|
traverse(c, h)
|
|
}
|
|
}
|
|
|
|
/* vim: set ft=go noet ai ts=4 sw=4 sts=4: */
|