// Package animeschedule provides an integration with the animeschedule.net API. package animeschedule import ( "context" "encoding/json" "fmt" "io" netutil "mal/pkg/net" "net/http" "net/url" "os" "regexp" "slices" "strconv" "strings" "time" "github.com/PuerkitoBio/goquery" ) type AirType string const ( AirTypeJPN AirType = "JPN" AirTypeSUB AirType = "SUB" AirTypeDUB AirType = "DUB" ) type Entry struct { Title string AnimeURL string ImageURL string EpisodeText string AirType AirType AirsAt time.Time LocalTime string DateLabel string Weekday time.Weekday } type WeekSchedule struct { Year int Week int Days map[time.Weekday][]Entry } type HTTPStatusError struct { StatusCode int URL string ContentType string BodyPreview string } func (e *HTTPStatusError) Error() string { return fmt.Sprintf("unexpected status %d for %s", e.StatusCode, e.URL) } var reWeek = regexp.MustCompile(`(?i)[?&]week=(\d+)`) var reYear = regexp.MustCompile(`(?i)[?&]year=(\d+)`) func scheduleLocation(timezone string) (*time.Location, error) { timezone = strings.TrimSpace(timezone) if timezone == "" { timezone = "UTC" } location, err := time.LoadLocation(timezone) if err != nil { return nil, fmt.Errorf("load schedule timezone %q: %w", timezone, err) } return location, nil } func FetchWeek(ctx context.Context, httpClient *http.Client, year int, week int, timezone string) (WeekSchedule, error) { apiToken := strings.TrimSpace(os.Getenv("ANIMESCHEDULE_API_TOKEN")) if apiToken != "" { return fetchWeekAPI(ctx, httpClient, apiToken, year, week, timezone) } location, err := scheduleLocation(timezone) if err != nil { return WeekSchedule{}, err } u, _ := url.Parse("https://animeschedule.net/") q := u.Query() if year > 0 { q.Set("year", strconv.Itoa(year)) } if week > 0 { q.Set("week", strconv.Itoa(week)) } u.RawQuery = q.Encode() doc, finalURL, err := fetchDocument(ctx, httpClient, u.String()) if err != nil { return WeekSchedule{}, err } resolvedYear := year resolvedWeek := week if resolvedWeek == 0 { if match := reWeek.FindStringSubmatch(finalURL); len(match) == 2 { if v, err := strconv.Atoi(match[1]); err == nil { resolvedWeek = v } } } if resolvedYear == 0 { if match := reYear.FindStringSubmatch(finalURL); len(match) == 2 { if v, err := strconv.Atoi(match[1]); err == nil { resolvedYear = v } } } out := WeekSchedule{ Year: resolvedYear, Week: resolvedWeek, Days: map[time.Weekday][]Entry{}, } doc.Find(".timetable-column").Each(func(_ int, column *goquery.Selection) { h1 := column.Find("h1.timetable-column-date").First() rawHeader := strings.Join(strings.Fields(strings.TrimSpace(h1.Text())), " ") weekday := parseWeekdayFromHeader(rawHeader) if weekday == nil { return } dayEntries := make([]Entry, 0, 16) column.Find(".timetable-column-show").Each(func(_ int, show *goquery.Selection) { if selectionHasClass(show, "filtered-out") { return } a := show.Find("a.show-link").First() title := strings.TrimSpace(a.Find("h2").First().Text()) if title == "" { title = strings.TrimSpace(a.Text()) } href, _ := a.Attr("href") animeURL := absolutizeURL("https://animeschedule.net", href) imageURL := "" if img := a.Find("img").First(); img != nil && img.Length() == 1 { if src, ok := img.Attr("data-src"); ok { imageURL = strings.TrimSpace(src) } if imageURL == "" { if src, ok := img.Attr("src"); ok && !strings.HasPrefix(src, "data:") { imageURL = strings.TrimSpace(src) } } } meta := show.Find("h3.time-bar").First() metaText := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") epText, _, airType := parseMeta(metaText) localTime, airsAt, _, _ := parseLocalTime(meta, location) if title == "" || animeURL == "" || localTime == "" || airType != AirTypeSUB { return } dayEntries = append(dayEntries, Entry{ Title: title, AnimeURL: animeURL, ImageURL: imageURL, EpisodeText: epText, AirType: airType, AirsAt: airsAt, LocalTime: localTime, DateLabel: rawHeader, Weekday: *weekday, }) }) if len(dayEntries) == 0 { return } out.Days[*weekday] = append(out.Days[*weekday], preferredReleaseEntries(dayEntries)...) }) return out, nil } func selectionHasClass(selection *goquery.Selection, className string) bool { raw, ok := selection.Attr("class") if !ok { return false } return slices.Contains(strings.Fields(raw), className) } func parseWeekdayFromHeader(header string) *time.Weekday { lower := strings.ToLower(header) candidates := []struct { key string val time.Weekday }{ {"monday", time.Monday}, {"tuesday", time.Tuesday}, {"wednesday", time.Wednesday}, {"thursday", time.Thursday}, {"friday", time.Friday}, {"saturday", time.Saturday}, {"sunday", time.Sunday}, } for _, c := range candidates { if strings.Contains(lower, c.key) { v := c.val return &v } } return nil } func parseMeta(meta string) (episodeText string, localTime string, airType AirType) { // Example: "Ep 8 04:00 PM SUB" parts := strings.Fields(meta) if len(parts) < 4 { return "", "", "" } // Find the time token(s) var timeIdx = -1 for i := range parts { if strings.Contains(parts[i], ":") && len(parts[i]) >= 4 { timeIdx = i break } } if timeIdx == -1 || timeIdx+2 >= len(parts) { return "", "", "" } localTime = strings.TrimSpace(parts[timeIdx] + " " + parts[timeIdx+1]) typeRaw := strings.TrimSpace(parts[timeIdx+2]) switch strings.ToUpper(typeRaw) { case "JPN": airType = AirTypeJPN case "SUB": airType = AirTypeSUB case "DUB": airType = AirTypeDUB default: return "", "", "" } episodeText = strings.TrimSpace(strings.Join(parts[:timeIdx], " ")) return episodeText, localTime, airType } func preferredReleaseEntries(entries []Entry) []Entry { type keyedEntry struct { index int entry Entry } selected := map[string]keyedEntry{} for i, entry := range entries { key := entry.AnimeURL + "\x00" + entry.EpisodeText current, ok := selected[key] if !ok || airTypePriority(entry.AirType) > airTypePriority(current.entry.AirType) { selected[key] = keyedEntry{index: i, entry: entry} } } out := make([]keyedEntry, 0, len(selected)) for _, entry := range selected { out = append(out, entry) } slices.SortFunc(out, func(a keyedEntry, b keyedEntry) int { return a.index - b.index }) preferred := make([]Entry, 0, len(out)) for _, entry := range out { preferred = append(preferred, entry.entry) } return preferred } func airTypePriority(airType AirType) int { switch airType { case AirTypeSUB: return 3 case AirTypeDUB: return 2 case AirTypeJPN: return 1 default: return 0 } } func parseLocalTime(meta *goquery.Selection, location *time.Location) (localTime string, airsAt time.Time, rawDatetime string, rawRenderedTime string) { // AnimeSchedule updates rendered time client-side based on the viewer's timezone. // The server-rendered HTML can show a different time string, so we prefer the `datetime` // attribute when available. t := meta.Find("time").First() if t.Length() == 1 { rawRenderedTime = strings.Join(strings.Fields(strings.TrimSpace(t.Text())), " ") if raw, ok := t.Attr("datetime"); ok { rawDatetime = raw if parsed, err := parseScheduleDatetime(rawDatetime); err == nil { airsAt = parsed.In(location) localTime = airsAt.Format("15:04") return localTime, airsAt, rawDatetime, rawRenderedTime } } } fallback := strings.Join(strings.Fields(strings.TrimSpace(meta.Text())), " ") _, parsedTime, _ := parseMeta(fallback) return parsedTime, time.Time{}, "", "" } func parseScheduleDatetime(value string) (time.Time, error) { for _, layout := range []string{time.RFC3339, "2006-01-02T15:04Z07:00"} { parsed, err := time.Parse(layout, strings.TrimSpace(value)) if err == nil { return parsed, nil } } return time.Time{}, fmt.Errorf("parse schedule datetime %q", value) } func absolutizeURL(base string, href string) string { href = strings.TrimSpace(href) if href == "" { return "" } if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { return href } return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(href, "/") } func addCommonHeaders(request *http.Request) { netutil.SetBrowserHTMLHeaders(request, "https://animeschedule.net/") } func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*goquery.Document, string, error) { document, response, err := netutil.FetchHTMLDocument(ctx, httpClient, url, addCommonHeaders, func(response *http.Response, body []byte) error { return &HTTPStatusError{ StatusCode: response.StatusCode, URL: url, ContentType: strings.TrimSpace(response.Header.Get("Content-Type")), BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "), } }) if err != nil { return nil, url, err } return document, response, nil } type timetableAnimeAPI struct { Title string `json:"title"` English string `json:"english"` Route string `json:"route"` EpisodeDate time.Time `json:"episodeDate"` EpisodeNumber int `json:"episodeNumber"` SubtractedEpisodeNumber int `json:"subtractedEpisodeNumber"` AirType string `json:"airType"` ImageVersionRoute string `json:"imageVersionRoute"` } func fetchWeekAPI(ctx context.Context, httpClient *http.Client, token string, year int, week int, timezone string) (WeekSchedule, error) { client := httpClient if client == nil { client = http.DefaultClient } location, err := scheduleLocation(timezone) if err != nil { return WeekSchedule{}, err } u, _ := url.Parse("https://animeschedule.net/api/v3/timetables/sub") q := u.Query() if year > 0 && week > 0 { q.Set("year", strconv.Itoa(year)) q.Set("week", strconv.Itoa(week)) } q.Set("tz", location.String()) u.RawQuery = q.Encode() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return WeekSchedule{}, fmt.Errorf("create api request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") req.Header.Set("User-Agent", netutil.Chrome135) res, err := client.Do(req) if err != nil { return WeekSchedule{}, fmt.Errorf("api request failed: %w", err) } defer func() { _ = res.Body.Close() }() if res.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(res.Body, netutil.Bytes512)) return WeekSchedule{}, &HTTPStatusError{ StatusCode: res.StatusCode, URL: u.String(), ContentType: strings.TrimSpace(res.Header.Get("Content-Type")), BodyPreview: strings.Join(strings.Fields(strings.TrimSpace(string(body))), " "), } } var payload []timetableAnimeAPI if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { return WeekSchedule{}, fmt.Errorf("decode timetables api: %w", err) } resolvedYear := year resolvedWeek := week if resolvedYear == 0 || resolvedWeek == 0 { resolvedYear, resolvedWeek = time.Now().In(time.Local).ISOWeek() } out := WeekSchedule{ Year: resolvedYear, Week: resolvedWeek, Days: map[time.Weekday][]Entry{}, } for _, item := range payload { title := strings.TrimSpace(item.English) if title == "" { title = strings.TrimSpace(item.Title) } if title == "" { continue } episodeNumber := item.EpisodeNumber subtracted := item.SubtractedEpisodeNumber episodeText := "" switch { case subtracted > 0 && subtracted < episodeNumber: episodeText = fmt.Sprintf("Ep %d-%d", subtracted, episodeNumber) case episodeNumber > 0: episodeText = fmt.Sprintf("Ep %d", episodeNumber) default: episodeText = "Ep ?" } airType := AirType(strings.ToUpper(strings.TrimSpace(item.AirType))) if airType != AirTypeSUB { continue } episodeTime := item.EpisodeDate.In(location) weekday := episodeTime.Weekday() localTime := episodeTime.Format("15:04") imageURL := "" if strings.TrimSpace(item.ImageVersionRoute) != "" { imageURL = "https://img.animeschedule.net/production/assets/public/img/" + strings.TrimLeft(strings.TrimSpace(item.ImageVersionRoute), "/") } animeURL := "" if strings.TrimSpace(item.Route) != "" { animeURL = "https://animeschedule.net/anime/" + strings.TrimLeft(strings.TrimSpace(item.Route), "/") } out.Days[weekday] = append(out.Days[weekday], Entry{ Title: title, AnimeURL: animeURL, ImageURL: imageURL, EpisodeText: episodeText, AirType: airType, AirsAt: episodeTime, LocalTime: localTime, Weekday: weekday, }) } return out, nil }