Files

185 lines
5.0 KiB
Go

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] + "..."
}