Files
tm_back/ted/scraper.go
T
Mazyar 7a9de273bb
continuous-integration/drone/push Build is passing
Enhance dashboard statistics with TED notice tracking
- Added a new field, ScrapedTEDNotices, to the StatisticsLifetimeTotals struct to track the total number of TED notices scraped.
- Updated the Statistics method in the statistics repository to include a background process for retrieving total scraped TED notices, improving the accuracy of dashboard statistics.
- Introduced new methods in the Counter to increment and retrieve daily counts for scraped TED notices, ensuring reliable metrics for reporting.
- Modified the TEDScraper to increment the TED notice scraped counter upon successful import, enhancing the tracking of scraping activity.

This update improves the dashboard's statistics by providing detailed insights into TED notice scraping activities, contributing to better data visibility and reporting.
2026-06-30 23:28:12 +03:30

571 lines
17 KiB
Go

package ted
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
"tm/internal/notice"
"tm/pkg/logger"
orm "tm/pkg/mongo"
"tm/pkg/notification"
)
// ScraperConfig holds configuration for the TED scraper
type Config struct {
BaseURL string `mapstructure:"base_url"`
UserAgent string `mapstructure:"user_agent"`
DownloadDir string `mapstructure:"download_dir"`
MaxRetries int `mapstructure:"max_retries"`
MaxConcurrency int `mapstructure:"max_concurrency"`
Timeout time.Duration `mapstructure:"timeout"`
RetryDelay time.Duration `mapstructure:"retry_delay"`
CleanupAfter time.Duration `mapstructure:"cleanup_after"`
ScrapingInterval string `mapstructure:"scraping_interval"`
AlertMail string `mapstructure:"ALERT_MAIL"`
MaxNoticesPerDay int `mapstructure:"max_notices_per_day"`
}
// TEDScraper handles downloading and parsing TED XML files
type TEDScraper struct {
notify notification.SDK
config *Config
logger logger.Logger
httpClient *http.Client
xmlParser *TEDParser
mongoManager *orm.ConnectionManager
noticeRepo notice.Repository
metricsCounter *orm.Counter
}
// NewTEDScraper creates a new TED scraper instance
func NewTEDScraper(
config *Config,
logger logger.Logger,
mongoManager *orm.ConnectionManager,
noticeRepo notice.Repository,
notify notification.SDK,
) *TEDScraper {
httpClient := &http.Client{
Timeout: config.Timeout,
}
return &TEDScraper{
notify: notify,
config: config,
logger: logger,
httpClient: httpClient,
xmlParser: NewTEDParser(),
mongoManager: mongoManager,
noticeRepo: noticeRepo,
metricsCounter: orm.NewCounter(mongoManager),
}
}
// downloadDailyPackage downloads a file from a URL
func (s *TEDScraper) DownloadDailyPackage(ctx context.Context, ojs string) (*FileProcessingResult, error) {
var (
result *FileProcessingResult
err error
)
url := fmt.Sprintf("%s/packages/daily/%s", s.config.BaseURL, ojs)
s.logger.Info("Downloading TED file", map[string]interface{}{
"url": url,
"ojs": ojs,
})
for attempt := 1; attempt <= s.config.MaxRetries; attempt++ {
result, err = s.downloadWithRetry(ctx, url, ojs)
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 result, ctx.Err()
case <-time.After(s.config.RetryDelay):
// Continue to next attempt
}
}
}
return result, err
}
// FileProcessingResult represents the result of processing a single file
type FileProcessingResult struct {
OJS string `json:"ojs"`
ProcessedCount int `json:"processed_count"`
SuccessCount int `json:"success_count"`
Errors []string `json:"errors"`
ProcessedAt int64 `json:"processed_at"`
}
func (s *TEDScraper) downloadWithRetry(ctx context.Context, url string, ojs string) (*FileProcessingResult, error) {
// Create a longer timeout context for processing large files
processCtx, cancel := context.WithTimeout(ctx, s.config.Timeout)
defer cancel()
// Create HTTP request
req, err := http.NewRequestWithContext(processCtx, "GET", url, nil)
if err != nil {
return &FileProcessingResult{}, 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,
"ojs": ojs,
})
// Make request
resp, err := s.httpClient.Do(req)
if err != nil {
return &FileProcessingResult{}, fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &FileProcessingResult{}, fmt.Errorf("HTTP error: %d %s", resp.StatusCode, resp.Status)
}
s.logger.Info("Download completed, starting processing", map[string]interface{}{
"url": url,
"ojs": ojs,
"content_length": resp.Header.Get("Content-Length"),
})
return s.tarGzFileProcessor(processCtx, resp.Body, ojs)
}
// tarGzFileProcessor extracts and processes XML files from a tar.gz archive
func (s *TEDScraper) tarGzFileProcessor(ctx context.Context, reader io.Reader, ojs string) (*FileProcessingResult, error) {
// Create gzip reader
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return &FileProcessingResult{}, 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
result := &FileProcessingResult{
OJS: ojs,
ProcessedCount: 0,
SuccessCount: 0,
Errors: make([]string, 0),
ProcessedAt: time.Now().Unix(),
}
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 &FileProcessingResult{}, 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 &FileProcessingResult{}, 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,
"ojs": ojs,
})
} else {
s.logger.Debug("Processing XML file from tar.gz", map[string]interface{}{
"file_name": fileName,
"processed_count": result.ProcessedCount,
"ojs": ojs,
})
}
// 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
}
// Skip processing if per-day limit reached (for UAT/low storage)
if s.config.MaxNoticesPerDay > 0 && result.SuccessCount >= s.config.MaxNoticesPerDay {
s.logger.Info("Reached max notices per day limit, stopping processing", map[string]interface{}{
"limit": s.config.MaxNoticesPerDay,
"ojs": ojs,
})
break
}
// Parse and process XML content
if err := s.processXMLContent(ctx, content, fileName, 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
}
}
if s.config.AlertMail != "" {
go s.notify.SendNotification(
context.Background(),
&notification.NotificationRequest{
EventType: notification.EventTypeEmail,
Title: "TED scraper completed",
Message: GenerateFileProcessingEmailTemplate(
&FileProcessingEmailTemplateData{
OJS: ojs,
ProcessedCount: result.ProcessedCount,
SuccessCount: result.SuccessCount,
Errors: result.Errors,
ProcessedAt: result.ProcessedAt,
}),
Priority: "important",
Methods: notification.NotificationMethods{
Email: s.config.AlertMail,
},
UserID: "ted-scraper",
Type: "alert",
})
}
return result, nil
}
func (s *TEDScraper) findNoticeByContractNoticeID(ctx context.Context, contractNoticeID string) (*notice.Notice, error) {
if strings.TrimSpace(contractNoticeID) == "" {
return nil, nil
}
existing, err := s.noticeRepo.GetByContractNoticeID(ctx, contractNoticeID)
if err != nil {
if errors.Is(err, orm.ErrDocumentNotFound) {
return nil, nil
}
return nil, err
}
return existing, nil
}
func isMongoDupKeyOnContractNoticeID(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "E11000") && strings.Contains(msg, "contract_notice_id")
}
func (s *TEDScraper) processXMLContent(ctx context.Context, content []byte, fileName string, result *FileProcessingResult) error {
s.logger.Debug("Processing XML content", map[string]interface{}{
"file_name": fileName,
"content_size": len(content),
"first_1000_chars": string(content[:min(1000, len(content))]),
})
// Parse XML using the flexible TED parser that detects notice type
notice, id, noticeType, err := s.xmlParser.ParseXML(content)
if err != nil {
s.logger.Error("XML parsing failed", map[string]interface{}{
"file_name": fileName,
"error": err.Error(),
"content_preview": string(content[:min(500, len(content))]),
})
return fmt.Errorf("failed to parse XML: %w", err)
}
// Map to tender entity
t := s.mapToTenderFromParsedDoc(notice, "", fileName)
if t == nil {
s.logger.Warn("Skipping document - mapper returned nil (unsupported or incomplete document)", map[string]interface{}{
"file_name": fileName,
"notice_id": id,
"notice_type": noticeType,
})
return nil
}
t.ContentXML = string(content)
existingNotice, err := s.findNoticeByContractNoticeID(ctx, t.ContractNoticeID)
if err != nil {
s.logger.Error("Failed to check existing notice", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"error": err.Error(),
})
return fmt.Errorf("failed to check existing notice: %w", err)
}
if existingNotice != nil {
if !noticeNeedsRefresh(existingNotice, t) {
s.logger.Debug("Notice unchanged, skipping", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"notice_doc_id": existingNotice.ID.Hex(),
})
return nil
}
MergeNoticeUpdate(existingNotice, t)
err = s.noticeRepo.Update(ctx, existingNotice)
if err != nil {
s.logger.Error("Failed to update existing notice", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"notice_doc_id": existingNotice.ID.Hex(),
"error": err.Error(),
})
return fmt.Errorf("failed to update notice: %w", err)
}
s.logger.Info("Notice updated (refresh)", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"notice_publication_id": t.NoticePublicationID,
"notice_version": t.NoticeVersion,
"notice_doc_id": existingNotice.ID.Hex(),
"processing_reset_worker": true,
})
result.SuccessCount++
} else {
s.logger.Info("Creating new notice", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
})
now := time.Now().Unix()
t.CreatedAt = now
t.UpdatedAt = now
err = s.noticeRepo.Import(ctx, t)
if err != nil {
if isMongoDupKeyOnContractNoticeID(err) {
existingAfterRace, findErr := s.findNoticeByContractNoticeID(ctx, t.ContractNoticeID)
if findErr != nil {
return fmt.Errorf("import duplicate key, re-fetch notice: %w", findErr)
}
if existingAfterRace == nil {
return fmt.Errorf("failed to import notice: %w", err)
}
MergeNoticeUpdate(existingAfterRace, t)
if upErr := s.noticeRepo.Update(ctx, existingAfterRace); upErr != nil {
s.logger.Error("Failed to update notice after duplicate-key race", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"error": upErr.Error(),
})
return fmt.Errorf("failed to update notice after duplicate key: %w", upErr)
}
s.logger.Info("Notice merged after duplicate-key race (parallel ingest)", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"notice_doc_id": existingAfterRace.ID.Hex(),
})
result.SuccessCount++
return nil
}
s.logger.Error("Failed to import notice", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"error": err.Error(),
})
return fmt.Errorf("failed to import notice: %w", err)
}
// Track scraping activity in a persistent counter (independent of the "notices"
// collection) since the worker deletes processed notices, which would otherwise make
// scrape history unrecoverable for dashboard reporting.
if s.metricsCounter != nil {
if incErr := s.metricsCounter.IncrementTEDNoticeScraped(ctx); incErr != nil {
s.logger.Warn("Failed to increment TED scraped notice counter", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
"error": incErr.Error(),
})
}
}
s.logger.Info("Successfully imported new notice", map[string]interface{}{
"contract_notice_id": t.ContractNoticeID,
})
result.SuccessCount++
}
// Log detailed parsed information based on notice type
s.logger.Info("Successfully parsed notice", map[string]interface{}{
"notice_id": id,
"notice_type": noticeType,
"file_name": fileName,
"tender_url": t.TenderURL,
})
return nil
}
func (s *TEDScraper) Run(ctx context.Context, from, to *time.Time) error {
// If no date range is provided, use current date
if from == nil && to == nil {
now := time.Now().Local()
s.logger.Info("Running periodic scraping for current date", map[string]interface{}{
"date": now.Format("02/01/2006"),
})
return s.processSingleDate(ctx, now)
}
// Validate date range
if from == nil || to == nil {
return fmt.Errorf("both from and to dates must be provided for date range scraping")
}
if from.After(*to) {
return fmt.Errorf("from date cannot be after to date")
}
s.logger.Info("Running date range scraping", map[string]interface{}{
"from_date": from.Format("02/01/2006"),
"to_date": to.Format("02/01/2006"),
})
// Process each date in the range
current := *from
totalDays := 0
successfulDays := 0
var errors []string
for !current.After(*to) {
totalDays++
s.logger.Info("Processing date", map[string]interface{}{
"date": current.Format("02/01/2006"),
"progress": fmt.Sprintf("%d/%d", totalDays, int(to.Sub(*from).Hours()/24)+1),
})
err := s.processSingleDate(ctx, current)
if err != nil {
errorMsg := fmt.Sprintf("failed to process date %s: %v", current.Format("02/01/2006"), err)
errors = append(errors, errorMsg)
s.logger.Error("Failed to process date", map[string]interface{}{
"date": current.Format("02/01/2006"),
"error": err.Error(),
})
} else {
successfulDays++
}
// Check for context cancellation
select {
case <-ctx.Done():
s.logger.Warn("Date range scraping cancelled", map[string]interface{}{
"processed_days": totalDays,
"successful_days": successfulDays,
"cancelled_at": current.Format("02/01/2006"),
})
return ctx.Err()
default:
}
// Move to next day
current = current.AddDate(0, 0, 1)
}
s.logger.Info("Date range scraping completed", map[string]interface{}{
"total_days": totalDays,
"successful_days": successfulDays,
"failed_days": len(errors),
"errors": errors,
})
// Return error if no days were processed successfully
if successfulDays == 0 && len(errors) > 0 {
return fmt.Errorf("failed to process any dates in range: %v", errors)
}
return nil
}
// processSingleDate processes data for a single date
func (s *TEDScraper) processSingleDate(ctx context.Context, date time.Time) error {
s.logger.Info("Processing single date", map[string]interface{}{
"date": date.Format("02/01/2006"),
})
// get ojs
ojs, err := GetOJS(ctx, s.config.BaseURL, date.Year(), date.Format("02/01/2006"), s.config.UserAgent, s.httpClient)
if err != nil {
s.logger.Error("Failed to get OJS", map[string]interface{}{
"date": date.Format("02/01/2006"),
"error": err.Error(),
})
return fmt.Errorf("failed to get OJS for date %s: %w", date.Format("02/01/2006"), err)
}
// download daily package
result, err := s.DownloadDailyPackage(ctx, ojs)
if err != nil {
s.logger.Error("Failed to download daily package", map[string]interface{}{
"date": date.Format("02/01/2006"),
"ojs": ojs,
"error": err.Error(),
})
return fmt.Errorf("failed to download daily package for date %s: %w", date.Format("02/01/2006"), err)
}
// process daily package
s.logger.Info("Processed daily package successfully", map[string]interface{}{
"result": result,
"date": date.Format("02/01/2006"),
"ojs": ojs,
})
return nil
}