// 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" "errors" "fmt" "io" "slices" "strconv" "strings" "time" "github.com/carlmjohnson/requests" "golang.org/x/net/html" ) // 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 orangeClass int8 tgHref int8 ) // Формальная попытка притвориться браузером. _, _, 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) } var holidays []string // Парсим body ответа от сервера. tokenizer := html.NewTokenizer(strings.NewReader(respBody)) for { tokenType := tokenizer.Next() if tokenType == html.ErrorToken { err := tokenizer.Err() if errors.Is(err, io.EOF) { // End of the file, break out of the loop. break } return "", err } if tokenType == html.StartTagToken { token := tokenizer.Token() if token.Data == "a" { for _, attribute := range token.Attr { // Кнопка о том, что у нас было вчера. if attribute.Key == "class" && attribute.Val == "text-orange-500 underline hover:no-underline" { orangeClass++ continue } // Наши ссылки находятся после первого и до второго упоминания о телеграме. if attribute.Key == "href" && attribute.Val == "https://t.me/celebratoday" { tgHref++ continue } if attribute.Key == "title" && tgHref == 1 && orangeClass == 2 { switch { case strings.HasPrefix(attribute.Val, "Праздники "): continue case strings.HasPrefix(attribute.Val, "Международные праздники "): continue case strings.HasPrefix(attribute.Val, "Всемирные праздники "): continue case strings.HasPrefix(attribute.Val, "Спортивные праздники "): continue case strings.HasPrefix(attribute.Val, "Медицинские праздники"): continue case strings.HasPrefix(attribute.Val, "Подробнее про"): continue case strings.HasPrefix(attribute.Val, "Необычные праздники"): continue case strings.HasPrefix(attribute.Val, "Профессиональные праздники"): continue case strings.HasPrefix(attribute.Val, "Национальные праздники"): continue case strings.HasPrefix(attribute.Val, "Народные праздники"): continue case strings.HasPrefix(attribute.Val, "Религиозные праздники"): continue default: holidays = append(holidays, strings.TrimSuffix(attribute.Val, " | celebratoday.com")) } continue } } } } // Если мы встретили вторую ссылку на телеграм, то конечная, дальше нам не надо. if tgHref >= 2 { break } } 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 } /* vim: set ft=go noet ai ts=4 sw=4 sts=4: */