Refactor document scraper to utilize active publication windows for tender retrieval
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Updated the ListPendingTenders method to filter tenders based on their active publication submission window instead of just deadlines, enhancing the accuracy of tender listings. - Introduced a new HasActivePublicationWindow method in the Tender entity to determine if the publication-based submission window is still open. - Modified the GetTenderByNoticeID method to reflect the new logic for filtering tenders based on their publication status. - Enhanced the documentation for relevant API endpoints to clarify the changes in tender retrieval criteria. This update improves the precision of tender scraping by ensuring only relevant tenders with active submission windows are processed, contributing to better data management and scraping efficiency.
This commit is contained in:
@@ -24,7 +24,7 @@ func NewHandler(service Service, logger logger.Logger) *Handler {
|
|||||||
|
|
||||||
// ListPendingTenders retrieves tenders that need document scraping
|
// ListPendingTenders retrieves tenders that need document scraping
|
||||||
// @Summary List pending tenders for document scraping
|
// @Summary List pending tenders for document scraping
|
||||||
// @Description Retrieve tenders that have not yet been scraped for documents and whose document URL matches a portal supported by the AI scraping service. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with active deadlines are returned; set include_expired=true to include expired tenders.
|
// @Description Retrieve tenders that have not yet been scraped for documents and whose document URL matches a portal supported by the AI scraping service. Each item includes contract_folder_id, notice_publication_id, document_url, title, and description. Optionally filter by created_at range (Unix timestamps in seconds). By default only tenders with an active publication submission window are returned (submission_deadline, or 48 working hours after publication_date); set include_expired=true to include expired tenders.
|
||||||
// @Tags Document-Scraper
|
// @Tags Document-Scraper
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
// @Param limit query int false "Number of items per page (default: 20, max: 100)"
|
||||||
@@ -61,7 +61,7 @@ func (h *Handler) ListPendingTenders(c echo.Context) error {
|
|||||||
|
|
||||||
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID
|
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID
|
||||||
// @Summary Get tender by notice ID for document scraping
|
// @Summary Get tender by notice ID for document scraping
|
||||||
// @Description Retrieve a specific unexpired tender whose document URL matches a supported scrape portal.
|
// @Description Retrieve a specific tender whose publication submission window is still open and whose document URL matches a supported scrape portal.
|
||||||
// @Tags Document-Scraper
|
// @Tags Document-Scraper
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param notice_id path string true "Notice Publication ID"
|
// @Param notice_id path string true "Notice Publication ID"
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ func (s *service) fetchScrapePortals(ctx context.Context) ([]string, error) {
|
|||||||
|
|
||||||
// ListPendingTenders retrieves tenders that have not yet been scraped for documents.
|
// ListPendingTenders retrieves tenders that have not yet been scraped for documents.
|
||||||
// Only returns tenders whose document URL matches a portal supported by the AI service.
|
// Only returns tenders whose document URL matches a portal supported by the AI service.
|
||||||
// By default only tenders with active deadlines are included; set IncludeExpired to include expired tenders.
|
// By default only tenders with an active publication submission window are included; set IncludeExpired to include expired tenders.
|
||||||
func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperListRequest) (*DocumentScraperListResponse, *response.Meta, error) {
|
func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperListRequest) (*DocumentScraperListResponse, *response.Meta, error) {
|
||||||
req.Normalize()
|
req.Normalize()
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
|||||||
}
|
}
|
||||||
if !req.IncludeExpired {
|
if !req.IncludeExpired {
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
filter.MinDeadline = &now
|
filter.MinWindowEnd = &now
|
||||||
}
|
}
|
||||||
|
|
||||||
tenders, hasMore, err := s.tenderRepo.GetPendingDocumentScrapeTenders(ctx, filter)
|
tenders, hasMore, err := s.tenderRepo.GetPendingDocumentScrapeTenders(ctx, filter)
|
||||||
@@ -117,6 +117,11 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
|||||||
return nil, nil, fmt.Errorf("failed to list pending tenders: %w", err)
|
return nil, nil, fmt.Errorf("failed to list pending tenders: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !req.IncludeExpired {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
tenders = filterActivePublicationWindow(tenders, now)
|
||||||
|
}
|
||||||
|
|
||||||
result := &DocumentScraperListResponse{
|
result := &DocumentScraperListResponse{
|
||||||
Tenders: make([]DocumentScraperTenderResponse, len(tenders)),
|
Tenders: make([]DocumentScraperTenderResponse, len(tenders)),
|
||||||
}
|
}
|
||||||
@@ -144,7 +149,7 @@ func (s *service) ListPendingTenders(ctx context.Context, req DocumentScraperLis
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID.
|
// GetTenderByNoticeID retrieves a specific tender by its notice publication ID.
|
||||||
// Only returns unexpired tenders whose document URL matches a supported scrape portal.
|
// Only returns tenders with an active publication submission window whose document URL matches a supported scrape portal.
|
||||||
func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*DocumentScraperTenderResponse, error) {
|
func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*DocumentScraperTenderResponse, error) {
|
||||||
s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{
|
s.logger.Info("Getting tender by notice ID for document scraper", map[string]interface{}{
|
||||||
"notice_id": noticeID,
|
"notice_id": noticeID,
|
||||||
@@ -169,10 +174,12 @@ func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*Do
|
|||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
if t.TenderDeadline <= now || !matchesScrapePortals(documentURL(t), portals) {
|
if !t.HasActivePublicationWindow(now) || !matchesScrapePortals(documentURL(t), portals) {
|
||||||
s.logger.Info("Tender filtered out: unsupported portal or deadline passed", map[string]interface{}{
|
s.logger.Info("Tender filtered out: unsupported portal or publication window closed", map[string]interface{}{
|
||||||
"notice_id": noticeID,
|
"notice_id": noticeID,
|
||||||
"tender_deadline": t.TenderDeadline,
|
"publication_date": t.PublicationDate,
|
||||||
|
"submission_deadline": t.SubmissionDeadline,
|
||||||
|
"publication_deadline": t.PublicationSubmissionDeadline(),
|
||||||
"now": now,
|
"now": now,
|
||||||
})
|
})
|
||||||
return nil, fmt.Errorf("tender not found")
|
return nil, fmt.Errorf("tender not found")
|
||||||
@@ -187,3 +194,16 @@ func (s *service) GetTenderByNoticeID(ctx context.Context, noticeID string) (*Do
|
|||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func filterActivePublicationWindow(tenders []tender.Tender, now int64) []tender.Tender {
|
||||||
|
if len(tenders) == 0 {
|
||||||
|
return tenders
|
||||||
|
}
|
||||||
|
active := make([]tender.Tender, 0, len(tenders))
|
||||||
|
for i := range tenders {
|
||||||
|
if tenders[i].HasActivePublicationWindow(now) {
|
||||||
|
active = append(active, tenders[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|||||||
@@ -280,6 +280,12 @@ func (t *Tender) PublicationSubmissionDeadline() int64 {
|
|||||||
return ted.CalculateSubmissionDeadline(time.Unix(t.PublicationDate, 0)).Unix()
|
return ted.CalculateSubmissionDeadline(time.Unix(t.PublicationDate, 0)).Unix()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasActivePublicationWindow reports whether the publication-based submission window is still open.
|
||||||
|
func (t *Tender) HasActivePublicationWindow(now int64) bool {
|
||||||
|
deadline := t.PublicationSubmissionDeadline()
|
||||||
|
return deadline > 0 && deadline > now
|
||||||
|
}
|
||||||
|
|
||||||
// IsRecommendable reports whether a tender should appear in customer AI recommendation results.
|
// IsRecommendable reports whether a tender should appear in customer AI recommendation results.
|
||||||
// Recommendations use the publication-based submission window, not tender_deadline.
|
// Recommendations use the publication-based submission window, not tender_deadline.
|
||||||
func (t *Tender) IsRecommendable(now int64) bool {
|
func (t *Tender) IsRecommendable(now int64) bool {
|
||||||
@@ -287,11 +293,7 @@ func (t *Tender) IsRecommendable(now int64) bool {
|
|||||||
case TenderStatusExpired, TenderStatusCancelled, TenderStatusAwarded, TenderStatusClosed:
|
case TenderStatusExpired, TenderStatusCancelled, TenderStatusAwarded, TenderStatusClosed:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
deadline := t.PublicationSubmissionDeadline()
|
return t.HasActivePublicationWindow(now)
|
||||||
if deadline <= 0 || deadline <= now {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTenderURL returns the TED tender URL
|
// GetTenderURL returns the TED tender URL
|
||||||
|
|||||||
@@ -88,6 +88,50 @@ func TestTenderPublicationSubmissionDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenderHasActivePublicationWindow(t *testing.T) {
|
||||||
|
now := int64(1_700_000_000)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
tender Tender
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "future submission deadline",
|
||||||
|
tender: Tender{SubmissionDeadline: now + 3600},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "past submission deadline",
|
||||||
|
tender: Tender{SubmissionDeadline: now - 3600},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "future tender deadline only",
|
||||||
|
tender: Tender{TenderDeadline: now + 86400},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "publication date only with open window",
|
||||||
|
tender: Tender{PublicationDate: now - 3600},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no publication or submission deadline",
|
||||||
|
tender: Tender{},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.tender.HasActivePublicationWindow(now); got != tt.want {
|
||||||
|
t.Fatalf("HasActivePublicationWindow() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenderIsRecommendable(t *testing.T) {
|
func TestTenderIsRecommendable(t *testing.T) {
|
||||||
now := int64(1_700_000_000)
|
now := int64(1_700_000_000)
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func documentScraperListProjection() bson.M {
|
|||||||
"tender_url": 1,
|
"tender_url": 1,
|
||||||
"title": 1,
|
"title": 1,
|
||||||
"description": 1,
|
"description": 1,
|
||||||
|
"publication_date": 1,
|
||||||
|
"submission_deadline": 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +658,7 @@ func (r *tenderRepository) GetExpiredTenders(ctx context.Context, beforeDate int
|
|||||||
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
|
// PendingDocumentScrapeFilter defines query criteria for pending document scrape tenders.
|
||||||
type PendingDocumentScrapeFilter struct {
|
type PendingDocumentScrapeFilter struct {
|
||||||
Portals []string
|
Portals []string
|
||||||
MinDeadline *int64 // when set, tender_deadline must be greater than this value
|
MinWindowEnd *int64 // when set, only tenders with an active publication submission window
|
||||||
CreatedAtFrom *int64
|
CreatedAtFrom *int64
|
||||||
CreatedAtTo *int64
|
CreatedAtTo *int64
|
||||||
Limit int
|
Limit int
|
||||||
@@ -682,25 +684,38 @@ func scrapePortalURLFilter(portals []string) bson.M {
|
|||||||
return bson.M{"$or": conditions}
|
return bson.M{"$or": conditions}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publicationWindowActiveBSON(now int64) bson.M {
|
||||||
|
return bson.M{
|
||||||
|
"$or": []bson.M{
|
||||||
|
{"submission_deadline": bson.M{"$gt": now}},
|
||||||
|
{
|
||||||
|
"$and": []bson.M{
|
||||||
|
{"$or": []bson.M{
|
||||||
|
{"submission_deadline": bson.M{"$exists": false}},
|
||||||
|
{"submission_deadline": 0},
|
||||||
|
}},
|
||||||
|
{"publication_date": bson.M{"$gt": 0}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetPendingDocumentScrapeTenders returns unscraped tenders whose document URL matches a supported portal.
|
// GetPendingDocumentScrapeTenders returns unscraped tenders whose document URL matches a supported portal.
|
||||||
// When MinDeadline is set, only tenders with tender_deadline greater than that value are returned.
|
// When MinWindowEnd is set, only tenders whose publication-based submission window is still open are returned.
|
||||||
func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, filter PendingDocumentScrapeFilter) ([]Tender, bool, error) {
|
func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context, filter PendingDocumentScrapeFilter) ([]Tender, bool, error) {
|
||||||
portalFilter := scrapePortalURLFilter(filter.Portals)
|
portalFilter := scrapePortalURLFilter(filter.Portals)
|
||||||
if portalFilter == nil {
|
if portalFilter == nil {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
query := bson.M{
|
andFilters := []bson.M{
|
||||||
"processing_metadata.documents_scraped": bson.M{"$ne": true},
|
{"processing_metadata.documents_scraped": bson.M{"$ne": true}},
|
||||||
}
|
portalFilter,
|
||||||
for key, value := range portalFilter {
|
|
||||||
query[key] = value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if filter.MinDeadline != nil {
|
if filter.MinWindowEnd != nil {
|
||||||
query["tender_deadline"] = bson.M{
|
andFilters = append(andFilters, publicationWindowActiveBSON(*filter.MinWindowEnd))
|
||||||
"$gt": *filter.MinDeadline,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if filter.CreatedAtFrom != nil || filter.CreatedAtTo != nil {
|
if filter.CreatedAtFrom != nil || filter.CreatedAtTo != nil {
|
||||||
@@ -711,9 +726,11 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context,
|
|||||||
if filter.CreatedAtTo != nil {
|
if filter.CreatedAtTo != nil {
|
||||||
createdFilter["$lte"] = *filter.CreatedAtTo
|
createdFilter["$lte"] = *filter.CreatedAtTo
|
||||||
}
|
}
|
||||||
query["created_at"] = createdFilter
|
andFilters = append(andFilters, bson.M{"created_at": createdFilter})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := bson.M{"$and": andFilters}
|
||||||
|
|
||||||
pagination := orm.Pagination{
|
pagination := orm.Pagination{
|
||||||
Limit: filter.Limit,
|
Limit: filter.Limit,
|
||||||
Skip: filter.Skip,
|
Skip: filter.Skip,
|
||||||
@@ -727,7 +744,7 @@ func (r *tenderRepository) GetPendingDocumentScrapeTenders(ctx context.Context,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
|
r.logger.Error("Failed to get pending document scrape tenders", map[string]interface{}{
|
||||||
"portals": filter.Portals,
|
"portals": filter.Portals,
|
||||||
"min_deadline": filter.MinDeadline,
|
"min_window_end": filter.MinWindowEnd,
|
||||||
"created_at_from": filter.CreatedAtFrom,
|
"created_at_from": filter.CreatedAtFrom,
|
||||||
"created_at_to": filter.CreatedAtTo,
|
"created_at_to": filter.CreatedAtTo,
|
||||||
"limit": filter.Limit,
|
"limit": filter.Limit,
|
||||||
|
|||||||
Reference in New Issue
Block a user