package ted import ( "bytes" "context" "encoding/csv" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) const ( calendarURL = "%s/en/release-calendar/-/download/file/CSV/%d" searchAPIURL = "https://api.ted.europa.eu/v3/notices/search" ) type searchAPIRequest struct { Query string `json:"query"` Fields []string `json:"fields"` Limit int `json:"limit"` } type searchAPIResponse struct { Notices []struct { OJSNumber string `json:"ojs-number"` } `json:"notices"` } // GetOJS resolves the OJS package id for a publication date. // It tries the TED release calendar CSV first, then falls back to the TED Search API // when the calendar download is blocked (e.g. AWS WAF HTTP 202). func GetOJS(ctx context.Context, baseURL string, year int, date, userAgent string, client *http.Client) (string, error) { if ojs, err := getOJSFromCalendarCSV(ctx, baseURL, year, date, userAgent, client); err == nil { return ojs, nil } else if !isCalendarUnavailable(err) { return "", err } return getOJSFromSearchAPIAt(ctx, date, userAgent, client, searchAPIURL) } func getOJSFromCalendarCSV(ctx context.Context, baseURL string, year int, date, userAgent string, client *http.Client) (string, error) { url := fmt.Sprintf(calendarURL, baseURL, year) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return "", fmt.Errorf("create calendar request: %w", err) } if strings.TrimSpace(userAgent) != "" { req.Header.Set("User-Agent", userAgent) } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("calendar HTTP error: %d %s (body: %s)", resp.StatusCode, resp.Status, previewBody(body)) } reader := csv.NewReader(bytes.NewReader(body)) records, err := reader.ReadAll() if err != nil { return "", fmt.Errorf("parse calendar CSV: %w", err) } if len(records) < 2 { return "", fmt.Errorf("ojs calendar is empty (rows=%d, body=%s)", len(records), previewBody(body)) } targetDate := strings.TrimSpace(date) for _, row := range records[1:] { if len(row) < 2 { continue } rowDate := strings.TrimSpace(row[1]) if rowDate == targetDate { return fmt.Sprintf("%d%05s", year, strings.TrimSpace(row[0])), nil } } return "", fmt.Errorf("can not find ojs number for date %s", date) } func getOJSFromSearchAPIAt(ctx context.Context, date, userAgent string, client *http.Client, apiURL string) (string, error) { parsed, err := time.Parse("02/01/2006", strings.TrimSpace(date)) if err != nil { return "", fmt.Errorf("parse publication date %s: %w", date, err) } payload, err := json.Marshal(searchAPIRequest{ Query: fmt.Sprintf("publication-date=%s", parsed.Format("20060102")), Fields: []string{"ojs-number"}, Limit: 1, }) if err != nil { return "", fmt.Errorf("marshal search request: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payload)) if err != nil { return "", fmt.Errorf("create search request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if strings.TrimSpace(userAgent) != "" { req.Header.Set("User-Agent", userAgent) } resp, err := client.Do(req) if err != nil { return "", err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", err } if resp.StatusCode != http.StatusOK { return "", fmt.Errorf("search API HTTP error: %d %s (body: %s)", resp.StatusCode, resp.Status, previewBody(body)) } var result searchAPIResponse if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("decode search API response: %w", err) } if len(result.Notices) == 0 || strings.TrimSpace(result.Notices[0].OJSNumber) == "" { return "", fmt.Errorf("can not find ojs number for date %s", date) } return formatOJSPackageID(parsed.Year(), result.Notices[0].OJSNumber) } func formatOJSPackageID(fallbackYear int, ojsNumber string) (string, error) { parts := strings.Split(strings.TrimSpace(ojsNumber), "/") if len(parts) != 2 { return "", fmt.Errorf("unexpected ojs-number format %q", ojsNumber) } seq := strings.TrimSpace(parts[0]) yearStr := strings.TrimSpace(parts[1]) if seq == "" || yearStr == "" { return "", fmt.Errorf("unexpected ojs-number format %q", ojsNumber) } year := fallbackYear if parsedYear, err := time.Parse("2006", yearStr); err == nil { year = parsedYear.Year() } return fmt.Sprintf("%d%05s", year, seq), nil } func isCalendarUnavailable(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "calendar HTTP error: 202") || strings.Contains(msg, "ojs calendar is empty") } func previewBody(body []byte) string { const maxLen = 160 s := strings.TrimSpace(string(body)) s = strings.ReplaceAll(s, "\n", " ") s = strings.ReplaceAll(s, "\r", " ") if len(s) <= maxLen { return s } return s[:maxLen] + "..." }