Files
tm_back/ted/scraper.go
T
n.nakhostin f67e179ffc Update .gitignore, adjust TED scraper configuration, and enhance Tender entity structure
- Added 'ted_samples/' to .gitignore to exclude TED sample files from version control.
- Updated the TED scraper configuration to extend the cleanup duration from 24 hours to 72 hours, allowing for better resource management.
- Enhanced the Tender entity by adding new fields: TenderDeadline, SubmissionDeadline, ApplicationDeadline, and SubmissionURL, improving the data model for tender management.
- Updated the TenderResponse structure to include the new fields, ensuring consistency in API responses.
- Implemented logic in the TED scraper to calculate submission and application deadlines based on tender deadlines, enhancing the scraping functionality.
2025-08-25 13:13:40 +03:30

1399 lines
43 KiB
Go

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
}