tendsign scraper

This commit is contained in:
Mazyar
2025-11-30 02:58:08 +03:30
parent d57bf95c70
commit b6546eb2bb
11 changed files with 597 additions and 4 deletions
+7
View File
@@ -1,6 +1,7 @@
package bootstrap package bootstrap
import ( import (
"time"
"tm/pkg/config" "tm/pkg/config"
) )
@@ -14,10 +15,16 @@ type Config struct {
Assets config.AssetsConfig Assets config.AssetsConfig
Notification config.NotificationConfig Notification config.NotificationConfig
Ollama config.OllamaConfig Ollama config.OllamaConfig
Scraper ScraperConfig
UserAuth AuthConfig UserAuth AuthConfig
CustomerAuth AuthConfig CustomerAuth AuthConfig
} }
type ScraperConfig struct {
BaseURL string `env:"SCRAPER_BASE_URL"`
Timeout time.Duration `env:"SCRAPER_TIMEOUT"`
}
type AuthConfig struct { type AuthConfig struct {
JWT JWTConfig JWT JWTConfig
} }
+6 -3
View File
@@ -103,6 +103,7 @@ import (
"tm/internal/feedback" "tm/internal/feedback"
"tm/internal/inquiry" "tm/internal/inquiry"
"tm/internal/notification" "tm/internal/notification"
"tm/internal/scraper"
"tm/internal/tender" "tm/internal/tender"
"tm/internal/tender_approval" "tm/internal/tender_approval"
"tm/internal/user" "tm/internal/user"
@@ -179,8 +180,9 @@ func main() {
notificationService := notification.NewService(notificationSDK, userService, customerService, logger) notificationService := notification.NewService(notificationSDK, userService, customerService, logger)
contactService := contact.NewService(contactRepository, logger) contactService := contact.NewService(contactRepository, logger)
cmsService := cms.NewService(cmsRepository, logger) cmsService := cms.NewService(cmsRepository, logger)
scraperService := scraper.NewService(conf.Scraper.BaseURL, conf.Scraper.Timeout, logger)
logger.Info("Services initialized successfully", map[string]interface{}{ logger.Info("Services initialized successfully", map[string]interface{}{
"services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms"}, "services": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper"},
}) })
// Initialize handlers with services // Initialize handlers with services
@@ -196,15 +198,16 @@ func main() {
notificationHandler := notification.NewHandler(notificationService) notificationHandler := notification.NewHandler(notificationService)
contactHandler := contact.NewHandler(contactService) contactHandler := contact.NewHandler(contactService)
cmsHandler := cms.NewHandler(cmsService) cmsHandler := cms.NewHandler(cmsService)
scraperHandler := scraper.NewHandler(scraperService)
logger.Info("Handlers initialized successfully", map[string]interface{}{ logger.Info("Handlers initialized successfully", map[string]interface{}{
"handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms"}, "handlers": []string{"customer", "user", "company", "category", "tender", "feedback", "tender_approval", "inquiry", "flag", "notification", "contact", "cms", "scraper"},
}) })
// Initialize HTTP server // Initialize HTTP server
e := bootstrap.InitHTTPServer(conf, logger) e := bootstrap.InitHTTPServer(conf, logger)
// Register routes // Register routes
router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler) router.RegisterAdminRoutes(e, userHandler, companyHandler, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler, scraperHandler)
router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler) router.RegisterPublicRoutes(e, customerHandler, tenderHandler, feedbackHandler, tenderApprovalHandler, companyHandler, inquiryHandler, categoryHandler, flagHandler, notificationHandler, contactHandler, cmsHandler)
// Start server // Start server
+9 -1
View File
@@ -10,6 +10,7 @@ import (
"tm/internal/feedback" "tm/internal/feedback"
"tm/internal/inquiry" "tm/internal/inquiry"
"tm/internal/notification" "tm/internal/notification"
"tm/internal/scraper"
"tm/internal/tender" "tm/internal/tender"
"tm/internal/tender_approval" "tm/internal/tender_approval"
"tm/internal/user" "tm/internal/user"
@@ -17,7 +18,7 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) { func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler *company.Handler, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler, scraperHandler *scraper.Handler) {
adminV1 := e.Group("/admin/v1") adminV1 := e.Group("/admin/v1")
// Admin Users Routes // Admin Users Routes
@@ -165,6 +166,13 @@ func RegisterAdminRoutes(e *echo.Echo, userHandler *user.Handler, companyHandler
cmsGP.PUT("/:id", cmsHandler.Update) cmsGP.PUT("/:id", cmsHandler.Update)
cmsGP.DELETE("/:id", cmsHandler.Delete) cmsGP.DELETE("/:id", cmsHandler.Delete)
} }
// Scraper Routes
scraperGP := adminV1.Group("/scraper")
{
scraperGP.Use(userHandler.AuthMiddleware())
scraperGP.POST("/download", scraperHandler.ScrapeDocuments)
}
} }
func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) { func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tenderHandler *tender.TenderHandler, feedbackHandler *feedback.Handler, tenderApprovalHandler *tender_approval.Handler, companyHandler *company.Handler, inquiryHandler *inquiry.Handler, categoryHandler *company_category.Handler, flagHandler *assets.Handler, notificationHandler *notification.NotificationHandler, contactHandler *contact.Handler, cmsHandler *cms.Handler) {
+34
View File
@@ -0,0 +1,34 @@
package scraper
// ScrapeRequestForm represents the request form for scraping documents
type ScrapeRequestForm struct {
NoticeID string `json:"notice_id" valid:"required,length(1|100)" example:"75896"`
Username string `json:"username" valid:"required,email" example:"feed@opplenz.com"`
Password string `json:"password" valid:"required,length(8|200)" example:"password123"`
LoginURL string `json:"login_url" valid:"required,url" example:"https://tendsign.com/login.aspx"`
NoticeURL string `json:"notice_url" valid:"required,url" example:"https://tendsign.com/supplier/s_meformsnotice.aspx?MeFormsNoticeId=75896"`
Category string `json:"category" valid:"optional,length(1|100)" example:"tenders"`
SubCategory string `json:"subcategory" valid:"optional,length(0|100)" example:"2024"`
}
// ScrapeResponse represents the response from scraping operation
type ScrapeResponse struct {
Success bool `json:"success"`
NoticeID string `json:"notice_id"`
UploadedCount int `json:"uploaded_count"`
UploadedFiles []UploadedFileInfo `json:"uploaded_files"`
Errors []FileError `json:"errors,omitempty"`
}
// UploadedFileInfo represents information about an uploaded file
type UploadedFileInfo struct {
Filename string `json:"filename"`
ObjectName string `json:"object_name"`
Size int64 `json:"size"`
}
// FileError represents an error for a specific file
type FileError struct {
Filename string `json:"filename"`
Error string `json:"error"`
}
+44
View File
@@ -0,0 +1,44 @@
package scraper
import (
"tm/pkg/response"
"github.com/labstack/echo/v4"
)
// Handler handles HTTP requests for scraper operations
type Handler struct {
service Service
}
// NewHandler creates a new scraper handler
func NewHandler(service Service) *Handler {
return &Handler{
service: service,
}
}
// ScrapeDocuments handles document scraping requests
// @Summary Scrape documents from external source
// @Description Scrapes documents from TendSign and uploads them to MinIO storage
// @Tags Admin-Scraper
// @Accept json
// @Produce json
// @Param request body ScrapeRequestForm true "Scraping request information"
// @Success 200 {object} response.Response{data=ScrapeResponse}
// @Failure 400 {object} response.Response
// @Failure 500 {object} response.Response
// @Router /admin/v1/scraper/download [post]
func (h *Handler) ScrapeDocuments(c echo.Context) error {
form, err := response.Parse[ScrapeRequestForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
result, err := h.service.ScrapeDocuments(c.Request().Context(), form)
if err != nil {
return response.InternalServerError(c, "Failed to scrape documents")
}
return response.Success(c, result, "Documents scraped and uploaded successfully")
}
+132
View File
@@ -0,0 +1,132 @@
package scraper
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"tm/pkg/logger"
)
// Service defines the interface for scraper business operations
type Service interface {
// ScrapeDocuments scrapes documents and uploads them to MinIO
ScrapeDocuments(ctx context.Context, form *ScrapeRequestForm) (*ScrapeResponse, error)
}
// scraperService implements Service interface
type scraperService struct {
httpClient *http.Client
baseURL string
logger logger.Logger
}
// NewService creates a new scraper service
func NewService(baseURL string, timeout time.Duration, logger logger.Logger) Service {
return &scraperService{
httpClient: &http.Client{
Timeout: timeout,
},
baseURL: baseURL,
logger: logger,
}
}
// ScrapeDocuments scrapes documents and uploads them to MinIO
func (s *scraperService) ScrapeDocuments(ctx context.Context, form *ScrapeRequestForm) (*ScrapeResponse, error) {
s.logger.Info("Starting document scraping", map[string]interface{}{
"notice_id": form.NoticeID,
"category": form.Category,
})
// Prepare request body
requestBody := map[string]interface{}{
"notice_id": form.NoticeID,
"username": form.Username,
"password": form.Password,
"login_url": form.LoginURL,
"notice_url": form.NoticeURL,
"category": form.Category,
"subcategory": form.SubCategory,
}
bodyBytes, err := json.Marshal(requestBody)
if err != nil {
s.logger.Error("Failed to marshal request body", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
// Create HTTP request
url := fmt.Sprintf("%s/download", s.baseURL)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes))
if err != nil {
s.logger.Error("Failed to create HTTP request", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Send request
resp, err := s.httpClient.Do(req)
if err != nil {
s.logger.Error("Failed to send request to scraper service", map[string]interface{}{
"error": err.Error(),
"url": url,
})
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
// Read response body
respBody, err := io.ReadAll(resp.Body)
if err != nil {
s.logger.Error("Failed to read response body", map[string]interface{}{
"error": err.Error(),
})
return nil, fmt.Errorf("failed to read response: %w", err)
}
// Handle error responses
if resp.StatusCode >= 400 {
var errorResp map[string]interface{}
if err := json.Unmarshal(respBody, &errorResp); err == nil {
if errorMsg, ok := errorResp["error"].(string); ok {
s.logger.Error("Scraper service returned error", map[string]interface{}{
"status_code": resp.StatusCode,
"error": errorMsg,
})
return nil, fmt.Errorf("scraper service error: %s", errorMsg)
}
}
s.logger.Error("Scraper service returned error", map[string]interface{}{
"status_code": resp.StatusCode,
"body": string(respBody),
})
return nil, fmt.Errorf("scraper service returned status %d", resp.StatusCode)
}
// Parse success response
var scrapeResp ScrapeResponse
if err := json.Unmarshal(respBody, &scrapeResp); err != nil {
s.logger.Error("Failed to unmarshal response", map[string]interface{}{
"error": err.Error(),
"body": string(respBody),
})
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
s.logger.Info("Document scraping completed", map[string]interface{}{
"notice_id": scrapeResp.NoticeID,
"uploaded_count": scrapeResp.UploadedCount,
"success": scrapeResp.Success,
})
return &scrapeResp, nil
}
+43
View File
@@ -0,0 +1,43 @@
FROM python:3.11-slim
RUN apt-get update && apt-get install -y \
wget \
gnupg \
ca-certificates \
fonts-liberation \
libasound2 \
libatk-bridge2.0-0 \
libatk1.0-0 \
libatspi2.0-0 \
libcups2 \
libdbus-1-3 \
libdrm2 \
libgbm1 \
libgtk-3-0 \
libnspr4 \
libnss3 \
libx11-6 \
libx11-xcb1 \
libxcb1 \
libxcomposite1 \
libxdamage1 \
libxext6 \
libxfixes3 \
libxkbcommon0 \
libxrandr2 \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install chromium
COPY Scraper.py .
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]
+121
View File
@@ -0,0 +1,121 @@
from playwright.sync_api import sync_playwright
import re
import os
import time
def clean_filename(filename):
"""Removes illegal characters from filenames."""
return re.sub(r'[<>:"/\\|?*]', '_', filename).strip()
def download_documents(username, password, login_url, notice_url, download_dir=None):
"""
Download documents from TendSign website.
Args:
username: Login username
password: Login password
login_url: URL to login page
notice_url: URL to notice entry page
download_dir: Directory to save downloads (optional, creates temp dir if not provided)
Returns:
List of downloaded file paths
"""
if download_dir is None:
download_dir = f"TendSign_Downloads_{int(time.time())}"
os.makedirs(download_dir, exist_ok=True)
print(f"[*] Downloads will be saved to: {download_dir}")
downloaded_files = []
with sync_playwright() as p:
print("[*] Starting browser...")
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
viewport={"width": 1920, "height": 1080},
locale="sv-SE",
)
context.add_init_script("Object.defineProperty(navigator, 'webdriver', {get: () => undefined});")
page = context.new_page()
try:
# === 1. LOGIN ===
print("[*] Logging in...")
page.goto(login_url, wait_until="networkidle")
page.wait_for_selector('input[name="UcomLogin$txt_UserName"]', timeout=30000)
page.fill('input[name="UcomLogin$txt_UserName"]', username)
page.fill('input[name="UcomLogin$txt_Password"]', password)
page.click('input[name="UcomLogin$btn_Submit"]')
page.wait_for_url("**/supplier/**", timeout=30000)
print("[SUCCESS] Logged in")
# === 2. NAVIGATE TO DOCUMENTS TAB ===
print(f"[*] Navigating to notice page...")
page.goto(notice_url, wait_until="networkidle", timeout=60000)
print("[*] Switching to 'Dokument' tab...")
with page.expect_navigation(wait_until="networkidle", timeout=15000):
page.click('a[href*="s_view_RFQfiles.aspx"]')
# === 3. LOCATE ALL FILE LINKS ===
print("[*] Looking for file links starting with 'Bilaga'...")
FILE_LINK_SELECTOR = "//a[starts-with(text(), 'Bilaga ')]"
try:
page.wait_for_selector(FILE_LINK_SELECTOR, timeout=10000)
except Exception:
raise Exception("Failed to locate any file links starting with 'Bilaga'.")
file_elements = page.locator(FILE_LINK_SELECTOR).all()
print(f"[*] Found {len(file_elements)} file links to download.")
if not file_elements:
print("[!] No file links found. Exiting.")
return downloaded_files
# === 4. DOWNLOAD LOOP ===
for i, element in enumerate(file_elements):
file_text = element.inner_text().strip()
print(f"[{i+1}/{len(file_elements)}] Attempting download: {file_text}...")
try:
with page.expect_download(timeout=60000) as download_info:
element.click()
download = download_info.value
suggested_name = download.suggested_filename
clean_name = clean_filename(suggested_name)
save_path = os.path.join(download_dir, clean_name)
download.save_as(save_path)
downloaded_files.append(save_path)
print(f" -> Successfully saved: {clean_name}")
except Exception as e:
print(f" -> [ERROR] Download failed for {file_text}: {e}")
time.sleep(1)
print("\n[DONE] All downloads processed.")
return downloaded_files
except Exception as e:
print(f"[CRITICAL ERROR] {e}")
page.screenshot(path=os.path.join(download_dir, "error_debug.png"))
raise
finally:
browser.close()
if __name__ == "__main__":
# Default values for backward compatibility
USERNAME = "feed@opplenz.com"
PASSWORD = "f38Dgma!rLn2VR3DZaBn"
LOGIN_URL = "https://tendsign.com/login.aspx"
NOTICE_ENTRY_URL = "https://tendsign.com/supplier/s_meformsnotice.aspx?MeFormsNoticeId=75896"
download_documents(USERNAME, PASSWORD, LOGIN_URL, NOTICE_ENTRY_URL)
+151
View File
@@ -0,0 +1,151 @@
from flask import Flask, request, jsonify
from Scraper import download_documents
import os
from minio import Minio
from minio.error import S3Error
import tempfile
import shutil
app = Flask(__name__)
# MinIO configuration from environment variables
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT', 'localhost:9000')
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY', 'minioadmin')
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY', 'minioadmin')
MINIO_USE_SSL = os.getenv('MINIO_USE_SSL', 'false').lower() == 'true'
MINIO_BUCKET = os.getenv('MINIO_BUCKET', 'documents')
def get_minio_client():
"""Create and return MinIO client"""
return Minio(
MINIO_ENDPOINT,
access_key=MINIO_ACCESS_KEY,
secret_key=MINIO_SECRET_KEY,
secure=MINIO_USE_SSL
)
def upload_to_minio(file_path, object_name, bucket=MINIO_BUCKET):
"""Upload a file to MinIO"""
try:
client = get_minio_client()
# Ensure bucket exists
if not client.bucket_exists(bucket):
client.make_bucket(bucket)
# Upload file
client.fput_object(bucket, object_name, file_path)
return True, None
except S3Error as e:
return False, str(e)
except Exception as e:
return False, str(e)
@app.route('/health', methods=['GET'])
def health():
"""Health check endpoint"""
return jsonify({"status": "healthy"}), 200
@app.route('/download', methods=['POST'])
def download():
"""Download documents and upload to MinIO"""
try:
data = request.get_json()
# Validate required fields
if not data:
return jsonify({"error": "Request body is required"}), 400
notice_id = data.get('notice_id')
username = data.get('username')
password = data.get('password')
login_url = data.get('login_url')
notice_url = data.get('notice_url')
category = data.get('category', 'tenders')
subcategory = data.get('subcategory', '')
if not all([notice_id, username, password, login_url, notice_url]):
return jsonify({
"error": "Missing required fields: notice_id, username, password, login_url, notice_url"
}), 400
# Create temporary directory for downloads
temp_dir = tempfile.mkdtemp(prefix=f"scraper_{notice_id}_")
try:
# Download documents using the scraper
downloaded_files = download_documents(
username=username,
password=password,
login_url=login_url,
notice_url=notice_url,
download_dir=temp_dir
)
if not downloaded_files:
return jsonify({
"error": "No files were downloaded",
"notice_id": notice_id
}), 404
# Upload each file to MinIO
uploaded_files = []
errors = []
for file_path in downloaded_files:
try:
filename = os.path.basename(file_path)
# Create object name with hierarchical path
if subcategory:
object_name = f"{category}/{subcategory}/{notice_id}/{filename}"
else:
object_name = f"{category}/{notice_id}/{filename}"
success, error = upload_to_minio(file_path, object_name)
if success:
uploaded_files.append({
"filename": filename,
"object_name": object_name,
"size": os.path.getsize(file_path)
})
else:
errors.append({
"filename": filename,
"error": error
})
except Exception as e:
errors.append({
"filename": os.path.basename(file_path),
"error": str(e)
})
# Clean up temporary directory
shutil.rmtree(temp_dir, ignore_errors=True)
return jsonify({
"success": True,
"notice_id": notice_id,
"uploaded_count": len(uploaded_files),
"uploaded_files": uploaded_files,
"errors": errors if errors else None
}), 200
except Exception as e:
# Clean up on error
shutil.rmtree(temp_dir, ignore_errors=True)
return jsonify({
"error": "Failed to download documents",
"message": str(e),
"notice_id": notice_id
}), 500
except Exception as e:
return jsonify({
"error": "Internal server error",
"message": str(e)
}), 500
if __name__ == '__main__':
port = int(os.getenv('PORT', 8000))
app.run(host='0.0.0.0', port=port, debug=False)
+45
View File
@@ -0,0 +1,45 @@
version: '3.8'
services:
scraper:
build:
context: .
dockerfile: pkg/scraper/Dockerfile
container_name: tm-scraper
ports:
- "8000:8000"
environment:
- PORT=8000
- MINIO_ENDPOINT=minio:9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin
- MINIO_USE_SSL=false
- MINIO_BUCKET=documents
networks:
- tm-network
depends_on:
- minio
restart: unless-stopped
minio:
image: minio/minio:latest
container_name: tm-minio
ports:
- "9000:9000"
- "9001:9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin
command: server /data --console-address ":9001"
volumes:
- minio-data:/data
networks:
- tm-network
restart: unless-stopped
networks:
tm-network:
driver: bridge
volumes:
minio-data:
+5
View File
@@ -0,0 +1,5 @@
playwright==1.40.0
flask==3.0.0
minio==7.2.0
requests==2.31.0
python-dotenv==1.0.0