package ted import ( "archive/tar" "compress/gzip" "context" "fmt" "io" "net/http" "path/filepath" "strconv" "strings" "time" "tm/internal/tender" "tm/pkg/logger" "tm/pkg/mongo" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" mongoDriver "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" ) // TEDScraper handles downloading and parsing TED XML files type TEDScraper struct { config *Config logger logger.Logger httpClient *http.Client xmlParser *TEDParser mongoManager *mongo.ConnectionManager tenderRepo tender.TenderRepository } // ScrapingJob represents a scraping job (simplified version) type ScrapingJob struct { mongo.Model Type string `bson:"type" json:"type"` Status string `bson:"status" json:"status"` StartedAt int64 `bson:"started_at" json:"started_at"` CompletedAt *int64 `bson:"completed_at,omitempty" json:"completed_at,omitempty"` Progress JobProgress `bson:"progress" json:"progress"` Results JobResults `bson:"results" json:"results"` Config map[string]interface{} `bson:"config,omitempty" json:"config,omitempty"` } // JobProgress represents the progress of a scraping job type JobProgress struct { TotalFiles int `bson:"total_files" json:"total_files"` ProcessedFiles int `bson:"processed_files" json:"processed_files"` FailedFiles int `bson:"failed_files" json:"failed_files"` PercentComplete float64 `bson:"percent_complete" json:"percent_complete"` CurrentFile string `bson:"current_file,omitempty" json:"current_file,omitempty"` } // JobResults represents the results of a scraping job type JobResults struct { TotalTenders int `bson:"total_tenders" json:"total_tenders"` NewTenders int `bson:"new_tenders" json:"new_tenders"` UpdatedTenders int `bson:"updated_tenders" json:"updated_tenders"` ErrorCount int `bson:"error_count" json:"error_count"` SuccessRate float64 `bson:"success_rate" json:"success_rate"` ProcessingTime int64 `bson:"processing_time" json:"processing_time"` } // ScrapingState represents the current state of scraping type ScrapingState struct { mongo.Model LastProcessedYear int `bson:"last_processed_year" json:"last_processed_year"` LastProcessedNumber int `bson:"last_processed_number" json:"last_processed_number"` LastRunAt int64 `bson:"last_run_at" json:"last_run_at"` NextRunAt int64 `bson:"next_run_at" json:"next_run_at"` IsRunning bool `bson:"is_running" json:"is_running"` CurrentJobID primitive.ObjectID `bson:"current_job_id,omitempty" json:"current_job_id,omitempty"` } // ScraperConfig holds configuration for the TED scraper type Config struct { BaseURL string `mapstructure:"base_url"` Timeout time.Duration `mapstructure:"timeout"` MaxRetries int `mapstructure:"max_retries"` RetryDelay time.Duration `mapstructure:"retry_delay"` UserAgent string `mapstructure:"user_agent"` MaxConcurrency int `mapstructure:"max_concurrency"` DownloadDir string `mapstructure:"download_dir"` CleanupAfter time.Duration `mapstructure:"cleanup_after"` ScrapingInterval time.Duration `mapstructure:"scraping_interval"` } // NewTEDScraper creates a new TED scraper instance func NewTEDScraper( config *Config, logger logger.Logger, mongoManager *mongo.ConnectionManager, tenderRepo tender.TenderRepository, ) *TEDScraper { httpClient := &http.Client{ Timeout: config.Timeout, } return &TEDScraper{ config: config, logger: logger, httpClient: httpClient, xmlParser: NewTEDParser(), mongoManager: mongoManager, tenderRepo: tenderRepo, } } // RunPeriodicScraping runs the scraper at configured intervals func (s *TEDScraper) RunPeriodicScraping(ctx context.Context) error { ticker := time.NewTicker(s.config.ScrapingInterval) defer ticker.Stop() s.logger.Info("Starting periodic TED XML scraping", map[string]interface{}{ "interval": s.config.ScrapingInterval.String(), "base_url": s.config.BaseURL, }) // Run initial scraping if err := s.ScrapeLatest(ctx); err != nil { s.logger.Error("Initial scraping failed", map[string]interface{}{ "error": err.Error(), }) } for { select { case <-ctx.Done(): s.logger.Info("Periodic scraping stopped", map[string]interface{}{ "reason": ctx.Err().Error(), }) return ctx.Err() case <-ticker.C: if err := s.ScrapeLatest(ctx); err != nil { s.logger.Error("Periodic scraping failed", map[string]interface{}{ "error": err.Error(), }) } } } } // ScrapeLatest scrapes the latest TED XML files func (s *TEDScraper) ScrapeLatest(ctx context.Context) error { startTime := time.Now() s.logger.Info("Starting TED XML scraping", map[string]interface{}{ "timestamp": startTime.Unix(), }) // Get current state state, err := s.getScrapingState(ctx) if err != nil { s.logger.Error("Failed to get scraping state", map[string]interface{}{ "error": err.Error(), }) // Initialize with current date if no state exists state = &ScrapingState{ LastProcessedYear: time.Now().Year(), LastProcessedNumber: 0, LastRunAt: startTime.Unix(), NextRunAt: startTime.Add(s.config.ScrapingInterval).Unix(), IsRunning: false, } } // Handle case where state is nil (no document found) if state == nil { s.logger.Info("No scraping state found, initializing new state", map[string]interface{}{}) state = &ScrapingState{ LastProcessedYear: time.Now().Year(), LastProcessedNumber: 0, LastRunAt: startTime.Unix(), NextRunAt: startTime.Add(s.config.ScrapingInterval).Unix(), IsRunning: false, } } if state.IsRunning { s.logger.Info("Scraping already running, skipping", map[string]interface{}{ "current_job_id": state.CurrentJobID, }) return nil } // Create scraping job job := &ScrapingJob{ Type: "ted_daily", Status: "running", StartedAt: startTime.Unix(), Progress: JobProgress{}, Results: JobResults{}, Config: map[string]interface{}{ "base_url": s.config.BaseURL, "max_retries": s.config.MaxRetries, "scraper_type": "ted_daily", }, } // Save job if err := s.saveScrapingJob(ctx, job); err != nil { s.logger.Error("Failed to save scraping job", map[string]interface{}{ "error": err.Error(), }) } // Update state state.IsRunning = true state.CurrentJobID = job.ID state.UpdatedAt = time.Now().Unix() s.saveScrapingState(ctx, state) // Calculate files to download filesToDownload := s.calculateFilesToDownload(state) if len(filesToDownload) == 0 { s.logger.Info("No new files to download", map[string]interface{}{ "last_processed": fmt.Sprintf("%d%05d", state.LastProcessedYear, state.LastProcessedNumber), }) // Complete the job s.completeJob(ctx, job, state, nil) return nil } // Update job progress job.Progress.TotalFiles = len(filesToDownload) s.saveScrapingJob(ctx, job) var lastSuccessfulFile *FileInfo // Download and process files for i, fileInfo := range filesToDownload { job.Progress.CurrentFile = fmt.Sprintf("%d%05d", fileInfo.Year, fileInfo.Number) s.saveScrapingJob(ctx, job) fileResult, err := s.downloadAndProcess(ctx, fileInfo) if err != nil { s.logger.Error("Failed to download and process file", map[string]interface{}{ "year": fileInfo.Year, "number": fileInfo.Number, "error": err.Error(), }) job.Progress.FailedFiles++ job.Results.ErrorCount++ } else { job.Progress.ProcessedFiles++ job.Results.TotalTenders += fileResult.ProcessedCount job.Results.NewTenders += fileResult.SuccessCount if len(fileResult.Errors) > 0 { job.Results.ErrorCount += len(fileResult.Errors) } lastSuccessfulFile = &fileInfo } // Update progress percentage job.Progress.PercentComplete = float64(i+1) / float64(len(filesToDownload)) * 100 s.saveScrapingJob(ctx, job) // Check context for cancellation select { case <-ctx.Done(): s.completeJob(ctx, job, state, lastSuccessfulFile) return ctx.Err() default: // Continue processing } } // Complete the job s.completeJob(ctx, job, state, lastSuccessfulFile) s.logger.Info("TED XML scraping completed", map[string]interface{}{ "files_processed": job.Progress.ProcessedFiles, "tenders_created": job.Results.NewTenders, "error_count": job.Results.ErrorCount, "success_rate": job.Results.SuccessRate, }) return nil } // FileInfo represents information about a TED file to download type FileInfo struct { Year int Number int URL string OJS string } // calculateFilesToDownload determines which files need to be downloaded func (s *TEDScraper) calculateFilesToDownload(state *ScrapingState) []FileInfo { var files []FileInfo now := time.Now() currentYear := now.Year() currentMonth := now.Month() // Only process current year and month year := currentYear // Start from the last processed file if it's from current year, otherwise start from beginning of current month startNumber := 1 if state.LastProcessedYear == currentYear { startNumber = state.LastProcessedNumber + 1 } else { // Calculate approximate file number for start of current month // Assuming roughly 22 working days per month (weekdays only) startNumber = int(currentMonth-1)*22 + 1 } // Calculate end number for current month // Estimate based on current day of month and weekdays daysInCurrentMonth := now.Day() // Rough estimate: multiply by 0.7 to account for weekdays only maxNumber := int(currentMonth-1)*22 + int(float64(daysInCurrentMonth)*0.7) // Add some buffer for potential files maxNumber += 5 s.logger.Info("Calculating files to download for current month", map[string]interface{}{ "current_year": currentYear, "current_month": int(currentMonth), "start_number": startNumber, "max_number": maxNumber, "last_processed": fmt.Sprintf("%d%05d", state.LastProcessedYear, state.LastProcessedNumber), }) startNumber = 146 for number := startNumber; number <= maxNumber && len(files) < 10; number++ { // Limit to 10 files per run fileURL := fmt.Sprintf("%s/%d%05d", s.config.BaseURL, year, number) ojsID := fmt.Sprintf("S %03d / %d", number, year) files = append(files, FileInfo{ Year: year, Number: number, URL: fileURL, OJS: ojsID, }) } return files } // FileProcessingResult represents the result of processing a single file type FileProcessingResult struct { Year int `json:"year"` Number int `json:"number"` ProcessedCount int `json:"processed_count"` SuccessCount int `json:"success_count"` Errors []string `json:"errors"` ProcessedAt int64 `json:"processed_at"` } // downloadAndProcess downloads and processes a single TED file func (s *TEDScraper) downloadAndProcess(ctx context.Context, fileInfo FileInfo) (*FileProcessingResult, error) { url := fmt.Sprintf("%s/%d%05d", s.config.BaseURL, fileInfo.Year, fileInfo.Number) s.logger.Info("Downloading TED file", map[string]interface{}{ "url": url, "year": fileInfo.Year, "number": fileInfo.Number, }) var result *FileProcessingResult var err error // Retry mechanism for attempt := 1; attempt <= s.config.MaxRetries; attempt++ { result, err = s.downloadAndProcessWithRetry(ctx, url, fileInfo) if err == nil { break } if attempt < s.config.MaxRetries { s.logger.Warn("Download attempt failed, retrying", map[string]interface{}{ "attempt": attempt, "error": err.Error(), "url": url, }) select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(s.config.RetryDelay): // Continue to next attempt } } } if err != nil { s.logger.Error("Failed to download and process file after retries", map[string]interface{}{ "url": url, "attempts": s.config.MaxRetries, "error": err.Error(), }) return nil, fmt.Errorf("failed to download %s after %d attempts: %w", url, s.config.MaxRetries, err) } return result, nil } // downloadAndProcessWithRetry performs a single download and process attempt func (s *TEDScraper) downloadAndProcessWithRetry(ctx context.Context, url string, fileInfo FileInfo) (*FileProcessingResult, error) { // Create a longer timeout context for processing large files processCtx, cancel := context.WithTimeout(ctx, s.config.Timeout+time.Minute*5) defer cancel() // Create HTTP request req, err := http.NewRequestWithContext(processCtx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", s.config.UserAgent) s.logger.Info("Starting download", map[string]interface{}{ "url": url, "year": fileInfo.Year, "number": fileInfo.Number, }) // Make request resp, err := s.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to download file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP error: %d %s", resp.StatusCode, resp.Status) } s.logger.Info("Download completed, starting processing", map[string]interface{}{ "url": url, "content_length": resp.Header.Get("Content-Length"), }) // Process tar.gz file directly from response body return s.processTarGzFile(processCtx, resp.Body, fileInfo) } // processTarGzFile extracts and processes XML files from a tar.gz archive func (s *TEDScraper) processTarGzFile(ctx context.Context, reader io.Reader, fileInfo FileInfo) (*FileProcessingResult, error) { result := &FileProcessingResult{ Year: fileInfo.Year, Number: fileInfo.Number, ProcessedCount: 0, SuccessCount: 0, Errors: make([]string, 0), ProcessedAt: time.Now().Unix(), } // Create gzip reader gzipReader, err := gzip.NewReader(reader) if err != nil { return nil, fmt.Errorf("failed to create gzip reader: %w", err) } defer gzipReader.Close() // Create tar reader tarReader := tar.NewReader(gzipReader) // Process each file in the tar archive fileCount := 0 for { // Check context for cancellation before each tar entry select { case <-ctx.Done(): s.logger.Warn("Context cancelled during tar processing", map[string]interface{}{ "processed_files": fileCount, "error": ctx.Err().Error(), }) return result, ctx.Err() default: } header, err := tarReader.Next() if err == io.EOF { break // End of archive } if err != nil { s.logger.Warn("Error reading tar entry", map[string]interface{}{ "error": err.Error(), "processed_files": fileCount, }) // If it's a timeout error, return it immediately if ctx.Err() != nil { return result, fmt.Errorf("timeout during tar processing: %w", err) } continue } fileCount++ // Skip directories and non-XML files if header.Typeflag != tar.TypeReg { continue } fileName := filepath.Base(header.Name) if !strings.HasSuffix(strings.ToLower(fileName), ".xml") { continue } // Log progress every 10 files or for first file if result.ProcessedCount == 0 || result.ProcessedCount%10 == 0 { s.logger.Info("Processing XML file from tar.gz", map[string]interface{}{ "file_name": fileName, "size": header.Size, "processed_count": result.ProcessedCount, "total_files_seen": fileCount, "year": fileInfo.Year, "number": fileInfo.Number, }) } else { s.logger.Debug("Processing XML file from tar.gz", map[string]interface{}{ "file_name": fileName, "processed_count": result.ProcessedCount, }) } // Read file content content, err := io.ReadAll(tarReader) if err != nil { errMsg := fmt.Sprintf("failed to read XML file %s: %v", fileName, err) result.Errors = append(result.Errors, errMsg) continue } // Parse and process XML content if err := s.processXMLContent(ctx, content, fileName, fileInfo, result); err != nil { errMsg := fmt.Sprintf("failed to process XML file %s: %v", fileName, err) result.Errors = append(result.Errors, errMsg) } result.ProcessedCount++ // Check context for cancellation select { case <-ctx.Done(): return result, ctx.Err() default: // Continue processing } } s.logger.Info("Completed processing tar.gz file", map[string]interface{}{ "year": fileInfo.Year, "number": fileInfo.Number, "total_files": fileCount, "processed_count": result.ProcessedCount, "success_count": result.SuccessCount, "error_count": len(result.Errors), "processing_time": time.Now().Unix() - result.ProcessedAt, }) return result, nil } // processXMLContent parses XML content and creates/updates tender func (s *TEDScraper) processXMLContent(ctx context.Context, content []byte, fileName string, fileInfo FileInfo, result *FileProcessingResult) error { s.logger.Info("Starting XML processing", map[string]interface{}{ "file_name": fileName, "file_size": len(content), }) // Parse XML using the TED parser parsedDoc, err := s.xmlParser.ParseXML(content) if err != nil { s.logger.Error("XML parsing failed", map[string]interface{}{ "file_name": fileName, "error": err.Error(), }) return fmt.Errorf("failed to parse XML: %w", err) } // Get the underlying document for ID and validation document := parsedDoc.GetDocument() documentID := "" noticeTypeCode := "" if document != nil { documentID = document.GetID() noticeTypeCode = document.GetNoticeType() } s.logger.Info("XML parsed successfully", map[string]interface{}{ "file_name": fileName, "document_type": string(parsedDoc.Type), "document_id": documentID, "notice_type_code": noticeTypeCode, }) // Validate parsed data var validationErr error if document != nil { validationErr = document.Validate() } if validationErr != nil { s.logger.Warn("XML validation failed, continuing anyway", map[string]interface{}{ "file_name": fileName, "document_type": string(parsedDoc.Type), "error": validationErr.Error(), }) // Don't return error, continue processing } else { s.logger.Info("XML validation passed", map[string]interface{}{ "file_name": fileName, "document_type": string(parsedDoc.Type), }) } // Map to tender entity sourceURL := fmt.Sprintf("%s/%d%05d", s.config.BaseURL, fileInfo.Year, fileInfo.Number) tenderEntity := s.mapToTenderFromParsedDoc(parsedDoc, sourceURL, fileName) s.logger.Info("Mapped to tender entity", map[string]interface{}{ "file_name": fileName, "tender_id": tenderEntity.TenderID, "contract_notice_id": tenderEntity.ContractNoticeID, "title": tenderEntity.Title, }) // Try to find existing tender by contract notice ID s.logger.Info("Checking for existing tender", map[string]interface{}{ "contract_notice_id": tenderEntity.ContractNoticeID, }) existingTender, err := s.findTenderByContractNoticeID(ctx, tenderEntity.ContractNoticeID) if err != nil { s.logger.Warn("Error checking for existing tender", map[string]interface{}{ "contract_notice_id": tenderEntity.ContractNoticeID, "error": err.Error(), }) } if err == nil && existingTender != nil { // Update existing tender s.logger.Info("Found existing tender, updating", map[string]interface{}{ "existing_tender_id": existingTender.ID, "contract_notice_id": tenderEntity.ContractNoticeID, }) tenderEntity.ID = existingTender.ID tenderEntity.CreatedAt = existingTender.CreatedAt tenderEntity.UpdatedAt = time.Now().Unix() err = s.tenderRepo.Update(ctx, tenderEntity) if err != nil { s.logger.Error("Failed to update existing tender", map[string]interface{}{ "tender_id": tenderEntity.ID, "error": err.Error(), }) return fmt.Errorf("failed to update existing tender: %w", err) } s.logger.Info("Successfully updated tender", map[string]interface{}{ "tender_id": tenderEntity.ID, }) } else { // Create new tender s.logger.Info("Creating new tender", map[string]interface{}{ "contract_notice_id": tenderEntity.ContractNoticeID, "tender_id": tenderEntity.TenderID, }) tenderEntity.CreatedAt = time.Now().Unix() tenderEntity.UpdatedAt = tenderEntity.CreatedAt err = s.tenderRepo.Create(ctx, tenderEntity) if err != nil { s.logger.Error("Failed to create new tender", map[string]interface{}{ "contract_notice_id": tenderEntity.ContractNoticeID, "tender_id": tenderEntity.TenderID, "error": err.Error(), }) return fmt.Errorf("failed to create tender: %w", err) } s.logger.Info("Successfully created new tender", map[string]interface{}{ "tender_id": tenderEntity.TenderID, "contract_notice_id": tenderEntity.ContractNoticeID, }) } result.SuccessCount++ s.logger.Debug("Successfully processed XML file", map[string]interface{}{ "file_name": fileName, "document_type": string(parsedDoc.Type), "document_id": documentID, "tender_id": tenderEntity.TenderID, }) return nil } // Helper methods for database operations func (s *TEDScraper) getScrapingState(ctx context.Context) (*ScrapingState, error) { collection := s.mongoManager.GetCollection("scraping_state") var state ScrapingState err := collection.FindOne(ctx, bson.M{"_id": "ted_scraper"}).Decode(&state) if err != nil { if err == mongoDriver.ErrNoDocuments { return nil, nil } return nil, err } return &state, nil } func (s *TEDScraper) saveScrapingState(ctx context.Context, state *ScrapingState) error { collection := s.mongoManager.GetCollection("scraping_state") state.UpdatedAt = time.Now().Unix() opts := options.Replace().SetUpsert(true) _, err := collection.ReplaceOne(ctx, bson.M{"_id": state.ID}, state, opts) return err } func (s *TEDScraper) saveScrapingJob(ctx context.Context, job *ScrapingJob) error { collection := s.mongoManager.GetCollection("scraping_jobs") job.UpdatedAt = time.Now().Unix() opts := options.Replace().SetUpsert(true) _, err := collection.ReplaceOne(ctx, bson.M{"_id": job.ID}, job, opts) return err } func (s *TEDScraper) findTenderByContractNoticeID(ctx context.Context, contractNoticeID string) (*tender.Tender, error) { // Use repository's specific method for finding by contract notice ID existingTender, err := s.tenderRepo.GetByContractNoticeID(ctx, contractNoticeID) if err != nil { // If document not found, return nil without error if err.Error() == "document not found" { return nil, nil } return nil, err } return existingTender, nil } func (s *TEDScraper) completeJob(ctx context.Context, job *ScrapingJob, state *ScrapingState, lastFile *FileInfo) { // Update state with last successful file if lastFile != nil { state.LastProcessedYear = lastFile.Year state.LastProcessedNumber = lastFile.Number } // Complete the job endTime := time.Now() completedAt := endTime.Unix() job.Status = "completed" job.CompletedAt = &completedAt job.Results.ProcessingTime = completedAt - job.StartedAt if job.Results.TotalTenders > 0 { job.Results.SuccessRate = float64(job.Results.NewTenders) / float64(job.Results.TotalTenders) } s.saveScrapingJob(ctx, job) // Update state state.IsRunning = false state.CurrentJobID = primitive.NilObjectID state.LastRunAt = completedAt state.NextRunAt = endTime.Add(s.config.ScrapingInterval).Unix() state.UpdatedAt = completedAt s.saveScrapingState(ctx, state) } // mapToTenderFromParsedDoc maps a parsed TED document to tender entity func (s *TEDScraper) mapToTenderFromParsedDoc(parsedDoc *ParsedDocument, sourceFileURL, sourceFileName string) *tender.Tender { switch parsedDoc.Type { case DocumentTypeContractNotice: if parsedDoc.ContractNotice == nil { s.logger.Error("ContractNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapToTender(parsedDoc.ContractNotice, sourceFileURL, sourceFileName) case DocumentTypeContractAwardNotice: if parsedDoc.ContractAwardNotice == nil { s.logger.Error("ContractAwardNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapContractAwardNoticeToTender(parsedDoc.ContractAwardNotice, sourceFileURL, sourceFileName) case DocumentTypePriorInformationNotice: if parsedDoc.PriorInformationNotice == nil { s.logger.Error("PriorInformationNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.PriorInformationNotice, sourceFileURL, sourceFileName) case DocumentTypeDesignContest: if parsedDoc.DesignContest == nil { s.logger.Error("DesignContest is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.DesignContest, sourceFileURL, sourceFileName) case DocumentTypeQualificationSystemNotice: if parsedDoc.QualificationSystemNotice == nil { s.logger.Error("QualificationSystemNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.QualificationSystemNotice, sourceFileURL, sourceFileName) case DocumentTypeConcessionNotice: if parsedDoc.ConcessionNotice == nil { s.logger.Error("ConcessionNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.ConcessionNotice, sourceFileURL, sourceFileName) case DocumentTypePlanningNotice: if parsedDoc.PlanningNotice == nil { s.logger.Error("PlanningNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.PlanningNotice, sourceFileURL, sourceFileName) case DocumentTypeCompetitionNotice: if parsedDoc.CompetitionNotice == nil { s.logger.Error("CompetitionNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.CompetitionNotice, sourceFileURL, sourceFileName) case DocumentTypeResultNotice: if parsedDoc.ResultNotice == nil { s.logger.Error("ResultNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.ResultNotice, sourceFileURL, sourceFileName) case DocumentTypeChangeNotice: if parsedDoc.ChangeNotice == nil { s.logger.Error("ChangeNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.ChangeNotice, sourceFileURL, sourceFileName) case DocumentTypeSubcontractNotice: if parsedDoc.SubcontractNotice == nil { s.logger.Error("SubcontractNotice is nil", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } return s.mapGenericNoticeToTender(parsedDoc.SubcontractNotice, sourceFileURL, sourceFileName) // Legacy document types that should be parsed as ContractNotice but may still be in the old flow case DocumentTypeCorrigendum, DocumentTypeModificationNotice: if parsedDoc.ContractNotice != nil { return s.mapToTender(parsedDoc.ContractNotice, sourceFileURL, sourceFileName) } // Fallback: try to get the document from the generic interface document := parsedDoc.GetDocument() if document != nil { return s.mapGenericNoticeToTender(document, sourceFileURL, sourceFileName) } s.logger.Error("No valid document found for legacy type", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil default: s.logger.Error("Unknown document type for mapping", map[string]interface{}{ "document_type": string(parsedDoc.Type), }) return nil } } // mapGenericNoticeToTender maps any TED document type to tender entity using the common interface func (s *TEDScraper) mapGenericNoticeToTender(doc TEDDocument, sourceFileURL, sourceFileName string) *tender.Tender { if doc == nil { s.logger.Error("Document is nil in mapGenericNoticeToTender", map[string]interface{}{}) return nil } now := time.Now().Unix() t := &tender.Tender{ ContractNoticeID: doc.GetID(), ContractFolderID: "", // Not available in generic interface NoticeTypeCode: doc.GetNoticeType(), NoticeSubTypeCode: "", // Not available in generic interface NoticeLanguageCode: doc.GetLanguage(), IssueDate: now, // TODO: Parse actual date from document IssueTime: now, // TODO: Parse actual time from document GazetteID: "", // Not available in generic interface NoticePublicationID: "", // Not available in generic interface PublicationDate: now, // TODO: Parse actual publication date // Basic tender information Title: "Generic Notice " + doc.GetNoticeType(), Description: "Processed from " + doc.GetNoticeType() + " document", Status: tender.TenderStatusActive, Source: tender.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, ProcessingMetadata: tender.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", }, } // Generate the tender ID after creating the tender t.TenderID = s.generateTenderID(t) return t } // mapToTender maps a TED ContractNotice to tender entity func (s *TEDScraper) mapToTender(cn *ContractNotice, sourceFileURL, sourceFileName string) *tender.Tender { now := time.Now().Unix() t := &tender.Tender{ ContractNoticeID: cn.ID, ContractFolderID: cn.ContractFolderID, NoticeTypeCode: cn.GetNoticeSubType(), NoticeSubTypeCode: cn.GetNoticeSubType(), NoticeLanguageCode: cn.NoticeLanguageCode, IssueDate: s.parseDateToUnixMilli(cn.IssueDate), IssueTime: s.parseTimeToUnixMilli(cn.IssueTime), GazetteID: cn.GetOJSID(), Title: cn.GetProcurementTitle(), Description: cn.GetProcurementDescription(), ProcurementTypeCode: cn.GetContractNature(), ProcedureCode: cn.GetProcedureCode(), MainClassification: cn.GetMainClassification(), PlaceOfPerformance: cn.GetPlaceOfPerformance(), DocumentURI: cn.GetDocumentURI(), Status: tender.TenderStatusActive, Source: tender.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, ProcessingMetadata: tender.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", }, } // Set publication information if pubInfo := cn.GetPublicationInfo(); pubInfo != nil { t.NoticePublicationID = pubInfo.NoticePublicationID t.PublicationDate = s.parseDateToUnixMilli(pubInfo.PublicationDate) t.TenderURL = s.generateTenderURL(pubInfo.NoticePublicationID) } // Set additional classifications if cn.ProcurementProject != nil && cn.ProcurementProject.AdditionalCommodityClassification != nil { for _, additionalClass := range cn.ProcurementProject.AdditionalCommodityClassification { if additionalClass.ItemClassificationCode != "" { t.AdditionalClassifications = append(t.AdditionalClassifications, additionalClass.ItemClassificationCode) } } } // Set estimated value and currency if amount, currency := cn.GetBudget(); amount != "" { if value, err := strconv.ParseFloat(amount, 64); err == nil { t.EstimatedValue = value t.Currency = currency } } // Set duration information if duration, unit := cn.GetDuration(); duration != "" { t.Duration = duration t.DurationUnit = unit } // Set tender deadline if deadline := cn.GetTenderDeadline(); deadline != "" { t.TenderDeadline = s.parseDateToUnixMilli(deadline) if deadlineTime, err := s.parseDate(deadline); err == nil { applicationDeadline := s.calculateApplicationDeadline(deadlineTime) t.ApplicationDeadline = applicationDeadline.UnixMilli() } } if pubDate, err := s.parseDate(cn.GetPublicationDate()); err == nil { submissionDeadline := s.calculateSubmissionDeadline(pubDate) t.SubmissionDeadline = submissionDeadline.UnixMilli() } // Set SubmissionURL from TenderingTerms if cn.ProcurementProjectLot != nil && cn.ProcurementProjectLot.TenderingTerms != nil { if tenderingTerms := cn.ProcurementProjectLot.TenderingTerms; tenderingTerms.TenderRecipientParty != nil { if endpointID := tenderingTerms.TenderRecipientParty.EndpointID; endpointID != "" { if strings.HasPrefix(endpointID, "http") { t.SubmissionURL = endpointID } else { t.SubmissionURL = "https://" + endpointID } } } } // Set location information if cn.ProcurementProject != nil && cn.ProcurementProject.RealizedLocation != nil { if addr := cn.ProcurementProject.RealizedLocation.Address; addr != nil { t.CityName = addr.CityName t.PostalCode = addr.PostalZone t.RegionCode = addr.CountrySubentityCode if addr.Country != nil { t.CountryCode = addr.Country.IdentificationCode } } } // Set buyer organization buyerID := cn.GetBuyerID() if buyerID != "" { if buyerOrg := cn.GetOrganizationByID(buyerID); buyerOrg != nil { t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer") } } // Set review organization reviewID := cn.GetReviewOrganizationID() if reviewID != "" { if reviewOrg := cn.GetOrganizationByID(reviewID); reviewOrg != nil { t.ReviewOrganization = s.mapOrganization(reviewOrg, "review") } } // Set all organizations if orgs := cn.GetOrganizations(); orgs != nil { for _, org := range orgs.Organization { mappedOrg := s.mapOrganization(&org, "") if mappedOrg != nil { t.Organizations = append(t.Organizations, *mappedOrg) } } } // Set selection criteria if criteria := cn.GetSelectionCriteria(); criteria != nil { for _, criterion := range criteria { t.SelectionCriteria = append(t.SelectionCriteria, tender.SelectionCriterion{ TypeCode: criterion.CriterionTypeCode, Description: criterion.Description, LanguageID: criterion.LanguageID, }) } } // Set official languages if languages := cn.GetOfficialLanguages(); languages != nil { t.OfficialLanguages = languages } // Generate unique tender ID t.TenderID = s.generateTenderID(t) return t } // mapContractAwardNoticeToTender maps a TED ContractAwardNotice to tender entity func (s *TEDScraper) mapContractAwardNoticeToTender(can *ContractAwardNotice, sourceFileURL, sourceFileName string) *tender.Tender { now := time.Now().Unix() t := &tender.Tender{ ContractNoticeID: can.ID, ContractFolderID: can.ContractFolderID, NoticeTypeCode: can.NoticeTypeCode, NoticeSubTypeCode: can.GetNoticeSubType(), NoticeLanguageCode: can.NoticeLanguageCode, IssueDate: s.parseDateToUnixMilli(can.IssueDate), IssueTime: s.parseTimeToUnixMilli(can.IssueTime), GazetteID: can.GetOJSID(), Title: can.GetProcurementTitle(), Description: can.GetProcurementDescription(), ProcurementTypeCode: can.GetContractNature(), ProcedureCode: can.GetProcedureCode(), MainClassification: can.GetMainClassification(), Status: tender.TenderStatusAwarded, // Award notices are for completed tenders Source: tender.TenderSourceTEDScraper, SourceFileURL: sourceFileURL, SourceFileName: sourceFileName, ProcessingMetadata: tender.ProcessingMetadata{ ScrapedAt: now, ProcessedAt: now, ProcessingVersion: "1.0", }, } // Set publication information if pubInfo := can.GetPublicationInfo(); pubInfo != nil { t.NoticePublicationID = pubInfo.NoticePublicationID t.PublicationDate = s.parseDateToUnixMilli(pubInfo.PublicationDate) t.TenderURL = s.generateTenderURL(pubInfo.NoticePublicationID) } // Set total award value if amount, currency := can.GetTotalAwardValue(); amount != "" { if parsedAmount, err := strconv.ParseFloat(amount, 64); err == nil { t.EstimatedValue = parsedAmount } t.Currency = currency } // Set location information from main procurement project if can.ProcurementProject != nil && can.ProcurementProject.RealizedLocation != nil { if addr := can.ProcurementProject.RealizedLocation.Address; addr != nil { t.CityName = addr.CityName t.PostalCode = addr.PostalZone t.RegionCode = addr.CountrySubentityCode if addr.Country != nil { t.CountryCode = addr.Country.IdentificationCode } } } // Set buyer organization buyerID := can.GetBuyerID() if buyerID != "" { if buyerOrg := can.GetOrganizationByID(buyerID); buyerOrg != nil { t.BuyerOrganization = s.mapOrganization(buyerOrg, "buyer") } } // Set all organizations if orgs := can.GetOrganizations(); orgs != nil { for _, org := range orgs.Organization { mappedOrg := s.mapOrganization(&org, "") if mappedOrg != nil { t.Organizations = append(t.Organizations, *mappedOrg) } } } // Generate unique tender ID t.TenderID = s.generateTenderID(t) return t } // mapOrganization maps a TED Organization to tender Organization func (s *TEDScraper) mapOrganization(tedOrg *Organization, role string) *tender.Organization { if tedOrg == nil || tedOrg.Company == nil { return nil } company := tedOrg.Company org := &tender.Organization{ Role: role, } // Set organization ID and name if company.PartyIdentification != nil { org.ID = company.PartyIdentification.ID } if company.PartyName != nil { org.Name = company.PartyName.Name } // Set company ID if company.PartyLegalEntity != nil { org.CompanyID = company.PartyLegalEntity.CompanyID } // Set website org.WebsiteURI = company.WebsiteURI // Set contact information if company.Contact != nil { org.ContactName = company.Contact.Name org.ContactTelephone = company.Contact.Telephone org.ContactEmail = company.Contact.ElectronicMail org.ContactFax = company.Contact.Telefax } // Set address if company.PostalAddress != nil { org.Address = tender.Address{ StreetName: company.PostalAddress.StreetName, CityName: company.PostalAddress.CityName, PostalZone: company.PostalAddress.PostalZone, CountrySubentityCode: company.PostalAddress.CountrySubentityCode, Department: company.PostalAddress.Department, } if company.PostalAddress.Country != nil { org.Address.CountryCode = company.PostalAddress.Country.IdentificationCode } } return org } // generateTenderID generates a unique tender ID using the format: // {Source}{BUYER_CODE}{TED_CODE}{DATE}{LOCATION_CODE} func (s *TEDScraper) generateTenderID(t *tender.Tender) string { var parts []string // Source (TED) parts = append(parts, "1") // TED code (Notice Publication ID, first 8 chars) tedCode := "00000000" if t.NoticePublicationID != "" { tedCode = s.padOrTruncate(t.NoticePublicationID, 8) } parts = append(parts, tedCode) // Buyer code (first 8 chars of buyer organization ID, or 8 zeros if not available) // buyerCode := "00000000" // if t.BuyerOrganization != nil && t.BuyerOrganization.ID != "" { // buyerCode = s.padOrTruncate(t.BuyerOrganization.ID, 8) // } // parts = append(parts, buyerCode) // Date (YYYYMMDD format from issue date) parts = append(parts, s.padOrTruncate(s.unixMilliToDateString(t.IssueDate), 4)) // Location code (country code + region code, padded to 6 chars) locationCode := "EU" if t.CountryCode != "" { locationCode = t.CountryCode } parts = append(parts, locationCode) return strings.Join(parts, "") } // padOrTruncate pads a string with zeros or truncates it to the specified length func (s *TEDScraper) padOrTruncate(str string, length int) string { // Remove any non-alphanumeric characters and convert to uppercase clean := strings.ToUpper(strings.ReplaceAll(str, "-", "")) clean = strings.ReplaceAll(clean, " ", "") if len(clean) >= length { return clean[:length] } // Pad with zeros return clean + strings.Repeat("0", length-len(clean)) } // generateTenderURL generates the TED tender URL from NoticePublicationID func (s *TEDScraper) generateTenderURL(noticePublicationID string) string { if noticePublicationID == "" { return "" } // Remove leading zeros and format for URL // Example: 00522942-2025 -> https://ted.europa.eu/en/notice/-/detail/522942-2025 parts := strings.Split(noticePublicationID, "-") if len(parts) != 2 { return "" } // Convert to int and back to string to remove leading zeros if num, err := strconv.Atoi(parts[0]); err == nil { cleanID := fmt.Sprintf("%d-%s", num, parts[1]) return fmt.Sprintf("https://ted.europa.eu/en/notice/-/detail/%s", cleanID) } return "" } // parseDate parses various date formats used in TED XML func (s *TEDScraper) parseDate(dateStr string) (time.Time, error) { if dateStr == "" { return time.Time{}, fmt.Errorf("empty date string") } // Common TED date formats formats := []string{ "2006-01-02+07:00", "2006-01-02-07:00", "2006-01-02T15:04:05+07:00", "2006-01-02T15:04:05-07:00", "2006-01-02T15:04:05Z", "2006-01-02Z", "2006-01-02", time.RFC3339, time.RFC3339Nano, } for _, format := range formats { if parsedTime, err := time.Parse(format, dateStr); err == nil { return parsedTime, nil } } return time.Time{}, fmt.Errorf("unable to parse date: %s", dateStr) } // parseDateToUnixMilli converts string date to Unix milliseconds (int64) func (s *TEDScraper) parseDateToUnixMilli(dateStr string) int64 { if dateStr == "" { return 0 } if parsedTime, err := s.parseDate(dateStr); err == nil { return parsedTime.UnixMilli() } return 0 } // parseTimeToUnixMilli converts string time to Unix milliseconds (int64) func (s *TEDScraper) parseTimeToUnixMilli(timeStr string) int64 { if timeStr == "" { return 0 } // If time string is just time without date, use today's date if !strings.Contains(timeStr, "T") && !strings.Contains(timeStr, " ") { // Assume it's just a time like "15:04:05" today := time.Now().Format("2006-01-02") timeStr = fmt.Sprintf("%sT%s", today, timeStr) } if parsedTime, err := s.parseDate(timeStr); err == nil { return parsedTime.UnixMilli() } return 0 } // unixMilliToDateString converts Unix milliseconds to date string for TenderID generation func (s *TEDScraper) unixMilliToDateString(unixMilli int64) string { if unixMilli == 0 { return "00000000" } return time.UnixMilli(unixMilli).Format("20060102") } func (s *TEDScraper) calculateSubmissionDeadline(publicationDate time.Time) time.Time { deadline := publicationDate workingHoursAdded := 0 for workingHoursAdded < 48 { deadline = deadline.Add(1 * time.Hour) if deadline.Weekday() != time.Saturday && deadline.Weekday() != time.Sunday { workingHoursAdded++ } } return deadline } func (s *TEDScraper) calculateApplicationDeadline(tenderDeadline time.Time) time.Time { applicationDeadline := tenderDeadline workingDaysSubtracted := 0 for workingDaysSubtracted < 7 { applicationDeadline = applicationDeadline.AddDate(0, 0, -1) if applicationDeadline.Weekday() != time.Saturday && applicationDeadline.Weekday() != time.Sunday { workingDaysSubtracted++ } } return applicationDeadline } func (s *TEDScraper) adjustToWorkingDay(targetDate time.Time, forward bool) time.Time { current := targetDate for current.Weekday() == time.Saturday || current.Weekday() == time.Sunday { if forward { current = current.AddDate(0, 0, 1) } else { current = current.AddDate(0, 0, -1) } } return current }