185 lines
5.0 KiB
Go
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] + "..."
|
|
}
|