// 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 рекурсивная функция, ищущая