Refactor MongoDB index creation to use NonEmptyStringPartialFilter for improved clarity and consistency. Update GetOJS function to utilize context and user agent for HTTP requests, enhancing error handling and response management.
This commit is contained in:
@@ -43,7 +43,7 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re
|
|||||||
*orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
|
*orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
|
||||||
// One row per TED ContractNoticeID when present (parallel scrape races).
|
// One row per TED ContractNoticeID when present (parallel scrape races).
|
||||||
*orm.CreateUniqueIndex("contract_notice_id_unique", bson.D{{Key: "contract_notice_id", Value: 1}}).
|
*orm.CreateUniqueIndex("contract_notice_id_unique", bson.D{{Key: "contract_notice_id", Value: 1}}).
|
||||||
WithPartialFilterExpression(bson.M{"contract_notice_id": bson.M{"$type": "string", "$ne": ""}}),
|
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("contract_notice_id")),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create indexes
|
// Create indexes
|
||||||
|
|||||||
@@ -94,16 +94,16 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Te
|
|||||||
*orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
|
*orm.NewIndex("contract_notice_id_idx", bson.D{{Key: "contract_notice_id", Value: 1}}),
|
||||||
// One tender row per TED ContractNoticeID when present (parallel ingest / lookup gaps).
|
// One tender row per TED ContractNoticeID when present (parallel ingest / lookup gaps).
|
||||||
*orm.CreateUniqueIndex("contract_notice_id_unique", bson.D{{Key: "contract_notice_id", Value: 1}}).
|
*orm.CreateUniqueIndex("contract_notice_id_unique", bson.D{{Key: "contract_notice_id", Value: 1}}).
|
||||||
WithPartialFilterExpression(bson.M{"contract_notice_id": bson.M{"$type": "string", "$ne": ""}}),
|
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("contract_notice_id")),
|
||||||
*orm.NewIndex("contract_folder_id_idx", bson.D{{Key: "contract_folder_id", Value: 1}}),
|
*orm.NewIndex("contract_folder_id_idx", bson.D{{Key: "contract_folder_id", Value: 1}}),
|
||||||
// One tender row per TED publication / procedure when ids are present (prevents parallel worker races).
|
// One tender row per TED publication / procedure when ids are present (prevents parallel worker races).
|
||||||
*orm.CreateUniqueIndex("notice_publication_id_unique", bson.D{{Key: "notice_publication_id", Value: 1}}).
|
*orm.CreateUniqueIndex("notice_publication_id_unique", bson.D{{Key: "notice_publication_id", Value: 1}}).
|
||||||
WithPartialFilterExpression(bson.M{"notice_publication_id": bson.M{"$type": "string", "$ne": ""}}),
|
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("notice_publication_id")),
|
||||||
*orm.CreateUniqueIndex("contract_folder_id_unique", bson.D{{Key: "contract_folder_id", Value: 1}}).
|
*orm.CreateUniqueIndex("contract_folder_id_unique", bson.D{{Key: "contract_folder_id", Value: 1}}).
|
||||||
WithPartialFilterExpression(bson.M{"contract_folder_id": bson.M{"$type": "string", "$ne": ""}}),
|
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("contract_folder_id")),
|
||||||
// One tender per UBL procurement project (merges per-lot notices that share ProcurementProject/ID).
|
// One tender per UBL procurement project (merges per-lot notices that share ProcurementProject/ID).
|
||||||
*orm.CreateUniqueIndex("procurement_project_id_unique", bson.D{{Key: "procurement_project_id", Value: 1}}).
|
*orm.CreateUniqueIndex("procurement_project_id_unique", bson.D{{Key: "procurement_project_id", Value: 1}}).
|
||||||
WithPartialFilterExpression(bson.M{"procurement_project_id": bson.M{"$type": "string", "$ne": ""}}),
|
WithPartialFilterExpression(orm.NonEmptyStringPartialFilter("procurement_project_id")),
|
||||||
*orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
|
*orm.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
|
||||||
*orm.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),
|
*orm.NewIndex("source_idx", bson.D{{Key: "source", Value: 1}}),
|
||||||
*orm.NewIndex("country_code_idx", bson.D{{Key: "country_code", Value: 1}}),
|
*orm.NewIndex("country_code_idx", bson.D{{Key: "country_code", Value: 1}}),
|
||||||
|
|||||||
+12
-1
@@ -17,7 +17,7 @@ func NewIndex(name string, keys bson.D) *Index {
|
|||||||
return &Index{
|
return &Index{
|
||||||
Name: name,
|
Name: name,
|
||||||
Keys: keys,
|
Keys: keys,
|
||||||
Options: options.Index(),
|
Options: options.Index().SetName(name),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +39,17 @@ func (i *Index) WithExpireAfterSeconds(seconds int32) *Index {
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NonEmptyStringPartialFilter returns a partial-index filter for non-empty string field values.
|
||||||
|
// MongoDB partial indexes do not support $ne; use $gt "" instead.
|
||||||
|
func NonEmptyStringPartialFilter(field string) bson.M {
|
||||||
|
return bson.M{
|
||||||
|
field: bson.M{
|
||||||
|
"$type": "string",
|
||||||
|
"$gt": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// WithPartialFilterExpression sets the partial filter expression for the index
|
// WithPartialFilterExpression sets the partial filter expression for the index
|
||||||
func (i *Index) WithPartialFilterExpression(filter bson.M) *Index {
|
func (i *Index) WithPartialFilterExpression(filter bson.M) *Index {
|
||||||
i.Options.SetPartialFilterExpression(filter)
|
i.Options.SetPartialFilterExpression(filter)
|
||||||
|
|||||||
+146
-21
@@ -2,58 +2,183 @@ package ted
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
calendarUrl = "%s/en/release-calendar/-/download/file/CSV/%d"
|
calendarURL = "%s/en/release-calendar/-/download/file/CSV/%d"
|
||||||
|
searchAPIURL = "https://api.ted.europa.eu/v3/notices/search"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Get OJS Number
|
type searchAPIRequest struct {
|
||||||
func GetOJS(baseURL string, year int, date string, client *http.Client) (string, error) {
|
Query string `json:"query"`
|
||||||
var (
|
Fields []string `json:"fields"`
|
||||||
ojs string
|
Limit int `json:"limit"`
|
||||||
)
|
}
|
||||||
url := fmt.Sprintf(calendarUrl, baseURL, year)
|
|
||||||
|
|
||||||
resp, err := client.Get(url)
|
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 {
|
if err != nil {
|
||||||
return ojs, err
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// read CSV
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ojs, err
|
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))
|
reader := csv.NewReader(bytes.NewReader(body))
|
||||||
records, err := reader.ReadAll()
|
records, err := reader.ReadAll()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ojs, err
|
return "", fmt.Errorf("parse calendar CSV: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(records) < 2 {
|
if len(records) < 2 {
|
||||||
return ojs, fmt.Errorf("ojs calendar is empty")
|
return "", fmt.Errorf("ojs calendar is empty (rows=%d, body=%s)", len(records), previewBody(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetDate := strings.TrimSpace(date)
|
||||||
for _, row := range records[1:] {
|
for _, row := range records[1:] {
|
||||||
|
if len(row) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
rowDate := strings.TrimSpace(row[1])
|
rowDate := strings.TrimSpace(row[1])
|
||||||
if rowDate == strings.TrimSpace(date) {
|
if rowDate == targetDate {
|
||||||
// Format: YEAR + zero-padded OJS number to make total length 9 digits
|
return fmt.Sprintf("%d%05s", year, strings.TrimSpace(row[0])), nil
|
||||||
// Examples: 202500004, 202500014, 202500196
|
|
||||||
ojs = fmt.Sprintf("%d%05s", year, strings.TrimSpace(fmt.Sprint(row[0])))
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ojs == "" {
|
|
||||||
return ojs, fmt.Errorf("can not find ojs number")
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ojs, nil
|
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] + "..."
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -525,7 +525,7 @@ func (s *TEDScraper) processSingleDate(ctx context.Context, date time.Time) erro
|
|||||||
})
|
})
|
||||||
|
|
||||||
// get ojs
|
// get ojs
|
||||||
ojs, err := GetOJS(s.config.BaseURL, date.Year(), date.Format("02/01/2006"), s.httpClient)
|
ojs, err := GetOJS(ctx, s.config.BaseURL, date.Year(), date.Format("02/01/2006"), s.config.UserAgent, s.httpClient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to get OJS", map[string]interface{}{
|
s.logger.Error("Failed to get OJS", map[string]interface{}{
|
||||||
"date": date.Format("02/01/2006"),
|
"date": date.Format("02/01/2006"),
|
||||||
|
|||||||
Reference in New Issue
Block a user