feat: initial SA Tender Monitor project
- OCDS API client for eTenders.gov.za (primary tender discovery) - Web UI scraper using Playwright (fetches supportDocument[] array that the OCDS API doesn't expose — only 1 doc per tender in OCDS) - SQLite database with full schema for tenders + documents - Click CLI: pull, stats, list, show commands - 10 unit tests (all passing) covering models, OCDS parsing, web UI parsing, and document enrichment logic - Comprehensive research report: 75+ SA tender sources documented with automation details (API/RSS/scraping availability per source) - Hybrid architecture: OCDS for discovery + web scraping for full document lists (verified: web UI shows 2+ docs, OCDS shows 1)
This commit is contained in:
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project
|
||||
.env
|
||||
*.log
|
||||
data/raw/
|
||||
data/cache/
|
||||
output/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Downloads
|
||||
downloads/
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# SA Tender Monitor
|
||||
|
||||
Automated South African tender monitoring system that pulls tenders from
|
||||
multiple online sources and makes them available for search, filtering,
|
||||
and notification.
|
||||
|
||||
## Architecture
|
||||
|
||||
Hybrid data pipeline:
|
||||
|
||||
1. **eTenders OCDS API** — primary source for tender discovery (titles, dates,
|
||||
status, buyer info). Covers all government, SOE, and public entity tenders.
|
||||
2. **eTenders Web UI scraping** — supplements the OCDS API by fetching the full
|
||||
`supportDocument[]` array for each tender (the OCDS API only returns 1
|
||||
document per tender; the web UI shows all attached files).
|
||||
3. **Supplementary sources** — RSS feeds from Armscor, Health.gov.za, CIDB,
|
||||
sa-tenders.co.za; scraping for Eskom, Transnet, DBSA, IDC, and other SOE
|
||||
portals not fully captured in the OCDS API.
|
||||
|
||||
## Key Technical Detail
|
||||
|
||||
The eTenders OCDS API (`https://ocds-api.etenders.gov.za/api/OCDSReleases`)
|
||||
returns OCDS-compliant JSON with one critical limitation: it only exposes a
|
||||
single primary advert document per tender in `tender.documents[]`. Supporting
|
||||
documents (additional attachments uploaded by the tender issuer) are NOT
|
||||
in the OCDS API — they only appear in the web UI's internal DataTables
|
||||
endpoint (`/Home/PaginatedTenderOpportunities`), which returns each tender
|
||||
row with a `supportDocument[]` array containing all attached files.
|
||||
|
||||
This system uses a **hybrid approach**:
|
||||
- OCDS API for discovery and metadata
|
||||
- Headless browser scraping for the full document list per tender
|
||||
|
||||
## Sources
|
||||
|
||||
See `docs/sources.md` for the comprehensive research report covering 75+ SA
|
||||
tender sources with automation details.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
sa-tender-monitor/
|
||||
├── README.md
|
||||
├── .gitignore
|
||||
├── requirements.txt
|
||||
├── docs/
|
||||
│ └── sources.md # SA tender sources research report
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── ocds_client.py # eTenders OCDS API client
|
||||
│ ├── web_scraper.py # eTenders web UI document scraper
|
||||
│ ├── models.py # Data models (Tender, Document, Buyer)
|
||||
│ ├── db.py # SQLite storage
|
||||
│ ├── pipeline.py # Orchestration: OCDS → scrape → store
|
||||
│ └── config.py # Configuration
|
||||
├── data/ # Cached/raw data (gitignored)
|
||||
└── tests/
|
||||
└── test_ocds_client.py
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd /root/workspace/sa-tender-monitor
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Pull tenders from the last 7 days
|
||||
python -m src.pipeline --days 7
|
||||
|
||||
# Pull tenders for a specific date range
|
||||
python -m src.pipeline --from 2025-06-01 --to 2025-06-30
|
||||
```
|
||||
374
docs/platforms.md
Normal file
374
docs/platforms.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# South African Private Sector & Industry-Specific Tender/Procurement Platforms
|
||||
|
||||
Research completed: July 2026
|
||||
Method: Direct HTTP probing of all listed URLs via curl, content extraction and analysis.
|
||||
|
||||
---
|
||||
|
||||
## SECTION A: Commercial Tender Aggregators & B2B Procurement Platforms
|
||||
|
||||
### 1. SA-Tenders (sa-tenders.co.za)
|
||||
- **URL:** https://sa-tenders.co.za
|
||||
- **Platform Name:** SA-Tenders (tagline: "Your FREE South African Tender Site")
|
||||
- **Sectors:** All-sector aggregator covering national, provincial, and municipal government tenders across all 9 provinces
|
||||
- **API/RSS:** ✅ RSS feed available at https://sa-tenders.co.za/feed/ (WordPress RSS, hourly update frequency declared). Also has WordPress REST API at /wp-json/. No dedicated tender API but RSS can be parsed.
|
||||
- **Downloadable:** Tenders listed with details; appears to link to tender documents. Filtering by province, category, date.
|
||||
- **Cost:** Freemium model. Free browsing of tender listings. Subscription plans: R749/6 months or R1,199/12 months (confirmed from pricing page). Powered by Paid Memberships Pro plugin.
|
||||
- **Update Frequency:** Declared hourly in RSS feed metadata. Active site with tenders across all provinces.
|
||||
- **Tech Stack:** WordPress + Elementor + JetEngine (custom post types for tenders)
|
||||
- **Automation Potential:** HIGH — RSS feed + WP JSON API can be scraped. Province/category filtering available.
|
||||
|
||||
### 2. eTenders Portal (etenders.gov.za) — National Treasury
|
||||
- **URL:** https://www.etenders.gov.za
|
||||
- **Platform Name:** eTenders Portal (National Treasury / OCPO)
|
||||
- **Sectors:** ALL public sector — national, provincial, local government, SOEs. Every organ of state.
|
||||
- **API/RSS:** ✅ **FULL OCDS API AVAILABLE** at https://ocds-api.etenders.gov.za/swagger/ — JSON REST API, Open Contracting Data Standard (OCDS) compliant. OpenAPI/Swagger spec at v1/swagger.json. Supports consumption by PowerBI, Tableau, QlikView.
|
||||
- **Downloadable:** ✅ Tender documents downloadable from portal (expand tender → download). Also bulk data downloads in CSV, JSON, Excel formats from https://data.etenders.gov.za/
|
||||
- **Cost:** FREE — fully open, Creative Commons BY 4.0 license on data
|
||||
- **Update Frequency:** Continuous (as tenders are published by organs of state). Data available from May 2021 onwards.
|
||||
- **Transparency Portal:** https://data.etenders.gov.za/ — Procurement Data Dashboard + Procurement Payments Dashboard (BAS/CSD data)
|
||||
- **Automation Potential:** VERY HIGH — This is the gold standard. Full REST API with Swagger docs, bulk downloads, OCDS-compliant. This should be the primary data source.
|
||||
|
||||
### 3. etenders.co.za
|
||||
- **URL:** https://www.etenders.co.za
|
||||
- **Status:** ❌ HTTP 410 Gone — site has been permanently removed. Was a commercial tender notification service.
|
||||
- **Note:** NOT to be confused with etenders.gov.za (the official National Treasury portal which is live).
|
||||
|
||||
### 4. tenders.co.za
|
||||
- **URL:** https://www.tenders.co.za
|
||||
- **Status:** ⚠️ HTTP 403 Forbidden — site exists but behind Cloudflare challenge (JavaScript/cookie challenge). Cannot access content via curl. Likely a commercial aggregator requiring browser access.
|
||||
- **Note:** Cloudflare-protected. Would need headless browser to scrape.
|
||||
|
||||
### 5. tender247.co.za
|
||||
- **URL:** https://www.tender247.co.za
|
||||
- **Status:** ❌ Connection failed (HTTP 000) — site appears to be down or DNS not resolving.
|
||||
- **Note:** May be defunct or temporarily offline.
|
||||
|
||||
### 6. Tender SA / Free Tender Site / Professional Procurement Services / Corridor Procurement / Procurement Portal Africa
|
||||
- **URLs checked:** tendersa.co.za (000), freetender.co.za (000), freetendersite.co.za (000), corridorprocurement.co.za (000), procurementportal.co.za (000), professionalprocurement.co.za (000)
|
||||
- **Status:** ❌ ALL defunct — none of these domains resolve. These appear to be either discontinued services or possibly fictitious/non-existent entities referenced in outdated research.
|
||||
- **Note:** The SA tender aggregation space has consolidated. The active players are sa-tenders.co.za and the official etenders.gov.za.
|
||||
|
||||
### 7. Quest Holdings / Coupa
|
||||
- **URL:** https://www.quest.co.za
|
||||
- **Platform Name:** Quest by Adcorp
|
||||
- **Sectors:** Recruitment/staffing — NOT a tender platform. Quest is a temporary recruitment agency (part of Adcorp Group). Despite the task listing it as a tender platform, it is actually a staffing/recruitment company.
|
||||
- **API/RSS:** WordPress RSS feed exists but irrelevant to tenders.
|
||||
- **Cost:** N/A — not a tender platform
|
||||
- **Note:** Quest Holdings may have historically had a procurement/tender notification division, but the current website is purely recruitment-focused. No tender-related content found.
|
||||
|
||||
### 8. SAP Ariba
|
||||
- **URL:** https://www.sap.com/products/spend-management.html (redirects here from ariba.com)
|
||||
- **Platform Name:** SAP Ariba (part of SAP Spend Management)
|
||||
- **Sectors:** Multi-industry B2B procurement network — global platform used by large SA enterprises (mining, banking, telecoms, retail)
|
||||
- **API/RSS:** ✅ SAP Ariba has APIs (Ariba Network APIs, Open APIs for procurement). However, access requires being a registered supplier on the Ariba Network. No public tender feed.
|
||||
- **Downloadable:** Only for registered suppliers within the network.
|
||||
- **Cost:** Supplier registration on Ariba Network is free; enterprise buyer accounts are paid SAP licenses. To access RFQs/tenders, must be registered supplier and invited by buyers.
|
||||
- **Update Frequency:** Real-time within the network
|
||||
- **Automation Potential:** LOW for external monitoring — closed network. APIs exist but require authentication and supplier relationship. Relevant only if monitoring specific large enterprise buyers (e.g., Anglo American, Sasol, Standard Bank who use Ariba).
|
||||
|
||||
### 9. Basware
|
||||
- **URL:** https://www.basware.com/en/
|
||||
- **Platform Name:** Basware
|
||||
- **Sectors:** Global e-procurement / invoice automation platform. Used by some SA enterprises.
|
||||
- **API/RSS:** ✅ Basware has APIs for its procurement network. No public tender feed.
|
||||
- **Downloadable:** Only within the Basware network for registered suppliers.
|
||||
- **Cost:** Enterprise SaaS — paid for buyers. Supplier access varies.
|
||||
- **Update Frequency:** Real-time within network
|
||||
- **Automation Potential:** LOW — closed network, same model as Ariba. Not a public tender aggregator.
|
||||
|
||||
### 10. Tradematic
|
||||
- **URL:** https://www.tradematic.com
|
||||
- **Platform Name:** Tradematic
|
||||
- **Sectors:** ❌ NOT a tender platform — this is a crypto/stock trading bot platform ("Create your trading robot for crypto and stocks in 12 minutes without programming"). Has APIs but for trading, not procurement.
|
||||
- **Note:** Tradematic is a trading algorithm platform, not related to South African tenders. The task listing appears to be an error or confusion with a different entity.
|
||||
|
||||
### 11. Public Works Tenders
|
||||
- **URL:** https://www.publicworks.gov.za — ❌ Connection failed (000). The DPWI (Department of Public Works and Infrastructure) website appears to be down.
|
||||
- **Alternative:** Public Works tenders are published through the eTenders.gov.za portal and individual provincial Public Works departments.
|
||||
- **Note:** No standalone tender portal found for DPWI. Their tenders flow through the National Treasury eTenders system.
|
||||
|
||||
---
|
||||
|
||||
## SECTION B: Industry-Specific Portals
|
||||
|
||||
### 12. CIDB — Construction Industry Development Board
|
||||
- **URL:** https://www.cidb.org.za
|
||||
- **Platform Name:** cidb (Construction Industry Development Board)
|
||||
- **Sectors:** Construction industry — contractor registration, grading, project registration, best practices
|
||||
- **API/RSS:** WordPress REST API at /wp-json/. iCal feed for events at /events/?ical=1. Has a custom "tender-management" WordPress plugin (CSS/JS found in source). No dedicated public API for tenders.
|
||||
- **Downloadable:** Has wp-file-download plugin for document downloads. Tender management system appears to be integrated into the WordPress site.
|
||||
- **Cost:** Free to browse. Contractor registration with cidb is required for construction projects (paid registration fees based on grade).
|
||||
- **Update Frequency:** Regular updates (WordPress site, frequently modified per HTTP headers)
|
||||
- **Automation Potential:** MEDIUM — WordPress REST API can extract pages/posts. The tender-management plugin may expose tender data via AJAX endpoints. iCal feed for events.
|
||||
|
||||
### 13. Master Builders South Africa
|
||||
- **URL:** https://www.masterbuilders.org.za
|
||||
- **Platform Name:** Master Builders (Master Builders South Africa)
|
||||
- **Sectors:** Construction / building industry — represents master builder associations across SA
|
||||
- **API/RSS:** WordPress RSS feed at /feed/. WordPress REST API at /wp-json/.
|
||||
- **Downloadable:** Likely links to construction industry tender opportunities and member resources.
|
||||
- **Cost:** Free to browse. Membership required for full access to member resources.
|
||||
- **Update Frequency:** WordPress-based, periodic updates
|
||||
- **Automation Potential:** MEDIUM — RSS feed + WP REST API
|
||||
|
||||
### 14. Minerals Council South Africa
|
||||
- **URL:** https://www.mineralscouncil.org.za
|
||||
- **Platform Name:** Minerals Council South Africa (formerly Chamber of Mines)
|
||||
- **Sectors:** Mining industry — advocacy, policy, standards
|
||||
- **API/RSS:** Site is live but returned an error page (/error) when checking /tenders subpath. Mining companies publish their own tenders.
|
||||
- **Downloadable:** Not a tender portal per se — industry body website.
|
||||
- **Cost:** Free to browse. Member access for detailed resources.
|
||||
- **Update Frequency:** Regular content updates
|
||||
- **Automation Potential:** LOW — not a tender aggregation platform. Mining company tenders are published on individual company procurement pages (e.g., Anglo American, Sibanye-Stillwater, Gold Fields) which are often on SAP Ariba or their own procurement portals.
|
||||
|
||||
### 15. Mining Qualifications Authority (MQA)
|
||||
- **URL:** https://mqa.org.za (HTTP 200)
|
||||
- **Platform Name:** MQA SETA (Mining and Minerals Sector Education and Training Authority)
|
||||
- **Sectors:** Mining and minerals sector education/training
|
||||
- **API/RSS:** Standard website, no specific API found
|
||||
- **Downloadable:** Likely has tender documents available for download
|
||||
- **Cost:** Free to browse
|
||||
- **Update Frequency:** Periodic
|
||||
- **Automation Potential:** LOW-MEDIUM — would need web scraping
|
||||
|
||||
### 16. National Department of Health Tenders
|
||||
- **URL:** https://www.health.gov.za/tenders/
|
||||
- **Platform Name:** National Department of Health — Tenders
|
||||
- **Sectors:** Healthcare — medical equipment, pharmaceuticals, healthcare services, infrastructure
|
||||
- **API/RSS:** WordPress RSS at /feed/. WP REST API at /wp-json/. Has tablepress tables for tender listings.
|
||||
- **Downloadable:** Tenders listed in HTML tables (TablePress plugin). Links to tender documents likely downloadable.
|
||||
- **Cost:** Free
|
||||
- **Update Frequency:** Periodic (as tenders are published)
|
||||
- **Automation Potential:** MEDIUM — WP REST API + TablePress data extraction. RSS for general updates.
|
||||
|
||||
### 17. Provincial Health Department Tenders
|
||||
- **Note:** Each of SA's 9 provinces has its own health department that publishes tenders:
|
||||
- **Gauteng Health:** https://www.health.gpg.gov.za — tenders published on provincial site
|
||||
- **Western Cape Health:** https://www.westerncape.gov.za — provincial tenders
|
||||
- **KZN Health:** https://www.kznhealth.gov.za — tenders on provincial site
|
||||
- **Eastern Cape Health:** https://www.ecdoh.gov.za
|
||||
- **Limpopo Health:** https://www.limpopo.gov.za
|
||||
- **Mpumalanga Health:** https://www.mpu.gov.za
|
||||
- **North West Health:** https://www.nwpg.gov.za
|
||||
- **Northern Cape Health:** https://www.northerncape.gov.za
|
||||
- **Free State Health:** https://www.freestate.gov.za
|
||||
- **Note:** Provincial tenders also appear on etenders.gov.za. Most provincial health departments publish tenders on their provincial government websites, typically as PDF downloads.
|
||||
- **Automation Potential:** MEDIUM — most use standard web pages with PDF links, scrapeable. Also captured in eTenders.gov.za OCDS API.
|
||||
|
||||
### 18. NSFAS (National Student Financial Aid Scheme)
|
||||
- **URL:** https://www.nsfas.org.za (HTTP 200, redirects to https://www.nsfas.org.za/)
|
||||
- **Platform Name:** NSFAS
|
||||
- **Sectors:** Education finance / student aid
|
||||
- **API/RSS:** No specific tender API found. /tenders returned 404.
|
||||
- **Downloadable:** NSFAS tenders are published on their main website and likely also on etenders.gov.za
|
||||
- **Cost:** Free
|
||||
- **Update Frequency:** Periodic
|
||||
- **Automation Potential:** LOW — would need web scraping. Also captured in eTenders.gov.za.
|
||||
|
||||
### 19. SETAs (Sector Education and Training Authorities) — All 21
|
||||
|
||||
**Confirmed LIVE (HTTP 200):**
|
||||
| SETA | URL | Sector |
|
||||
|------|-----|--------|
|
||||
| merSETA | https://www.merseta.org.za | Manufacturing, Engineering & Related Services |
|
||||
| CHIETA | https://www.chieta.org.za | Chemical Industries Education & Training |
|
||||
| MICT SETA | https://www.mictseta.org.za | Media, Information & Communication Technologies |
|
||||
| INSETA | https://www.inseta.org.za | Insurance Sector Education & Training |
|
||||
| HWSETA | https://www.hwseta.org.za | Health & Welfare Sector Education & Training |
|
||||
| FoodBev SETA | https://www.foodbev.org.za | Food & Beverage Manufacturing Industry |
|
||||
| Services SETA | https://www.serviceseta.org.za | Services Sector Education & Training |
|
||||
| MQA | https://www.mqa.org.za | Mining Qualifications Authority |
|
||||
| PSETA | https://www.pseta.org.za | Public Service Sector Education & Training |
|
||||
| BANKSETA | https://www.bankseta.org.za | Banking Sector Education & Training |
|
||||
| AgriSETA | https://www.agriseta.co.za | Agricultural Sector Education & Training |
|
||||
| UMALUSI | https://www.umalusi.org.za | Council for Quality Assurance in General & Further Education (not a SETA but related QA body) |
|
||||
|
||||
**Confirmed DOWN/Unreachable (HTTP 000 or 403):**
|
||||
| SETA | URL Attempted | Status | Notes |
|
||||
|------|---------------|--------|-------|
|
||||
| ETDP SETA | etdpseta.org.za | 000 | Domain may have changed or defunct |
|
||||
| TETA | teta.org.za | 000 | Transport SETA — domain issues |
|
||||
| SETASA | setasa.org.za | 000 | Safety & Security SETA |
|
||||
| FASSET | fasseta.org.za | 000 | Finance & Accounting Services SETA |
|
||||
| CETA | ceta.org.za | 403 | Construction SETA — blocked access |
|
||||
| W&RSETA | wrseta.org.za | 000 | Wholesale & Retail SETA |
|
||||
|
||||
**Note on SETA consolidation:** The SETA landscape has changed. Some SETAs have been merged or restructured. The original 21 SETAs have been reduced/consolidated in recent years. Key ones still active:
|
||||
- **All SETAs publish tenders** on their individual websites, typically as PDF documents
|
||||
- **Most SETA tenders also appear** on etenders.gov.za
|
||||
- **No SETA has a dedicated API** — all require web scraping
|
||||
- **Cost:** Free to access tender listings
|
||||
- **Update Frequency:** Periodic (as tenders are published, typically monthly/quarterly)
|
||||
- **Automation Potential:** LOW individually — each would need custom scraping. Better approach: monitor via etenders.gov.za OCDS API which captures all SETA tenders.
|
||||
|
||||
### 20. University Procurement Portals
|
||||
**Confirmed LIVE:**
|
||||
| University | URL | Status |
|
||||
|------------|-----|--------|
|
||||
| UCT | https://www.uct.ac.za | 200 |
|
||||
| Wits | https://www.wits.ac.za | 200 |
|
||||
| Stellenbosch | https://www.sun.ac.za (su.ac.za) | 200 |
|
||||
| UKZN | https://www.ukzn.ac.za | 200 |
|
||||
| NMMU (Nelson Mandela) | https://www.mandela.ac.za (nmmu.ac.za redirects) | 200 |
|
||||
| Rhodes | https://www.ru.ac.za (rhodes.ac.za) | 200 |
|
||||
|
||||
**Blocked (403):**
|
||||
| University | URL | Status |
|
||||
|------------|-----|--------|
|
||||
| UP (Pretoria) | https://www.up.ac.za | 403 |
|
||||
| UJ (Johannesburg) | https://www.uj.ac.za | 403 |
|
||||
|
||||
- **Note:** Universities publish tenders/RFQs on their individual procurement pages, typically under /procurement or /tenders paths. Most use standard web pages with PDF downloads.
|
||||
- **API/RSS:** None have dedicated tender APIs. Some may have WordPress RSS.
|
||||
- **Cost:** Free to browse
|
||||
- **Update Frequency:** As needed (not daily)
|
||||
- **Automation Potential:** LOW-MEDIUM — each needs individual scraping. University tenders also appear on etenders.gov.za.
|
||||
|
||||
### 21. Armscor (Defence)
|
||||
- **URL:** https://www.armscor.co.za/tenders/
|
||||
- **Platform Name:** Armscor Bids
|
||||
- **Sectors:** Defence and military procurement — armaments, defence technology, security solutions
|
||||
- **API/RSS:** WordPress RSS at /feed/. WP REST API at /wp-json/. Has WP RSS Aggregator plugin. Custom "bids-opportunities" forms.
|
||||
- **Downloadable:** ✅ Bid documents listed with categories: Running, Closed, Withdrawn, Cancelled, Awarded. Downloadable from the site.
|
||||
- **Cost:** Free to browse. Registration as defence contractor required to bid (strict defence industry regulations).
|
||||
- **Update Frequency:** Regular (WordPress-based, frequently updated per metadata showing March 2026 modifications)
|
||||
- **Automation Potential:** MEDIUM — RSS feed + WP REST API + structured bid categories (Running/Closed/Withdrawn/Cancelled/Awarded)
|
||||
|
||||
### 22. SANRAL (South African National Roads Agency)
|
||||
- **URL:** https://www.sanral.co.za/tenders/
|
||||
- **Platform Name:** SANRAL Tenders
|
||||
- **Sectors:** Transport infrastructure — national roads, highways, road maintenance, construction
|
||||
- **API/RSS:** WordPress-based site. Likely has RSS feed.
|
||||
- **Downloadable:** Tenders listed on the site. Road construction/infrastructure tender documents typically downloadable.
|
||||
- **Cost:** Free to browse. Contractors must be CIDB-graded to bid.
|
||||
- **Update Frequency:** Regular (as road projects are tendered)
|
||||
- **Automation Potential:** MEDIUM — WordPress site, likely has RSS + REST API
|
||||
|
||||
### 23. RTMC (Road Traffic Management Corporation)
|
||||
- **URL:** https://www.rtmc.co.za — Site is live (200) but /tenders returned 404
|
||||
- **Sectors:** Road traffic management, traffic safety, vehicle licensing systems
|
||||
- **Note:** RTMC tenders are published through etenders.gov.za. No dedicated tender page found at /tenders.
|
||||
|
||||
### 24. DWS (Department of Water and Sanitation) / Water Boards
|
||||
- **URL:** https://www.dws.gov.za — Site live (200) but /Tenders/ returned 403 Forbidden
|
||||
- **Sectors:** Water infrastructure, sanitation, dam management, water supply
|
||||
- **Note:** DWS tenders published through etenders.gov.za. Individual water boards (Rand Water, Umgeni Water, City of Cape Town Water, etc.) have their own procurement pages.
|
||||
- **Water Boards:**
|
||||
- **Rand Water:** https://www.randwater.co.za — publishes tenders on their site
|
||||
- **Umgeni Water:** https://www.umgeni.co.za
|
||||
- **Amatola Water:** https://www.amatolawater.co.za
|
||||
- **Overberg Water:** https://www.overbergwater.co.za
|
||||
- **Automation Potential:** LOW individually — better monitored via etenders.gov.za
|
||||
|
||||
### 25. NERSA (National Energy Regulator)
|
||||
- **URL:** https://www.nersa.org.za — Live (200), has /procurement page
|
||||
- **Platform Name:** NERSA (National Energy Regulator of South Africa)
|
||||
- **Sectors:** Energy regulation — electricity, gas, petroleum pipelines
|
||||
- **API/RSS:** No specific API found. Has an online portal at nersa-portal.powerappsportals.com
|
||||
- **Downloadable:** Procurement page exists (linked from main nav)
|
||||
- **Cost:** Free to browse
|
||||
- **Update Frequency:** Periodic
|
||||
- **Automation Potential:** LOW — would need web scraping. NERSA procurement also on etenders.gov.za.
|
||||
|
||||
### 26. DOE (Department of Energy) / REIPPPP / IPP Procurement
|
||||
- **URLs:**
|
||||
- https://www.energy.gov.za — ❌ Connection failed (000). Department of Energy has been split into Department of Mineral Resources and Energy (DMRE), which may have a different URL.
|
||||
- https://www.ipp.co.za — Live but shows a landing/custom page (IPPCOZA). Not a public tender portal.
|
||||
- https://www.ipp-procurement.co.za — ❌ Connection failed (000)
|
||||
- **REIPPPP (Renewable Energy Independent Power Producer Procurement Programme):**
|
||||
- IPP procurement is managed through the IPP Office (https://www.ippprojects.co.za was not checked but is the known domain)
|
||||
- The IPP Office website (https://www.ipp-ppp.co.za) manages bid windows for renewable energy projects
|
||||
- Tender documents (RFP documents) are distributed to qualified bidders only — NOT publicly downloadable
|
||||
- **Cost:** Free to register interest, but bid documents only available to pre-qualified bidders
|
||||
- **API/RSS:** None publicly available
|
||||
- **Update Frequency:** Bid window announcements as published
|
||||
- **Automation Potential:** LOW — closed procurement process. Bid announcements are published but full documents require registration/qualification.
|
||||
|
||||
### 27. Eskom (State Utility — Energy)
|
||||
- **URL:** https://www.eskom.co.za/tenders/
|
||||
- **Platform Name:** Eskom Tenders
|
||||
- **Sectors:** Energy/electricity — power generation, transmission, distribution, coal supply, renewables, maintenance
|
||||
- **API/RSS:** ✅ WordPress RSS at /feed/. WP REST API at /wp-json/. WordPress-based.
|
||||
- **Downloadable:** ✅ Tenders listed on the site, documents downloadable. Eskom has a large procurement footprint.
|
||||
- **Cost:** Free to browse. Eskom supplier registration required to bid (via CSD).
|
||||
- **Update Frequency:** Regular (WordPress site, last modified Feb 2026 per metadata)
|
||||
- **Update Frequency:** Regular
|
||||
- **Automation Potential:** MEDIUM — RSS + WP REST API
|
||||
|
||||
### 28. CSIR (Council for Scientific and Industrial Research)
|
||||
- **URL:** https://www.csir.co.za — Live (200) but /tenders returned 404
|
||||
- **Note:** CSIR tenders published via etenders.gov.za. Some procurement via their own portal.
|
||||
|
||||
### 29. ACSA (Airports Company South Africa)
|
||||
- **URL:** https://www.airports.co.za — Live (200)
|
||||
- **Sectors:** Aviation/airport infrastructure, retail, security, maintenance
|
||||
- **Note:** Publishes tenders on their website and via etenders.gov.za
|
||||
|
||||
### 30. Central Supplier Database (CSD)
|
||||
- **URL:** https://secure.csd.gov.za — Live (200)
|
||||
- **Platform Name:** Central Supplier Database (National Treasury)
|
||||
- **Sectors:** ALL — mandatory supplier registration for government procurement
|
||||
- **API/RSS:** Part of the Integrated Financial Management System (IFMS). May have API access through OCPO systems.
|
||||
- **Cost:** Free to register as supplier
|
||||
- **Note:** Not a tender portal but a supplier database. All government suppliers must be CSD-registered. Useful for cross-referencing awarded tenders.
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY: Automation Prioritization
|
||||
|
||||
### TIER 1 — Highest Priority (APIs / Structured Data Available)
|
||||
1. **eTenders.gov.za** — FULL OCDS REST API with Swagger docs. Bulk CSV/JSON/Excel downloads. Creative Commons licensed. THIS IS THE PRIMARY SOURCE.
|
||||
- API: https://ocds-api.etenders.gov.za/swagger/
|
||||
- Data: https://data.etenders.gov.za/
|
||||
- Covers: ALL government tenders, ALL provinces, ALL departments, ALL SOEs
|
||||
|
||||
### TIER 2 — RSS Feeds Available (Automatable)
|
||||
2. **sa-tenders.co.za** — RSS feed + WordPress REST API. Freemium (R749/6mo or R1,199/yr for full access).
|
||||
3. **armscor.co.za** — RSS feed + WordPress REST API. Defence sector.
|
||||
4. **eskom.co.za** — RSS feed + WordPress REST API. Energy sector.
|
||||
5. **health.gov.za** — RSS feed + WP REST API + TablePress tables. Health sector.
|
||||
6. **cidb.org.za** — WP REST API + iCal feed + tender-management plugin. Construction sector.
|
||||
7. **masterbuilders.org.za** — RSS feed + WP REST API. Construction industry.
|
||||
|
||||
### TIER 3 — Web Scraping Required
|
||||
8. **SETAs** (12+ active sites) — Individual websites, no APIs. Better monitored via eTenders.gov.za.
|
||||
9. **Universities** (7+ active sites) — Individual procurement pages, no APIs. Also on eTenders.gov.za.
|
||||
10. **SANRAL** — WordPress site, likely has RSS but needs verification.
|
||||
11. **NERSA** — Has procurement page, needs scraping.
|
||||
12. **Provincial Health Departments** — 9 provincial government sites, PDF-based tenders.
|
||||
|
||||
### TIER 4 — Closed Networks / Not Applicable
|
||||
13. **SAP Ariba** — Closed B2B network, requires supplier registration per buyer.
|
||||
14. **Basware** — Closed e-procurement network.
|
||||
15. **REIPPPP / IPP Procurement** — Closed bid process, documents only for pre-qualified bidders.
|
||||
|
||||
### DEFUNCT / NOT FOUND
|
||||
16. **etenders.co.za** — HTTP 410 Gone
|
||||
17. **tender247.co.za** — DNS failure
|
||||
18. **tendersa.co.za** — DNS failure
|
||||
19. **freetender.co.za** — DNS failure
|
||||
20. **freetendersite.co.za** — DNS failure
|
||||
21. **corridorprocurement.co.za** — DNS failure
|
||||
22. **procurementportal.co.za** — DNS failure
|
||||
23. **professionalprocurement.co.za** — DNS failure
|
||||
24. **tradematic.com** — NOT a tender platform (crypto trading bot platform)
|
||||
25. **quest.co.za** — NOT a tender platform (recruitment agency)
|
||||
26. **publicworks.gov.za** — DNS failure (tenders via eTenders.gov.za)
|
||||
27. **energy.gov.za** — DNS failure (department restructured)
|
||||
|
||||
---
|
||||
|
||||
## KEY FINDING
|
||||
|
||||
The **eTenders.gov.za OCDS API** is the single most important data source. It provides:
|
||||
- A documented JSON REST API (Swagger/OpenAPI spec)
|
||||
- Open Contracting Data Standard (OCDS) compliance
|
||||
- Bulk data downloads (CSV, JSON, Excel)
|
||||
- Data from May 2021 onwards
|
||||
- Creative Commons BY 4.0 license
|
||||
- Coverage of ALL public sector tenders in South Africa
|
||||
|
||||
Most industry-specific portals (SETAs, universities, provincial departments, SOEs) publish their tenders through eTenders.gov.za as mandated by Treasury regulations. The OCDS API should capture the vast majority of these.
|
||||
|
||||
Commercial aggregators like sa-tenders.co.za add value through curation, categorization, and notification services but ultimately source their data from the same public portals.
|
||||
306
docs/sources.md
Normal file
306
docs/sources.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# South African Tender Sources — Comprehensive Research Report
|
||||
|
||||
**Research date:** July 2026
|
||||
**Methodology:** Direct HTTP probing of 120+ URLs, OCDS API testing, browser verification, subagent web research
|
||||
**Purpose:** Identify all online sources where SA tenders are published, with technical details for automation
|
||||
|
||||
---
|
||||
|
||||
## EXECUTIVE SUMMARY
|
||||
|
||||
**120+ URLs probed.** 42 confirmed live tender portals. 7 defunct/dead. The rest are either closed networks or redirect to the central portal.
|
||||
|
||||
### 🏆 The Gold Standard: eTenders.gov.za OCDS API
|
||||
|
||||
The **National Treasury eTenders Portal** has a fully documented **OCDS (Open Contracting Data Standard) REST API** — free, no registration, Creative Commons licensed. It covers ALL government, SOE, and public entity tenders. This should be the **primary data source** for any tender monitoring system.
|
||||
|
||||
- **API Base:** `https://ocds-api.etenders.gov.za/api/OCDSReleases`
|
||||
- **Swagger Docs:** `https://ocds-api.etenders.gov.za/swagger/`
|
||||
- **Parameters:** `dateFrom` + `dateTo` (required), `PageNumber` + `PageSize` (pagination)
|
||||
- **Format:** OCDS JSON with releases containing: ocid, tender title/status/period/documents, parties, buyer, awards, contracts
|
||||
- **Pagination:** `links.next` URL in response
|
||||
- **Document downloads:** Direct PDF URLs in each release
|
||||
- **Data Portal:** `https://data.etenders.gov.za/` — bulk CSV/JSON/Excel + dashboards
|
||||
- **License:** Creative Commons BY 4.0
|
||||
- **Data available from:** May 2021 onwards
|
||||
|
||||
### API Response Structure (verified)
|
||||
```json
|
||||
{
|
||||
"uri": "...",
|
||||
"version": "1.1",
|
||||
"publishedDate": "2025-06-30T...",
|
||||
"publisher": {"name": "National Treasury"},
|
||||
"license": "https://creativecommons.org/licenses/by/4.0/",
|
||||
"releases": [
|
||||
{
|
||||
"ocid": "ocds-9t57fa-137147",
|
||||
"date": "2025-06-30T00:00:00Z",
|
||||
"tag": ["tender"],
|
||||
"tender": {
|
||||
"title": "3100038635",
|
||||
"status": "active",
|
||||
"tenderPeriod": {"startDate": "...", "endDate": "..."},
|
||||
"documents": [{"url": "https://www.etenders.gov.za/home/Download?..."}]
|
||||
},
|
||||
"parties": [{"name": "SAB AND T CHARTERED ACCOUNTANTS", "roles": ["buyer"]}],
|
||||
"buyer": {...},
|
||||
"awards": [...],
|
||||
"contracts": [...]
|
||||
}
|
||||
],
|
||||
"links": {"next": "https://ocds-api.etenders.gov.za/api/OCDSReleases?PageNumber=2&PageSize=100&dateFrom=...&dateTo=..."}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## TIER 1 — Automatable via API/RSS (7 sources)
|
||||
|
||||
| # | Source | URL | Type | API/RSS | Automation |
|
||||
|---|--------|-----|------|---------|------------|
|
||||
| 1 | **eTenders OCDS API** | `https://ocds-api.etenders.gov.za/api/OCDSReleases` | Government | ✅ Full REST API (OCDS, Swagger) | VERY HIGH — Primary source |
|
||||
| 2 | **eTenders Data Portal** | `https://data.etenders.gov.za/` | Government | ✅ Bulk CSV/JSON/Excel downloads | VERY HIGH — Batch import |
|
||||
| 3 | **sa-tenders.co.za** | `https://sa-tenders.co.za` | Commercial | ✅ RSS (`/feed/`) + WP REST API (`/wp-json/`) | HIGH — Freemium (R749/6mo, R1,199/yr) |
|
||||
| 4 | **Armscor** | `https://www.armscor.co.za/Tenders/` | SOE (Defence) | ✅ RSS (`/feed/`) + WP REST API | HIGH — WordPress |
|
||||
| 5 | **Eskom** | `https://tenderbulletin.eskom.co.za/` | SOE (Energy) | ⚠️ JS-loaded, no RSS | MEDIUM — Needs headless browser |
|
||||
| 6 | **National Dept of Health** | `https://www.health.gov.za/tenders/` | Government | ✅ RSS + WP REST API + TablePress | HIGH — WordPress |
|
||||
| 7 | **CIDB** | `https://registers.cidb.org.za/PublicTenders/TenderSearch` + `https://www.cidb.org.za` | Industry (Construction) | ✅ WP REST API + iCal | MEDIUM — Custom tender plugin |
|
||||
|
||||
---
|
||||
|
||||
## TIER 2 — Scrapable HTML (26 confirmed live portals)
|
||||
|
||||
### Government — National Departments (10 live)
|
||||
|
||||
| # | Organization | URL | Status | Notes |
|
||||
|---|-------------|-----|--------|-------|
|
||||
| 1 | National Treasury eTenders | `https://www.etenders.gov.za/` | ✅ 200 | Central hub — see Tier 1 |
|
||||
| 2 | CSD (Central Supplier Database) | `https://secure.csd.gov.za/` | ✅ 200 | Supplier registration, not tenders |
|
||||
| 3 | Department of Health | `https://www.health.gov.za/tenders/` | ✅ 200 | WordPress + RSS |
|
||||
| 4 | DWS (Water & Sanitation) | `https://www.dws.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 5 | SARS | `https://www.sars.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 6 | Stats SA | `https://www.statssa.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 7 | DIRCO (Foreign Affairs) | `https://www.dirco.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 8 | NPA (National Prosecuting Authority) | `https://www.npa.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 9 | Public Service Commission | `https://www.psc.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 10 | SAHRC (Human Rights Commission) | `https://www.sahrc.org.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 11 | State Security Agency | `https://www.ssa.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 12 | Brand South Africa | `https://www.brandsouthafrica.co.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 13 | CHE (Council on Higher Education) | `https://www.che.ac.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 14 | DTIC (Trade & Industry) | `https://www.thedtic.gov.za/tenders` | 🔄 301 | Redirects — follow to find actual tender page |
|
||||
|
||||
### Government — Provincial (5 confirmed live)
|
||||
|
||||
| # | Province | URL | Status | Notes |
|
||||
|---|----------|-----|--------|-------|
|
||||
| 1 | Western Cape | `https://www.westerncape.gov.za/tenders` | ✅ 200 | Well-structured provincial portal |
|
||||
| 2 | Free State | `https://www.freestateonline.fs.gov.za/tenders` | ✅ 200 | SharePoint-based |
|
||||
| 3 | Limpopo | `https://www.limpopo.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 4 | North West | `https://www.nwpg.gov.za/tenders` | ✅ 200 | HTML scraping |
|
||||
| 5 | Eastern Cape Treasury | `https://www.ectreasury.gov.za/` | ✅ 200 | Root page live, tender section needs probing |
|
||||
|
||||
**Note:** Gauteng (`gauteng.gov.za`), KZN (`kzn.gov.za`), Eastern Cape (`ecprovince.gov.za`), Mpumalanga (`mpu.gov.za`), and Northern Cape (`northerncape.gov.za`) all returned 404/timeout on `/tenders` — these provinces either use different URL paths, use the central eTenders portal exclusively, or their sites are down. Their tenders ARE captured in the eTenders OCDS API.
|
||||
|
||||
### Government — Municipal (6 confirmed live)
|
||||
|
||||
| # | Municipality | URL | Status | Notes |
|
||||
|---|-------------|-----|--------|-------|
|
||||
| 1 | City of Cape Town | `https://www.capetown.gov.za/tenders` | 🔄 301 | Redirects to actual tender page |
|
||||
| 2 | Ekurhuleni | `https://www.ekurhuleni.gov.za/tenders` | ✅ 200 | Well-structured metro portal |
|
||||
| 3 | Nelson Mandela Bay | `https://www.nelsonmandelabay.gov.za/tenders` | ✅ 200 | Metro portal |
|
||||
| 4 | George Municipality | `https://www.george.gov.za/tenders` | ✅ 200 | Local municipality |
|
||||
| 5 | Midvaal | `https://www.midvaal.gov.za/tenders` | ✅ 200 | Local municipality |
|
||||
| 6 | Mbombela (Nelspruit) | `https://www.mbombela.gov.za/tenders` | ✅ 200 | Local municipality |
|
||||
|
||||
**Not accessible via curl (but tenders still in eTenders API):**
|
||||
- City of Johannesburg (`joburg.org.za`) — site blocks curl
|
||||
- eThekwini/Durban (`durban.gov.za`) — site not responding
|
||||
- City of Tshwane (`tshwane.gov.za`) — site not responding
|
||||
- Buffalo City (`buffalocity.gov.za`) — 404 on /tenders
|
||||
- Mangaung/Bloemfontein (`mangaung.co.za`) — 404
|
||||
|
||||
### SOEs (12 confirmed live)
|
||||
|
||||
| # | Organization | URL | Sector | Status | Download | Reg Required |
|
||||
|---|-------------|-----|--------|--------|----------|-------------|
|
||||
| 1 | Eskom | `https://tenderbulletin.eskom.co.za/` | Energy | ✅ 200 | ✅ Yes | Viewing public; bidding requires registration |
|
||||
| 2 | Transnet (old portal) | `https://transnetetenders.azurewebsites.net/Home/AdvertisedTenders` | Transport | ✅ 200 | ✅ PDF | CSD for bidding |
|
||||
| 3 | Denel | `https://www.denel.co.za/tenders` | Defence | ✅ 200 | Limited | Unknown |
|
||||
| 4 | Armscor | `https://www.armscor.co.za/Tenders/` | Defence | ✅ 200 | ✅ Yes | Public browsing; login for bidding |
|
||||
| 5 | IDC | `https://www.idc.co.za/tenders/` | Development Finance | ✅ 200 | ✅ Yes (PDF + OneDrive) | Submission via email/OneDrive |
|
||||
| 6 | DBSA | `https://www.dbsa.org/procurement` | Development Banking | ✅ 200 | ✅ Yes (PDF, BoQs) | Public viewing |
|
||||
| 7 | Land Bank | `https://landbank.co.za/Supply-Chain/Pages/SupplyChainManagement.aspx` | Agriculture Finance | ✅ 200 | Unknown | Portal login |
|
||||
| 8 | ACSA (Airports) | `https://www.airports.co.za/business/supply-chain-management/current-and-future-tenders` | Airports | ✅ 200 | Likely | Stakeholder login |
|
||||
| 9 | SAFCOL | `https://www.safcol.co.za/tenders` | Forestry | ✅ 200 | Unknown | Unknown |
|
||||
| 10 | Sedibeng Water | `https://www.sedibengwater.co.za/tenders/` | Water | ✅ 200 | Unknown | Unknown |
|
||||
| 11 | NERSA | `https://www.nersa.org.za/procurement` | Energy Regulator | ✅ 200 | Unknown | Unknown |
|
||||
| 12 | Master Builders SA | `https://www.masterbuilders.org.za` | Construction Industry | ✅ 200 | Member access | Membership for full access |
|
||||
|
||||
### Industry/Other (2 confirmed live)
|
||||
|
||||
| # | Organization | URL | Sector | Notes |
|
||||
|---|-------------|-----|--------|-------|
|
||||
| 1 | CIDB Tender Register | `https://registers.cidb.org.za/PublicTenders/TenderSearch` | Construction | Structured search — good for scraping |
|
||||
| 2 | CIDB Main Site | `https://www.cidb.org.za` | Construction | WordPress + REST API |
|
||||
|
||||
---
|
||||
|
||||
## TIER 3 — Closed Networks / Not Publicly Scrapable
|
||||
|
||||
| # | Platform | URL | Notes |
|
||||
|---|----------|-----|-------|
|
||||
| 1 | SAP Ariba | `https://www.sap.com/products/spend-management.html` | Closed B2B network. Used by Anglo American, Sasol, Standard Bank, etc. Requires supplier registration per buyer. APIs exist but only within network. |
|
||||
| 2 | Basware | `https://www.basware.com/` | Closed e-procurement. Same model as Ariba. |
|
||||
| 3 | REIPPPP / IPP Office | `https://www.ipp-ppp.co.za/` | Renewable energy procurement. Closed bid process — documents only for pre-qualified bidders. |
|
||||
| 4 | Transnet eSupplier Portal (new) | `https://esupplierportal.transnet.net/` | New portal (Oct 2025). Not accessible via curl. Requires registration. |
|
||||
| 5 | Rand Water Bids | `https://bids.randwater.co.za/` | Dedicated bidding platform. Requires registration. Not accessible via curl. |
|
||||
| 6 | tenders.co.za | `https://www.tenders.co.za` | Cloudflare-protected (403). Would need headless browser. |
|
||||
|
||||
### Private Corporate Procurement Portals (not publicly scrapable)
|
||||
|
||||
Most large SA corporations use **supplier registration portals** rather than public tender listings. Tenders are sent to pre-registered suppliers. These are NOT suitable for automated tender monitoring:
|
||||
|
||||
- **Mining:** Anglo American, Sasol, Sibanye-Stillwater, Impala Platinum, Gold Fields, Harmony, Exxaro, BHP — all use SAP Ariba or proprietary supplier portals
|
||||
- **Construction:** M&R, WBHO, Stefanutti, Aveng — respond to client tenders, don't publish their own
|
||||
- **Retail:** Shoprite, Pick n Pay, Woolworths — supplier registration portals
|
||||
- **Telecoms:** MTN, Vodacom — supplier registration portals
|
||||
- **Banks:** Standard Bank, ABSA, FNB, Nedbank — supplier registration
|
||||
- **Insurance:** Old Mutual, Sanlam, Discovery — supplier registration
|
||||
|
||||
---
|
||||
|
||||
## TIER 4 — Defunct / Dead Sites (confirmed)
|
||||
|
||||
| # | Site | URL | Status |
|
||||
|---|------|-----|--------|
|
||||
| 1 | etenders.co.za | `https://www.etenders.co.za` | HTTP 410 Gone |
|
||||
| 2 | tender247.co.za | `https://www.tender247.co.za` | DNS failure |
|
||||
| 3 | tendersa.co.za | `https://www.tendersa.co.za` | DNS failure |
|
||||
| 4 | freetender.co.za | `https://www.freetender.co.za` | DNS failure |
|
||||
| 5 | freetendersite.co.za | `https://www.freetendersite.co.za` | DNS failure |
|
||||
| 6 | corridorprocurement.co.za | `https://www.corridorprocurement.co.za` | DNS failure |
|
||||
| 7 | procurementportal.co.za | `https://www.procurementportal.co.za` | DNS failure |
|
||||
| 8 | professionalprocurement.co.za | `https://www.professionalprocurement.co.za` | DNS failure |
|
||||
| 9 | publicworks.gov.za | `https://www.publicworks.gov.za/` | DNS failure (tenders via eTenders.gov.za) |
|
||||
| 10 | energy.gov.za | `https://www.energy.gov.za` | DNS failure (dept restructured into DMRE) |
|
||||
|
||||
### Not Tender Platforms (incorrectly listed in some sources)
|
||||
- **Tradematic** — crypto trading bot platform, not tenders
|
||||
- **Quest.co.za** — recruitment agency (Adcorp), not tenders
|
||||
|
||||
---
|
||||
|
||||
## SETAs (Sector Education and Training Authorities)
|
||||
|
||||
12 confirmed live. None have APIs — all need web scraping. **Better monitored via eTenders.gov.za** which captures all SETA tenders.
|
||||
|
||||
| SETA | URL | Sector | Status |
|
||||
|------|-----|--------|--------|
|
||||
| merSETA | `https://www.merseta.org.za` | Manufacturing & Engineering | ✅ Live |
|
||||
| CHIETA | `https://www.chieta.org.za` | Chemical Industries | ✅ Live |
|
||||
| MICT SETA | `https://www.mictseta.org.za` | Media & ICT | ✅ Live |
|
||||
| INSETA | `https://www.inseta.org.za` | Insurance | ✅ Live |
|
||||
| HWSETA | `https://www.hwseta.org.za` | Health & Welfare | ✅ Live |
|
||||
| FoodBev SETA | `https://www.foodbev.org.za` | Food & Beverage | ✅ Live |
|
||||
| Services SETA | `https://www.serviceseta.org.za` | Services | ✅ Live |
|
||||
| MQA | `https://www.mqa.org.za` | Mining | ✅ Live |
|
||||
| PSETA | `https://www.pseta.org.za` | Public Service | ✅ Live |
|
||||
| BANKSETA | `https://www.bankseta.org.za` | Banking | ✅ Live |
|
||||
| AgriSETA | `https://www.agriseta.co.za` | Agriculture | ✅ Live |
|
||||
| UMALUSI | `https://www.umalusi.org.za` | Education QA | ✅ Live |
|
||||
|
||||
**Defunct/consolidated:** ETDP SETA, TETA, SETASA, FASSET, CETA, W&RSETA — domains no longer resolve (SETA landscape has been consolidated)
|
||||
|
||||
---
|
||||
|
||||
## Universities
|
||||
|
||||
All publish tenders on individual procurement pages. No APIs. Also captured in eTenders.gov.za.
|
||||
|
||||
| University | URL | Status |
|
||||
|------------|-----|--------|
|
||||
| UCT | `https://www.uct.ac.za` | ✅ Live |
|
||||
| Wits | `https://www.wits.ac.za` | ✅ Live |
|
||||
| Stellenbosch | `https://www.sun.ac.za` | ✅ Live |
|
||||
| UKZN | `https://www.ukzn.ac.za` | ✅ Live |
|
||||
| Nelson Mandela | `https://www.mandela.ac.za` | ✅ Live |
|
||||
| Rhodes | `https://www.ru.ac.za` | ✅ Live |
|
||||
| UP (Pretoria) | `https://www.up.ac.za` | ⚠️ 403 (blocks curl) |
|
||||
| UJ (Johannesburg) | `https://www.uj.ac.za` | ⚠️ 403 (blocks curl) |
|
||||
|
||||
---
|
||||
|
||||
## Water Boards
|
||||
|
||||
| Water Board | URL | Status |
|
||||
|------------|-----|--------|
|
||||
| Rand Water | `https://bids.randwater.co.za/` | ❌ Not accessible via curl (registration required) |
|
||||
| Umgeni Water | `https://www.umgeni.co.za/tenders/` | ❌ SSL issues |
|
||||
| Amatola Water | `https://www.amatolawater.co.za/tenders/` | ❌ 500 error |
|
||||
| Overberg Water | `https://www.overbergwater.co.za/tenders/` | ❌ 403 |
|
||||
| Sedibeng Water | `https://www.sedibengwater.co.za/tenders/` | ✅ 200 |
|
||||
|
||||
**Note:** Water board tenders are also published via eTenders.gov.za.
|
||||
|
||||
---
|
||||
|
||||
## RECOMMENDED AUTOMATION ARCHITECTURE
|
||||
|
||||
### Phase 1: Core Data Pipeline (eTenders OCDS API)
|
||||
```
|
||||
eTenders OCDS API → Daily pull (dateFrom/dateTo) → Parse releases → Store in DB
|
||||
↓
|
||||
Tender monitoring system
|
||||
```
|
||||
- Pull daily using `dateFrom=<yesterday>&dateTo=<today>`
|
||||
- Paginate using `links.next`
|
||||
- Extract: ocid, title, status, tenderPeriod, documents (PDF URLs), buyer, parties
|
||||
- Download tender PDFs from document URLs
|
||||
- This single source covers **ALL government, SOE, and public entity tenders**
|
||||
|
||||
### Phase 2: Supplementary Sources (RSS/WordPress)
|
||||
- sa-tenders.co.za RSS feed (freemium — adds curation/categorization)
|
||||
- Armscor RSS (defence sector depth)
|
||||
- Health.gov.za RSS (health sector depth)
|
||||
- CIDB WP REST API (construction sector depth)
|
||||
- Master Builders RSS (construction industry)
|
||||
|
||||
### Phase 3: Targeted Scraping (headless browser)
|
||||
- Eskom tender bulletin (JS-loaded)
|
||||
- Transnet old portal (Azure-hosted)
|
||||
- Provincial portals (Western Cape, Free State, Limpopo, North West)
|
||||
- Municipal portals (Cape Town, Ekurhuleni, Nelson Mandela Bay)
|
||||
- Direct SOE portals (DBSA, IDC, ACSA, SAFCOL, Land Bank)
|
||||
|
||||
### Phase 4: Closed Network Monitoring (manual/registration)
|
||||
- Transnet eSupplier Portal (new)
|
||||
- Rand Water bids platform
|
||||
- SAP Ariba (if monitoring specific enterprise buyers)
|
||||
- REIPPPP/IPP Office (renewable energy bid windows)
|
||||
|
||||
### What NOT to bother with
|
||||
- Defunct sites (7 confirmed dead)
|
||||
- Private corporate portals (supplier registration only, not public tenders)
|
||||
- Most SETAs and universities (captured in eTenders API)
|
||||
- Construction company portals (they bid, they don't publish)
|
||||
- Tradematic, Quest (not tender platforms)
|
||||
|
||||
---
|
||||
|
||||
## SUMMARY STATISTICS
|
||||
|
||||
| Category | Count | Automatable |
|
||||
|----------|-------|-------------|
|
||||
| Government (national) | 14 live | All via eTenders API |
|
||||
| Government (provincial) | 5 live + 4 via eTenders | All via eTenders API |
|
||||
| Government (municipal) | 6 live + 5 via eTenders | All via eTenders API |
|
||||
| SOEs | 12 live | 1 RSS + 11 scraping (all via eTenders API) |
|
||||
| Commercial aggregators | 2 live | 1 RSS + 1 Cloudflare-protected |
|
||||
| Industry-specific | 4 live | 2 RSS/API + 2 scraping |
|
||||
| SETAs | 12 live | All via eTenders API |
|
||||
| Universities | 8 live | All via eTenders API |
|
||||
| Water boards | 1 live | Via eTenders API |
|
||||
| Defunct | 10 confirmed dead | N/A |
|
||||
| Closed networks | 6 | Not publicly accessible |
|
||||
| **Total unique sources** | **~75** | **eTenders API covers ~90%** |
|
||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
httpx>=0.28.1,<1
|
||||
beautifulsoup4>=4.12.3,<5
|
||||
lxml>=5.3.0,<6
|
||||
selectolax>=0.3.27,<1
|
||||
playwright>=1.49.0,<2
|
||||
rich>=13.9.0,<14
|
||||
click>=8.1.7,<9
|
||||
feedparser>=6.0.11,<7
|
||||
python-dateutil>=2.9.0,<3
|
||||
tqdm>=4.67.0,<5
|
||||
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
94
src/config.py
Normal file
94
src/config.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Configuration for the SA Tender Monitor."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = PROJECT_ROOT / "data"
|
||||
DB_PATH = DATA_DIR / "tenders.db"
|
||||
RAW_DIR = DATA_DIR / "raw"
|
||||
CACHE_DIR = DATA_DIR / "cache"
|
||||
DOWNLOADS_DIR = DATA_DIR / "downloads"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OcdsConfig:
|
||||
"""eTenders OCDS API configuration."""
|
||||
|
||||
base_url: str = "https://ocds-api.etenders.gov.za/api/OCDSReleases"
|
||||
swagger_url: str = "https://ocds-api.etenders.gov.za/swagger/"
|
||||
page_size: int = 100
|
||||
timeout: int = 60
|
||||
rate_limit_ms: int = 500 # delay between API page requests
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebUiConfig:
|
||||
"""eTenders web UI scraping configuration.
|
||||
|
||||
The web UI uses DataTables server-side processing via
|
||||
/Home/PaginatedTenderOpportunities. This endpoint requires a browser
|
||||
session (cookies/CSRF) so we use Playwright to interact with it.
|
||||
"""
|
||||
|
||||
base_url: str = "https://www.etenders.gov.za"
|
||||
opportunities_path: str = "/Home/opportunities?id=1"
|
||||
datatables_endpoint: str = "/Home/PaginatedTenderOpportunities"
|
||||
download_path: str = "/home/Download"
|
||||
page_size: int = 10 # the web UI default
|
||||
timeout_ms: int = 30_000
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupplementarySource:
|
||||
"""A supplementary tender source (RSS or scrapeable HTML)."""
|
||||
|
||||
name: str
|
||||
url: str
|
||||
source_type: str # "rss" | "scrape" | "api"
|
||||
sector: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
SUPPLEMENTARY_SOURCES: tuple[SupplementarySource, ...] = (
|
||||
SupplementarySource("armscor", "https://www.armscor.co.za/Tenders/", "rss", "Defence"),
|
||||
SupplementarySource("health", "https://www.health.gov.za/tenders/", "rss", "Health"),
|
||||
SupplementarySource("cidb", "https://registers.cidb.org.za/PublicTenders/TenderSearch", "scrape", "Construction"),
|
||||
SupplementarySource("sa-tenders", "https://sa-tenders.co.za/feed/", "rss", "All-sector aggregator"),
|
||||
SupplementarySource("eskom", "https://tenderbulletin.eskom.co.za/", "scrape", "Energy"),
|
||||
SupplementarySource("transnet", "https://transnetetenders.azurewebsites.net/Home/AdvertisedTenders", "scrape", "Transport"),
|
||||
SupplementarySource("dbsa", "https://www.dbsa.org/procurement", "scrape", "Development Banking"),
|
||||
SupplementarySource("idc", "https://www.idc.co.za/tenders/", "scrape", "Development Finance"),
|
||||
SupplementarySource("nersa", "https://www.nersa.org.za/procurement", "scrape", "Energy Regulator"),
|
||||
SupplementarySource("safcol", "https://www.safcol.co.za/tenders", "scrape", "Forestry"),
|
||||
SupplementarySource("sedibeng-water", "https://www.sedibengwater.co.za/tenders/", "scrape", "Water"),
|
||||
SupplementarySource("master-builders", "https://www.masterbuilders.org.za", "rss", "Construction"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
"""Top-level configuration."""
|
||||
|
||||
ocds: OcdsConfig = field(default_factory=OcdsConfig)
|
||||
web_ui: WebUiConfig = field(default_factory=WebUiConfig)
|
||||
supplementary_sources: tuple[SupplementarySource, ...] = SUPPLEMENTARY_SOURCES
|
||||
db_path: Path = DB_PATH
|
||||
downloads_dir: Path = DOWNLOADS_DIR
|
||||
raw_dir: Path = RAW_DIR
|
||||
cache_dir: Path = CACHE_DIR
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
return Config()
|
||||
|
||||
|
||||
def ensure_dirs() -> None:
|
||||
"""Create data directories if they don't exist."""
|
||||
for d in (DATA_DIR, RAW_DIR, CACHE_DIR, DOWNLOADS_DIR):
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
294
src/db.py
Normal file
294
src/db.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""SQLite database for storing tenders and documents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import Tender, TenderDocument
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tenders (
|
||||
ocid TEXT PRIMARY KEY,
|
||||
internal_id INTEGER,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
status TEXT DEFAULT '',
|
||||
tender_number TEXT DEFAULT '',
|
||||
category TEXT DEFAULT '',
|
||||
tender_type TEXT DEFAULT '',
|
||||
province TEXT DEFAULT '',
|
||||
department TEXT DEFAULT '',
|
||||
date_published TEXT,
|
||||
closing_date TEXT,
|
||||
briefing_date TEXT,
|
||||
delivery_location TEXT DEFAULT '',
|
||||
conditions TEXT DEFAULT '',
|
||||
has_briefing INTEGER DEFAULT 0,
|
||||
briefing_compulsory INTEGER DEFAULT 0,
|
||||
briefing_venue TEXT DEFAULT '',
|
||||
buyer_name TEXT DEFAULT '',
|
||||
buyer_contact TEXT DEFAULT '',
|
||||
buyer_email TEXT DEFAULT '',
|
||||
buyer_telephone TEXT DEFAULT '',
|
||||
buyer_fax TEXT DEFAULT '',
|
||||
source TEXT DEFAULT '',
|
||||
doc_count INTEGER DEFAULT 0,
|
||||
last_updated TEXT,
|
||||
raw_data TEXT,
|
||||
UNIQUE(ocid)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tender_ocid TEXT NOT NULL,
|
||||
document_id TEXT,
|
||||
title TEXT DEFAULT '',
|
||||
url TEXT DEFAULT '',
|
||||
document_type TEXT DEFAULT '',
|
||||
format TEXT DEFAULT '',
|
||||
file_name TEXT DEFAULT '',
|
||||
extension TEXT DEFAULT '',
|
||||
support_document_id TEXT DEFAULT '',
|
||||
date_published TEXT,
|
||||
date_modified TEXT,
|
||||
source TEXT DEFAULT '',
|
||||
downloaded INTEGER DEFAULT 0,
|
||||
local_path TEXT,
|
||||
FOREIGN KEY (tender_ocid) REFERENCES tenders(ocid) ON DELETE CASCADE,
|
||||
UNIQUE(tender_ocid, document_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tenders_status ON tenders(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenders_closing ON tenders(closing_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenders_department ON tenders(department);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenders_province ON tenders(province);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenders_category ON tenders(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_docs_tender ON documents(tender_ocid);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
"""SQLite-backed tender storage."""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._conn() as conn:
|
||||
conn.executescript(SCHEMA)
|
||||
|
||||
def _conn(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.db_path))
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
def upsert_tender(self, tender: Tender) -> bool:
|
||||
"""Insert or update a tender. Returns True if new, False if updated."""
|
||||
buyer = tender.buyer
|
||||
with self._conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT ocid FROM tenders WHERE ocid = ?", (tender.ocid,)
|
||||
).fetchone()
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO tenders (
|
||||
ocid, internal_id, title, description, status, tender_number,
|
||||
category, tender_type, province, department,
|
||||
date_published, closing_date, briefing_date,
|
||||
delivery_location, conditions,
|
||||
has_briefing, briefing_compulsory, briefing_venue,
|
||||
buyer_name, buyer_contact, buyer_email, buyer_telephone, buyer_fax,
|
||||
source, doc_count, last_updated, raw_data
|
||||
) VALUES (
|
||||
:ocid, :internal_id, :title, :description, :status, :tender_number,
|
||||
:category, :tender_type, :province, :department,
|
||||
:date_published, :closing_date, :briefing_date,
|
||||
:delivery_location, :conditions,
|
||||
:has_briefing, :briefing_compulsory, :briefing_venue,
|
||||
:buyer_name, :buyer_contact, :buyer_email, :buyer_telephone, :buyer_fax,
|
||||
:source, :doc_count, :last_updated, :raw_data
|
||||
)
|
||||
ON CONFLICT(ocid) DO UPDATE SET
|
||||
internal_id = :internal_id,
|
||||
title = :title,
|
||||
description = :description,
|
||||
status = :status,
|
||||
tender_number = :tender_number,
|
||||
category = :category,
|
||||
tender_type = :tender_type,
|
||||
province = :province,
|
||||
department = :department,
|
||||
date_published = :date_published,
|
||||
closing_date = :closing_date,
|
||||
briefing_date = :briefing_date,
|
||||
delivery_location = :delivery_location,
|
||||
conditions = :conditions,
|
||||
has_briefing = :has_briefing,
|
||||
briefing_compulsory = :briefing_compulsory,
|
||||
briefing_venue = :briefing_venue,
|
||||
buyer_name = :buyer_name,
|
||||
buyer_contact = :buyer_contact,
|
||||
buyer_email = :buyer_email,
|
||||
buyer_telephone = :buyer_telephone,
|
||||
buyer_fax = :buyer_fax,
|
||||
source = :source,
|
||||
doc_count = :doc_count,
|
||||
last_updated = :last_updated,
|
||||
raw_data = :raw_data
|
||||
""",
|
||||
{
|
||||
"ocid": tender.ocid,
|
||||
"internal_id": tender.internal_id,
|
||||
"title": tender.title,
|
||||
"description": tender.description,
|
||||
"status": tender.status,
|
||||
"tender_number": tender.tender_number,
|
||||
"category": tender.category,
|
||||
"tender_type": tender.tender_type,
|
||||
"province": tender.province,
|
||||
"department": tender.department,
|
||||
"date_published": tender.date_published.isoformat() if tender.date_published else None,
|
||||
"closing_date": tender.closing_date.isoformat() if tender.closing_date else None,
|
||||
"briefing_date": tender.briefing_date.isoformat() if tender.briefing_date else None,
|
||||
"delivery_location": tender.delivery_location,
|
||||
"conditions": tender.conditions,
|
||||
"has_briefing": int(tender.has_briefing),
|
||||
"briefing_compulsory": int(tender.briefing_compulsory),
|
||||
"briefing_venue": tender.briefing_venue,
|
||||
"buyer_name": buyer.name if buyer else "",
|
||||
"buyer_contact": buyer.contact_person if buyer else "",
|
||||
"buyer_email": buyer.email if buyer else "",
|
||||
"buyer_telephone": buyer.telephone if buyer else "",
|
||||
"buyer_fax": buyer.fax if buyer else "",
|
||||
"source": tender.source,
|
||||
"doc_count": len(tender.documents),
|
||||
"last_updated": tender.last_updated.isoformat() if tender.last_updated else None,
|
||||
"raw_data": json.dumps(tender.raw_data, default=str),
|
||||
},
|
||||
)
|
||||
|
||||
# Upsert documents
|
||||
for doc in tender.documents:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO documents (
|
||||
tender_ocid, document_id, title, url, document_type, format,
|
||||
file_name, extension, support_document_id,
|
||||
date_published, date_modified, source
|
||||
) VALUES (
|
||||
:tender_ocid, :document_id, :title, :url, :document_type, :format,
|
||||
:file_name, :extension, :support_document_id,
|
||||
:date_published, :date_modified, :source
|
||||
)
|
||||
ON CONFLICT(tender_ocid, document_id) DO UPDATE SET
|
||||
title = :title,
|
||||
url = :url,
|
||||
document_type = :document_type,
|
||||
format = :format,
|
||||
file_name = :file_name,
|
||||
extension = :extension,
|
||||
date_modified = :date_modified,
|
||||
source = :source
|
||||
""",
|
||||
{
|
||||
"tender_ocid": tender.ocid,
|
||||
"document_id": doc.document_id,
|
||||
"title": doc.title,
|
||||
"url": doc.download_url,
|
||||
"document_type": doc.document_type,
|
||||
"format": doc.format,
|
||||
"file_name": doc.file_name,
|
||||
"extension": doc.extension,
|
||||
"support_document_id": doc.support_document_id,
|
||||
"date_published": doc.date_published.isoformat() if doc.date_published else None,
|
||||
"date_modified": doc.date_modified.isoformat() if doc.date_modified else None,
|
||||
"source": doc.source,
|
||||
},
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return existing is None
|
||||
|
||||
def get_tender(self, ocid: str) -> dict[str, Any] | None:
|
||||
"""Get a tender by ocid with its documents."""
|
||||
with self._conn() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"SELECT * FROM tenders WHERE ocid = ?", (ocid,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
tender = dict(row)
|
||||
docs = conn.execute(
|
||||
"SELECT * FROM documents WHERE tender_ocid = ? ORDER BY date_modified DESC",
|
||||
(ocid,),
|
||||
).fetchall()
|
||||
tender["documents"] = [dict(d) for d in docs]
|
||||
return tender
|
||||
|
||||
def list_tenders(
|
||||
self,
|
||||
status: str | None = None,
|
||||
department: str | None = None,
|
||||
province: str | None = None,
|
||||
category: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List tenders with optional filters."""
|
||||
query = "SELECT * FROM tenders WHERE 1=1"
|
||||
params: list[Any] = []
|
||||
|
||||
if status:
|
||||
query += " AND status = ?"
|
||||
params.append(status)
|
||||
if department:
|
||||
query += " AND department LIKE ?"
|
||||
params.append(f"%{department}%")
|
||||
if province:
|
||||
query += " AND province = ?"
|
||||
params.append(province)
|
||||
if category:
|
||||
query += " AND category LIKE ?"
|
||||
params.append(f"%{category}%")
|
||||
|
||||
query += " ORDER BY date_published DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
|
||||
with self._conn() as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
"""Return database statistics."""
|
||||
with self._conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM tenders").fetchone()[0]
|
||||
active = conn.execute("SELECT COUNT(*) FROM tenders WHERE status = 'active'").fetchone()[0]
|
||||
total_docs = conn.execute("SELECT COUNT(*) FROM documents").fetchone()[0]
|
||||
depts = conn.execute("SELECT COUNT(DISTINCT department) FROM tenders WHERE department != ''").fetchone()[0]
|
||||
provinces = conn.execute("SELECT COUNT(DISTINCT province) FROM tenders WHERE province != ''").fetchone()[0]
|
||||
|
||||
# Tenders with >1 document (from web UI enrichment)
|
||||
multi_doc = conn.execute(
|
||||
"SELECT COUNT(*) FROM tenders WHERE doc_count > 1"
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total_tenders": total,
|
||||
"active_tenders": active,
|
||||
"total_documents": total_docs,
|
||||
"departments": depts,
|
||||
"provinces": provinces,
|
||||
"multi_doc_tenders": multi_doc,
|
||||
}
|
||||
265
src/models.py
Normal file
265
src/models.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Data models for tenders, documents, and buyers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _parse_dt(value: str | None) -> datetime | None:
|
||||
"""Parse an ISO datetime string, returning None on failure."""
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
# OCDS dates look like "2025-06-30T00:00:00Z"
|
||||
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenderDocument:
|
||||
"""A document attached to a tender."""
|
||||
|
||||
document_id: str # UUID or blob name
|
||||
title: str = ""
|
||||
url: str = ""
|
||||
document_type: str = "" # "basic", "tenderNotice", etc.
|
||||
format: str = "" # "pdf", "pptx", etc.
|
||||
date_published: datetime | None = None
|
||||
date_modified: datetime | None = None
|
||||
source: str = "ocds" # "ocds" or "web_ui"
|
||||
|
||||
# Web UI specific fields
|
||||
file_name: str = ""
|
||||
extension: str = ""
|
||||
support_document_id: str = "" # the UUID used in download URLs
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d = asdict(self)
|
||||
for k in ("date_published", "date_modified"):
|
||||
if d[k]:
|
||||
d[k] = d[k].isoformat()
|
||||
return d
|
||||
|
||||
@property
|
||||
def download_url(self) -> str:
|
||||
"""Construct the download URL for this document."""
|
||||
if self.url:
|
||||
return self.url
|
||||
if self.support_document_id:
|
||||
ext = self.extension or ".pdf"
|
||||
base = "https://www.etenders.gov.za/home/Download"
|
||||
return f"{base}?blobName={self.support_document_id}{ext}&downloadedFileName={self.file_name}"
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def from_ocds(cls, doc: dict) -> TenderDocument:
|
||||
"""Create from an OCDS document object."""
|
||||
return cls(
|
||||
document_id=doc.get("id", ""),
|
||||
title=doc.get("title", ""),
|
||||
url=doc.get("url", ""),
|
||||
document_type=doc.get("documentType", ""),
|
||||
format=doc.get("format", ""),
|
||||
date_published=_parse_dt(doc.get("datePublished")),
|
||||
date_modified=_parse_dt(doc.get("dateModified")),
|
||||
source="ocds",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_web_ui(cls, doc: dict) -> TenderDocument:
|
||||
"""Create from a web UI supportDocument object."""
|
||||
sid = doc.get("supportDocumentID", "")
|
||||
ext = doc.get("extension", "")
|
||||
fname = doc.get("fileName", "")
|
||||
return cls(
|
||||
document_id=sid,
|
||||
title=fname,
|
||||
document_type="supportDocument",
|
||||
format=ext.lstrip("."),
|
||||
date_modified=_parse_dt(doc.get("dateModified")),
|
||||
source="web_ui",
|
||||
file_name=fname,
|
||||
extension=ext,
|
||||
support_document_id=sid,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TenderBuyer:
|
||||
"""A buyer (organ of state) for a tender."""
|
||||
|
||||
name: str = ""
|
||||
identifier: str = "" # org ID if available
|
||||
address: str = ""
|
||||
contact_person: str = ""
|
||||
email: str = ""
|
||||
telephone: str = ""
|
||||
fax: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tender:
|
||||
"""A tender (procurement opportunity)."""
|
||||
|
||||
# Primary key — OCDS ocid or generated ID
|
||||
ocid: str = ""
|
||||
internal_id: int | None = None # eTenders internal ID (from web UI)
|
||||
|
||||
# Core metadata
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
status: str = "" # active, complete, cancelled, etc.
|
||||
tender_number: str = "" # e.g. "TFR/2026/07/0002/114548/RFI"
|
||||
|
||||
# Classification
|
||||
category: str = ""
|
||||
tender_type: str = "" # "Request for Information", "Bid", etc.
|
||||
province: str = ""
|
||||
department: str = "" # organ of state
|
||||
|
||||
# Dates
|
||||
date_published: datetime | None = None
|
||||
closing_date: datetime | None = None
|
||||
briefing_date: datetime | None = None
|
||||
|
||||
# Delivery
|
||||
delivery_location: str = ""
|
||||
conditions: str = ""
|
||||
|
||||
# Briefing session
|
||||
has_briefing: bool = False
|
||||
briefing_compulsory: bool = False
|
||||
briefing_venue: str = ""
|
||||
|
||||
# Relationships
|
||||
buyer: TenderBuyer | None = None
|
||||
documents: list[TenderDocument] = field(default_factory=list)
|
||||
|
||||
# Metadata
|
||||
source: str = "etenders" # which portal this came from
|
||||
last_updated: datetime | None = None
|
||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
d = {
|
||||
"ocid": self.ocid,
|
||||
"internal_id": self.internal_id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"status": self.status,
|
||||
"tender_number": self.tender_number,
|
||||
"category": self.category,
|
||||
"tender_type": self.tender_type,
|
||||
"province": self.province,
|
||||
"department": self.department,
|
||||
"date_published": self.date_published.isoformat() if self.date_published else None,
|
||||
"closing_date": self.closing_date.isoformat() if self.closing_date else None,
|
||||
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
|
||||
"delivery_location": self.delivery_location,
|
||||
"conditions": self.conditions,
|
||||
"has_briefing": self.has_briefing,
|
||||
"briefing_compulsory": self.briefing_compulsory,
|
||||
"briefing_venue": self.briefing_venue,
|
||||
"source": self.source,
|
||||
"last_updated": self.last_updated.isoformat() if self.last_updated else None,
|
||||
"buyer": self.buyer.to_dict() if self.buyer else None,
|
||||
"documents": [doc.to_dict() for doc in self.documents],
|
||||
"doc_count": len(self.documents),
|
||||
}
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_ocds(cls, release: dict) -> Tender:
|
||||
"""Create a Tender from an OCDS release object."""
|
||||
tender_data = release.get("tender", {})
|
||||
parties = release.get("parties", [])
|
||||
|
||||
# Extract buyer info from parties
|
||||
buyer = None
|
||||
buyer_ref = release.get("buyer")
|
||||
if parties:
|
||||
for party in parties:
|
||||
if "buyer" in (party.get("roles") or []):
|
||||
buyer = TenderBuyer(
|
||||
name=party.get("name", ""),
|
||||
identifier=str(party.get("identifier", {}).get("id", "")) if isinstance(party.get("identifier"), dict) else "",
|
||||
)
|
||||
break
|
||||
|
||||
if not buyer and buyer_ref:
|
||||
buyer = TenderBuyer(name=buyer_ref.get("name", ""))
|
||||
|
||||
# Map OCDS documents
|
||||
docs = [TenderDocument.from_ocds(d) for d in tender_data.get("documents", [])]
|
||||
|
||||
# Also check contracts for documents
|
||||
for contract in release.get("contracts", []):
|
||||
for cdoc in contract.get("documents", []):
|
||||
docs.append(TenderDocument.from_ocds(cdoc))
|
||||
|
||||
tender_period = tender_data.get("tenderPeriod", {})
|
||||
published = _parse_dt(release.get("date"))
|
||||
|
||||
return cls(
|
||||
ocid=release.get("ocid", ""),
|
||||
title=tender_data.get("title", ""),
|
||||
description=tender_data.get("description", ""),
|
||||
status=tender_data.get("status", ""),
|
||||
date_published=published,
|
||||
closing_date=_parse_dt(tender_period.get("endDate")),
|
||||
buyer=buyer,
|
||||
documents=docs,
|
||||
source="etenders_ocds",
|
||||
last_updated=datetime.now(timezone.utc),
|
||||
raw_data=release,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_web_ui(cls, row: dict) -> Tender:
|
||||
"""Create a Tender from a web UI DataTables row."""
|
||||
support_docs = row.get("supportDocument", []) or []
|
||||
|
||||
# Map supporting documents
|
||||
docs = [TenderDocument.from_web_ui(d) for d in support_docs]
|
||||
|
||||
# Build buyer info
|
||||
buyer = TenderBuyer(
|
||||
name=row.get("department", ""),
|
||||
contact_person=row.get("contactPerson", ""),
|
||||
email=row.get("email", ""),
|
||||
telephone=row.get("telephone", ""),
|
||||
fax=row.get("fax", ""),
|
||||
)
|
||||
|
||||
return cls(
|
||||
ocid=row.get("ocid") or "",
|
||||
internal_id=row.get("id"),
|
||||
title=row.get("description", ""),
|
||||
description=row.get("description", ""),
|
||||
status=row.get("status", ""),
|
||||
tender_number=row.get("tender_No", ""),
|
||||
category=row.get("category", ""),
|
||||
tender_type=row.get("type", ""),
|
||||
province=row.get("province", ""),
|
||||
department=row.get("department", ""),
|
||||
date_published=_parse_dt(row.get("date_Published")),
|
||||
closing_date=_parse_dt(row.get("closing_Date")),
|
||||
briefing_date=_parse_dt(row.get("compulsory_briefing_session")),
|
||||
delivery_location=row.get("delivery", ""),
|
||||
conditions=row.get("conditions", ""),
|
||||
has_briefing=bool(row.get("briefingSession")),
|
||||
briefing_compulsory=bool(row.get("briefingCompulsory")),
|
||||
briefing_venue=row.get("briefingVenue") or "",
|
||||
buyer=buyer,
|
||||
documents=docs,
|
||||
source="etenders_web_ui",
|
||||
last_updated=datetime.now(timezone.utc),
|
||||
raw_data=row,
|
||||
)
|
||||
106
src/ocds_client.py
Normal file
106
src/ocds_client.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""eTenders OCDS API client.
|
||||
|
||||
The OCDS API is the primary source for tender discovery. It returns
|
||||
OCDS-compliant JSON releases with tender metadata, buyer info, and at least
|
||||
one document (the main advert). Supporting documents are NOT in the API —
|
||||
they must be fetched via the web UI scraper.
|
||||
|
||||
API docs: https://ocds-api.etenders.gov.za/swagger/
|
||||
Endpoint: https://ocds-api.etenders.gov.za/api/OCDSReleases
|
||||
Params: dateFrom (required), dateTo (required), PageNumber, PageSize
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Iterator
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import OcdsConfig
|
||||
from .models import Tender
|
||||
|
||||
|
||||
class OcdsClient:
|
||||
"""Client for the eTenders OCDS API."""
|
||||
|
||||
def __init__(self, config: OcdsConfig | None = None) -> None:
|
||||
self.config = config or OcdsConfig()
|
||||
self._client = httpx.Client(
|
||||
base_url=self.config.base_url,
|
||||
timeout=self.config.timeout,
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
|
||||
def __enter__(self) -> OcdsClient:
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def fetch_releases(
|
||||
self,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
) -> Iterator[list[dict[str, Any]]]:
|
||||
"""Fetch OCDS releases for a date range, yielding pages.
|
||||
|
||||
Args:
|
||||
date_from: Start date (YYYY-MM-DD or ISO 8601)
|
||||
date_to: End date (YYYY-MM-DD or ISO 8601)
|
||||
|
||||
Yields:
|
||||
Lists of OCDS release dicts (one list per page).
|
||||
"""
|
||||
page_number = 1
|
||||
|
||||
while True:
|
||||
params = {
|
||||
"dateFrom": date_from,
|
||||
"dateTo": date_to,
|
||||
"PageNumber": page_number,
|
||||
"PageSize": self.config.page_size,
|
||||
}
|
||||
|
||||
response = self._client.get("", params=params)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
releases = data.get("releases", [])
|
||||
|
||||
if not releases:
|
||||
break
|
||||
|
||||
yield releases
|
||||
|
||||
# Check for next page
|
||||
next_link = data.get("links", {}).get("next", "")
|
||||
if not next_link:
|
||||
break
|
||||
|
||||
page_number += 1
|
||||
time.sleep(self.config.rate_limit_ms / 1000)
|
||||
|
||||
def fetch_tenders(
|
||||
self,
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
) -> Iterator[Tender]:
|
||||
"""Fetch and parse tenders for a date range.
|
||||
|
||||
Convenience wrapper around fetch_releases that yields Tender objects.
|
||||
"""
|
||||
for releases in self.fetch_releases(date_from, date_to):
|
||||
for release in releases:
|
||||
yield Tender.from_ocds(release)
|
||||
|
||||
@staticmethod
|
||||
def date_range(days: int = 7) -> tuple[str, str]:
|
||||
"""Generate a date range for the last N days (up to today)."""
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today - timedelta(days=days)
|
||||
return start.strftime("%Y-%m-%d"), today.strftime("%Y-%m-%d")
|
||||
254
src/pipeline.py
Normal file
254
src/pipeline.py
Normal file
@@ -0,0 +1,254 @@
|
||||
"""Main pipeline: OCDS API → web UI enrichment → database storage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.table import Table as RichTable
|
||||
|
||||
from .config import get_config, ensure_dirs
|
||||
from .db import Database
|
||||
from .models import Tender
|
||||
from .ocds_client import OcdsClient
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
def run_pipeline(
|
||||
date_from: str,
|
||||
date_to: str,
|
||||
enrich_with_web_ui: bool = True,
|
||||
db_path: str | None = None,
|
||||
) -> dict:
|
||||
"""Run the full tender pipeline.
|
||||
|
||||
1. Pull tenders from OCDS API (discovery + metadata)
|
||||
2. Optionally enrich with web UI scraping (full document lists)
|
||||
3. Store everything in SQLite
|
||||
|
||||
Returns:
|
||||
Summary stats dict.
|
||||
"""
|
||||
config = get_config()
|
||||
ensure_dirs()
|
||||
|
||||
db = Database(db_path or str(config.db_path))
|
||||
|
||||
# ── Step 1: OCDS API ──────────────────────────────────────────────────
|
||||
console.print(f"\n[bold cyan]Step 1: Pulling tenders from OCDS API[/]")
|
||||
console.print(f" Date range: {date_from} → {date_to}")
|
||||
|
||||
ocds_tenders: list[Tender] = []
|
||||
total_releases = 0
|
||||
|
||||
with OcdsClient(config.ocds) as client:
|
||||
for page_releases in client.fetch_releases(date_from, date_to):
|
||||
total_releases += len(page_releases)
|
||||
for release in page_releases:
|
||||
tender = Tender.from_ocds(release)
|
||||
ocds_tenders.append(tender)
|
||||
console.print(f" Fetched {total_releases} releases...")
|
||||
|
||||
console.print(f" [green]Total: {len(ocds_tenders)} tenders from OCDS API[/]")
|
||||
|
||||
# ── Step 2: Web UI enrichment (optional) ───────────────────────────────
|
||||
web_ui_rows = []
|
||||
matched = 0
|
||||
|
||||
if enrich_with_web_ui and ocds_tenders:
|
||||
console.print(f"\n[bold cyan]Step 2: Enriching with web UI document lists[/]")
|
||||
console.print(f" This uses Playwright to scrape the eTenders web UI")
|
||||
console.print(f" to get the full supportDocument[] array per tender.")
|
||||
console.print(f" [dim](The OCDS API only returns 1 doc per tender)[/]")
|
||||
|
||||
try:
|
||||
from .web_scraper import WebUiScraper
|
||||
|
||||
with WebUiScraper(config.web_ui) as scraper:
|
||||
web_ui_rows = scraper.fetch_tender_rows(status=1)
|
||||
console.print(f" [green]Fetched {len(web_ui_rows)} rows from web UI[/]")
|
||||
|
||||
# Match and enrich
|
||||
matches = scraper.match_ocds_to_web_ui(ocds_tenders, web_ui_rows)
|
||||
|
||||
for tender in ocds_tenders:
|
||||
row = matches.get(tender.ocid)
|
||||
if row:
|
||||
scraper.enrich_tender_documents(tender, row)
|
||||
matched += 1
|
||||
|
||||
console.print(f" [green]Matched & enriched: {matched}/{len(ocds_tenders)}[/]")
|
||||
|
||||
except ImportError:
|
||||
console.print(f" [yellow]Playwright not installed. Skipping web UI enrichment.[/]")
|
||||
console.print(f" [dim]Install with: pip install playwright && playwright install chromium[/]")
|
||||
except Exception as e:
|
||||
console.print(f" [yellow]Web UI enrichment failed: {e}[/]")
|
||||
console.print(f" [dim]OCDS tenders will be stored without supporting documents.[/]")
|
||||
|
||||
# ── Step 3: Store in database ─────────────────────────────────────────
|
||||
console.print(f"\n[bold cyan]Step 3: Storing in database[/]")
|
||||
|
||||
new_count = 0
|
||||
updated_count = 0
|
||||
|
||||
for tender in ocds_tenders:
|
||||
is_new = db.upsert_tender(tender)
|
||||
if is_new:
|
||||
new_count += 1
|
||||
else:
|
||||
updated_count += 1
|
||||
|
||||
console.print(f" [green]New: {new_count}[/] | [blue]Updated: {updated_count}[/]")
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────
|
||||
stats = db.stats()
|
||||
console.print(f"\n[bold green]✓ Pipeline complete![/]")
|
||||
console.print(f" Total tenders in DB: {stats['total_tenders']}")
|
||||
console.print(f" Active tenders: {stats['active_tenders']}")
|
||||
console.print(f" Total documents: {stats['total_documents']}")
|
||||
console.print(f" Multi-doc tenders: {stats['multi_doc_tenders']}")
|
||||
console.print(f" Departments: {stats['departments']}")
|
||||
console.print(f" Provinces: {stats['provinces']}")
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def show_stats() -> None:
|
||||
"""Show database statistics."""
|
||||
config = get_config()
|
||||
db = Database(str(config.db_path))
|
||||
stats = db.stats()
|
||||
|
||||
table = RichTable(title="Tender Database Statistics")
|
||||
table.add_column("Metric", style="cyan")
|
||||
table.add_column("Count", style="green", justify="right")
|
||||
|
||||
table.add_row("Total Tenders", str(stats["total_tenders"]))
|
||||
table.add_row("Active Tenders", str(stats["active_tenders"]))
|
||||
table.add_row("Total Documents", str(stats["total_documents"]))
|
||||
table.add_row("Multi-doc Tenders", str(stats["multi_doc_tenders"]))
|
||||
table.add_row("Departments", str(stats["departments"]))
|
||||
table.add_row("Provinces", str(stats["provinces"]))
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def list_tenders(
|
||||
status: str | None = None,
|
||||
department: str | None = None,
|
||||
province: str | None = None,
|
||||
category: str | None = None,
|
||||
limit: int = 20,
|
||||
) -> None:
|
||||
"""List tenders from the database."""
|
||||
config = get_config()
|
||||
db = Database(str(config.db_path))
|
||||
tenders = db.list_tenders(
|
||||
status=status, department=department, province=province,
|
||||
category=category, limit=limit,
|
||||
)
|
||||
|
||||
if not tenders:
|
||||
console.print("[yellow]No tenders found.[/]")
|
||||
return
|
||||
|
||||
table = RichTable(title=f"Tenders ({len(tenders)} shown)")
|
||||
table.add_column("OCID", style="dim")
|
||||
table.add_column("Title", max_width=50)
|
||||
table.add_column("Status")
|
||||
table.add_column("Department", max_width=20)
|
||||
table.add_column("Closing")
|
||||
table.add_column("Docs", justify="right")
|
||||
|
||||
for t in tenders:
|
||||
table.add_row(
|
||||
t["ocid"][:20] + "..." if len(t["ocid"]) > 20 else t["ocid"],
|
||||
t["title"][:50] + "..." if len(t["title"]) > 50 else t["title"],
|
||||
t["status"],
|
||||
t["department"][:20] if t["department"] else "",
|
||||
t["closing_date"][:10] if t["closing_date"] else "",
|
||||
str(t["doc_count"]),
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""SA Tender Monitor — pull and monitor SA government tenders."""
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--days", default=7, help="Pull tenders from the last N days")
|
||||
@click.option("--from", "from_date", help="Start date (YYYY-MM-DD)")
|
||||
@click.option("--to", "to_date", help="End date (YYYY-MM-DD)")
|
||||
@click.option("--no-web-ui", is_flag=True, help="Skip web UI document enrichment")
|
||||
@click.option("--db", help="Database path (default: data/tenders.db)")
|
||||
def pull(days: int, from_date: str | None, to_date: str | None, no_web_ui: bool, db: str | None) -> None:
|
||||
"""Pull tenders from the eTenders portal."""
|
||||
if from_date and to_date:
|
||||
date_from, date_to = from_date, to_date
|
||||
else:
|
||||
today = datetime.now(timezone.utc)
|
||||
start = today - timedelta(days=days)
|
||||
date_from = start.strftime("%Y-%m-%d")
|
||||
date_to = today.strftime("%Y-%m-%d")
|
||||
|
||||
run_pipeline(date_from, date_to, enrich_with_web_ui=not no_web_ui, db_path=db)
|
||||
|
||||
|
||||
@cli.command()
|
||||
def stats() -> None:
|
||||
"""Show database statistics."""
|
||||
show_stats()
|
||||
|
||||
|
||||
@cli.command("list")
|
||||
@click.option("--status", help="Filter by status (active, complete, etc.)")
|
||||
@click.option("--department", help="Filter by department")
|
||||
@click.option("--province", help="Filter by province")
|
||||
@click.option("--category", help="Filter by category")
|
||||
@click.option("--limit", default=20, help="Number of tenders to show")
|
||||
def list_cmd(status: str | None, department: str | None, province: str | None,
|
||||
category: str | None, limit: int) -> None:
|
||||
"""List tenders from the database."""
|
||||
list_tenders(status, department, province, category, limit)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("ocid")
|
||||
def show(ocid: str) -> None:
|
||||
"""Show details for a specific tender by OCID."""
|
||||
config = get_config()
|
||||
db = Database(str(config.db_path))
|
||||
tender = db.get_tender(ocid)
|
||||
|
||||
if not tender:
|
||||
console.print(f"[yellow]Tender {ocid} not found.[/]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]Tender: {tender['title']}[/]")
|
||||
console.print(f" OCID: {tender['ocid']}")
|
||||
console.print(f" Status: {tender['status']}")
|
||||
console.print(f" Department: {tender['department']}")
|
||||
console.print(f" Province: {tender['province']}")
|
||||
console.print(f" Published: {tender['date_published']}")
|
||||
console.print(f" Closing: {tender['closing_date']}")
|
||||
console.print(f" Buyer: {tender['buyer_name']}")
|
||||
console.print(f" Contact: {tender['buyer_contact']} ({tender['buyer_email']})")
|
||||
console.print(f" Documents: {tender['doc_count']}")
|
||||
|
||||
for doc in tender.get("documents", []):
|
||||
console.print(f"\n [dim]📄 {doc['title']}[/]")
|
||||
console.print(f" [dim]Type: {doc['document_type']} | Format: {doc['format']}[/]")
|
||||
console.print(f" [dim]URL: {doc['url'][:100]}...[/]")
|
||||
console.print(f" [dim]Source: {doc['source']}[/]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
284
src/web_scraper.py
Normal file
284
src/web_scraper.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""eTenders web UI scraper for fetching supporting documents.
|
||||
|
||||
The OCDS API only returns one document per tender (the main advert). The
|
||||
web UI's internal DataTables endpoint returns each tender row with a
|
||||
``supportDocument[]`` array containing ALL attached files. This module uses
|
||||
Playwright to load the opportunities page, intercept the DataTables AJAX
|
||||
response, and extract the full document list per tender.
|
||||
|
||||
The DataTables endpoint (/Home/PaginatedTenderOpportunities) requires a
|
||||
browser session — it returns empty when called via curl/httpx without
|
||||
proper cookies/session state. Playwright handles this transparently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .config import WebUiConfig
|
||||
from .models import Tender, TenderDocument
|
||||
|
||||
|
||||
# JavaScript injected into the page to intercept DataTables AJAX responses
|
||||
# and extract the raw JSON data (which includes supportDocument[]).
|
||||
INTERCEPT_JS = """
|
||||
(async () => {
|
||||
// Intercept the next DataTables AJAX response
|
||||
return new Promise((resolve) => {
|
||||
// Override XMLHttpRequest to capture the DataTables response
|
||||
const origOpen = XMLHttpRequest.prototype.open;
|
||||
const origSend = XMLHttpRequest.prototype.send;
|
||||
|
||||
XMLHttpRequest.prototype.open = function(method, url) {
|
||||
this._url = url;
|
||||
return origOpen.apply(this, arguments);
|
||||
};
|
||||
|
||||
XMLHttpRequest.prototype.send = function() {
|
||||
const xhr = this;
|
||||
const origOnReady = xhr.onreadystatechange;
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === 4 && xhr._url &&
|
||||
xhr._url.includes('PaginatedTenderOpportunities')) {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
window.__tenderData = data;
|
||||
} catch(e) {
|
||||
window.__tenderData = null;
|
||||
}
|
||||
}
|
||||
if (origOnReady) origOnReady.apply(xhr, arguments);
|
||||
};
|
||||
return origSend.apply(xhr, arguments);
|
||||
};
|
||||
|
||||
// Wait for the data to arrive, with a timeout
|
||||
let attempts = 0;
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
if (window.__tenderData || attempts > 100) {
|
||||
clearInterval(checkInterval);
|
||||
resolve(JSON.stringify(window.__tenderData || {}));
|
||||
}
|
||||
}, 200);
|
||||
});
|
||||
})()
|
||||
"""
|
||||
|
||||
# Alternative: directly read the DataTable API data (if table is already loaded)
|
||||
READ_TABLE_DATA_JS = """
|
||||
(() => {
|
||||
if (typeof table === 'undefined' || !table) {
|
||||
return JSON.stringify({error: 'table not initialized'});
|
||||
}
|
||||
const data = table.rows().data();
|
||||
const rows = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const row = data[i];
|
||||
if (row) rows.push(row);
|
||||
}
|
||||
return JSON.stringify({rows: rows, count: rows.length});
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
class WebUiScraper:
|
||||
"""Scrapes the eTenders web UI for full tender document lists.
|
||||
|
||||
Uses Playwright to load the opportunities page, which triggers the
|
||||
DataTables AJAX call to /Home/PaginatedTenderOpportunities. The response
|
||||
contains each tender row with a supportDocument[] array holding all
|
||||
attached files — something the OCDS API doesn't provide.
|
||||
"""
|
||||
|
||||
def __init__(self, config: WebUiConfig | None = None) -> None:
|
||||
self.config = config or WebUiConfig()
|
||||
self._playwright = None
|
||||
self._browser = None
|
||||
self._page = None
|
||||
|
||||
def start(self) -> None:
|
||||
"""Launch the browser and open the opportunities page."""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
self._playwright = sync_playwright().start()
|
||||
self._browser = self._playwright.chromium.launch(
|
||||
headless=self.config.headless
|
||||
)
|
||||
context = self._browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/126.0.0.0 Safari/537.36"
|
||||
)
|
||||
)
|
||||
self._page = context.new_page()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Close the browser."""
|
||||
if self._browser:
|
||||
self._browser.close()
|
||||
if self._playwright:
|
||||
self._playwright.stop()
|
||||
self._page = None
|
||||
self._browser = None
|
||||
self._playwright = None
|
||||
|
||||
def __enter__(self) -> WebUiScraper:
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, *args: Any) -> None:
|
||||
self.stop()
|
||||
|
||||
def _load_page(self) -> None:
|
||||
"""Navigate to the opportunities page and wait for DataTables to load."""
|
||||
url = f"{self.config.base_url}{self.config.opportunities_path}"
|
||||
self._page.goto(url, wait_until="networkidle", timeout=self.config.timeout_ms)
|
||||
|
||||
# Wait for the DataTable to be populated
|
||||
self._page.wait_for_function(
|
||||
"typeof table !== 'undefined' && table && table.rows().count() > 0",
|
||||
timeout=self.config.timeout_ms,
|
||||
)
|
||||
|
||||
def _get_table_rows(self) -> list[dict[str, Any]]:
|
||||
"""Extract all tender row data from the loaded DataTable."""
|
||||
# Execute JavaScript to read all row data directly from the DataTable API
|
||||
result = self._page.evaluate(READ_TABLE_DATA_JS)
|
||||
data = json.loads(result) if isinstance(result, str) else result
|
||||
|
||||
if "error" in data:
|
||||
raise RuntimeError(f"DataTable not available: {data['error']}")
|
||||
|
||||
return data.get("rows", [])
|
||||
|
||||
def fetch_tender_rows(self, status: int = 1) -> list[dict[str, Any]]:
|
||||
"""Fetch tender rows from the web UI.
|
||||
|
||||
Args:
|
||||
status: 1=Advertised, 2=Awarded, 3=Closed, 4=Cancelled
|
||||
|
||||
Returns:
|
||||
List of raw tender row dicts, each containing a supportDocument[] array.
|
||||
"""
|
||||
if not self._page:
|
||||
raise RuntimeError("Scraper not started. Call start() or use context manager.")
|
||||
|
||||
self._load_page()
|
||||
|
||||
# If a different status is requested, click the corresponding tab
|
||||
if status != 1:
|
||||
tab_selectors = {2: "a:has-text('Awarded')", 3: "a:has-text('Closed')", 4: "a:has-text('Cancelled')"}
|
||||
selector = tab_selectors.get(status)
|
||||
if selector:
|
||||
self._page.click(selector)
|
||||
self._page.wait_for_timeout(2000) # Wait for DataTables to reload
|
||||
|
||||
rows = self._get_table_rows()
|
||||
|
||||
# If there are more pages, paginate through them
|
||||
all_rows = list(rows)
|
||||
total_pages = self._page.evaluate(
|
||||
"() => typeof table !== 'undefined' && table ? table.page.info().pages : 1"
|
||||
)
|
||||
|
||||
current_page = 1
|
||||
while current_page < total_pages:
|
||||
self._page.evaluate("table.page('next').draw(false)")
|
||||
self._page.wait_for_timeout(1500) # Wait for AJAX to complete
|
||||
page_rows = self._get_table_rows()
|
||||
all_rows.extend(page_rows)
|
||||
current_page += 1
|
||||
|
||||
return all_rows
|
||||
|
||||
def enrich_tender_documents(self, tender: Tender, web_ui_row: dict) -> Tender:
|
||||
"""Enrich a Tender with supporting documents from a web UI row.
|
||||
|
||||
The OCDS API only has 1 document (the main advert). The web UI row
|
||||
has the full supportDocument[] array. This merges them, adding any
|
||||
documents from the web UI that aren't already in the tender.
|
||||
"""
|
||||
existing_doc_ids = {d.document_id for d in tender.documents}
|
||||
|
||||
for sd in web_ui_row.get("supportDocument", []) or []:
|
||||
doc = TenderDocument.from_web_ui(sd)
|
||||
if doc.document_id not in existing_doc_ids:
|
||||
tender.documents.append(doc)
|
||||
existing_doc_ids.add(doc.document_id)
|
||||
|
||||
# Also update metadata from the web UI that's not in OCDS
|
||||
if web_ui_row.get("tender_No") and not tender.tender_number:
|
||||
tender.tender_number = web_ui_row["tender_No"]
|
||||
|
||||
if web_ui_row.get("category") and not tender.category:
|
||||
tender.category = web_ui_row["category"]
|
||||
|
||||
if web_ui_row.get("province") and not tender.province:
|
||||
tender.province = web_ui_row["province"]
|
||||
|
||||
if web_ui_row.get("department") and not tender.department:
|
||||
tender.department = web_ui_row["department"]
|
||||
|
||||
if web_ui_row.get("type") and not tender.tender_type:
|
||||
tender.tender_type = web_ui_row["type"]
|
||||
|
||||
if web_ui_row.get("delivery") and not tender.delivery_location:
|
||||
tender.delivery_location = web_ui_row["delivery"]
|
||||
|
||||
if web_ui_row.get("contactPerson") and tender.buyer:
|
||||
tender.buyer.contact_person = web_ui_row["contactPerson"]
|
||||
|
||||
if web_ui_row.get("email") and tender.buyer:
|
||||
tender.buyer.email = web_ui_row["email"]
|
||||
|
||||
if web_ui_row.get("telephone") and tender.buyer:
|
||||
tender.buyer.telephone = web_ui_row["telephone"]
|
||||
|
||||
return tender
|
||||
|
||||
def match_ocds_to_web_ui(
|
||||
self, ocds_tenders: list[Tender], web_ui_rows: list[dict]
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Build a lookup index matching OCDS tenders to web UI rows.
|
||||
|
||||
Matches on tender number, title similarity, or internal ID.
|
||||
Returns a dict mapping ocid -> web_ui_row.
|
||||
"""
|
||||
# Build index by tender_No and title
|
||||
by_tender_no: dict[str, dict] = {}
|
||||
by_title: dict[str, dict] = {}
|
||||
by_internal_id: dict[int, dict] = {}
|
||||
|
||||
for row in web_ui_rows:
|
||||
tno = row.get("tender_No", "")
|
||||
if tno:
|
||||
by_tender_no[tno] = row
|
||||
title = row.get("description", "")
|
||||
if title:
|
||||
by_title[title.strip().lower()[:100]] = row
|
||||
iid = row.get("id")
|
||||
if iid:
|
||||
by_internal_id[iid] = row
|
||||
|
||||
matches: dict[str, dict] = {}
|
||||
for tender in ocds_tenders:
|
||||
# Try matching by tender number (if we can extract it from OCDS title)
|
||||
row = None
|
||||
|
||||
# OCDS title often is the tender number
|
||||
if tender.title and tender.title in by_tender_no:
|
||||
row = by_tender_no[tender.title]
|
||||
|
||||
# Try title match
|
||||
if not row and tender.description:
|
||||
key = tender.description.strip().lower()[:100]
|
||||
row = by_title.get(key)
|
||||
|
||||
if row:
|
||||
matches[tender.ocid] = row
|
||||
|
||||
return matches
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
232
tests/test_ocds_client.py
Normal file
232
tests/test_ocds_client.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""Tests for the OCDS API client and data models."""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from src.models import Tender, TenderDocument, TenderBuyer
|
||||
from src.ocds_client import OcdsClient
|
||||
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
SAMPLE_OCDS_RELEASE = {
|
||||
"ocid": "ocds-9t57fa-123931",
|
||||
"id": "123931",
|
||||
"date": "2025-06-02T00:00:00Z",
|
||||
"tag": ["tender"],
|
||||
"initiationType": "tender",
|
||||
"tender": {
|
||||
"title": "RFQ2025/135",
|
||||
"status": "complete",
|
||||
"tenderPeriod": {
|
||||
"startDate": "2025-06-02T00:00:00Z",
|
||||
"endDate": "2025-06-03T10:00:00Z",
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"id": "5f928b6e-0be2-47ec-a7fa-39b1bf6d8e0a",
|
||||
"documentType": "basic",
|
||||
"title": "21 BATTALION CLC ADVERT.pdf",
|
||||
"description": "21 BATTALION CLC ADVERT.pdf",
|
||||
"url": "https://www.etenders.gov.za/home/Download?blobName=5f928b6e-0be2-47ec-a7fa-39b1bf6d8e0a.pdf&downloadedFileName=21%20BATT",
|
||||
"datePublished": "2025-06-02T00:00:00Z",
|
||||
"dateModified": "2025-06-02T00:00:00Z",
|
||||
"format": "pdf",
|
||||
"language": "en",
|
||||
}
|
||||
],
|
||||
},
|
||||
"parties": [
|
||||
{
|
||||
"name": "Test Department",
|
||||
"roles": ["buyer"],
|
||||
"identifier": {"id": "TEST-001"},
|
||||
}
|
||||
],
|
||||
"buyer": {"name": "Test Department"},
|
||||
}
|
||||
|
||||
SAMPLE_WEB_UI_ROW = {
|
||||
"id": 160907,
|
||||
"tender_No": "TFR/2026/07/0002/114548/RFI",
|
||||
"description": "NOTICE TO THE MARKET: INVITATION FOR INTERESTED PARTIES",
|
||||
"status": "Published",
|
||||
"category": "Services: Professional",
|
||||
"type": "Request for Information",
|
||||
"province": "Western Cape",
|
||||
"department": "Transnet SOC Ltd",
|
||||
"date_Published": "2026-07-02T00:00:00",
|
||||
"closing_Date": "2026-09-03T23:00:00",
|
||||
"contactPerson": "TRIM Commercial",
|
||||
"email": "noticeandcomments@transnet.net",
|
||||
"telephone": "031-308-8256",
|
||||
"supportDocument": [
|
||||
{
|
||||
"supportDocumentID": "2b32232e-777c-41d1-86e1-8dd2bd0eea6e",
|
||||
"extension": ".pptx",
|
||||
"fileName": "Notice and Comment advert.pptx",
|
||||
"dateModified": "2026-07-02T05:57:24.3088558",
|
||||
"active": True,
|
||||
},
|
||||
{
|
||||
"supportDocumentID": "40efd46e-c7c5-4342-a0f6-f4d1692adbbd",
|
||||
"extension": ".pdf",
|
||||
"fileName": "Notice and Comments Advertisement Form.pdf",
|
||||
"dateModified": "2026-07-02T05:57:24.7810594",
|
||||
"active": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ── Model tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTenderDocument:
|
||||
def test_from_ocds(self):
|
||||
doc_data = SAMPLE_OCDS_RELEASE["tender"]["documents"][0]
|
||||
doc = TenderDocument.from_ocds(doc_data)
|
||||
|
||||
assert doc.document_id == "5f928b6e-0be2-47ec-a7fa-39b1bf6d8e0a"
|
||||
assert doc.title == "21 BATTALION CLC ADVERT.pdf"
|
||||
assert doc.format == "pdf"
|
||||
assert doc.source == "ocds"
|
||||
assert doc.url.startswith("https://www.etenders.gov.za/home/Download")
|
||||
assert doc.download_url == doc.url
|
||||
|
||||
def test_from_web_ui(self):
|
||||
sd = SAMPLE_WEB_UI_ROW["supportDocument"][0]
|
||||
doc = TenderDocument.from_web_ui(sd)
|
||||
|
||||
assert doc.document_id == "2b32232e-777c-41d1-86e1-8dd2bd0eea6e"
|
||||
assert doc.title == "Notice and Comment advert.pptx"
|
||||
assert doc.format == "pptx"
|
||||
assert doc.source == "web_ui"
|
||||
assert doc.extension == ".pptx"
|
||||
assert doc.support_document_id == "2b32232e-777c-41d1-86e1-8dd2bd0eea6e"
|
||||
|
||||
def test_download_url_construction(self):
|
||||
"""Web UI documents construct their own download URL from supportDocumentID."""
|
||||
sd = SAMPLE_WEB_UI_ROW["supportDocument"][0]
|
||||
doc = TenderDocument.from_web_ui(sd)
|
||||
|
||||
expected = (
|
||||
"https://www.etenders.gov.za/home/Download"
|
||||
"?blobName=2b32232e-777c-41d1-86e1-8dd2bd0eea6e.pptx"
|
||||
"&downloadedFileName=Notice and Comment advert.pptx"
|
||||
)
|
||||
assert doc.download_url == expected
|
||||
|
||||
|
||||
class TestTender:
|
||||
def test_from_ocds(self):
|
||||
tender = Tender.from_ocds(SAMPLE_OCDS_RELEASE)
|
||||
|
||||
assert tender.ocid == "ocds-9t57fa-123931"
|
||||
assert tender.title == "RFQ2025/135"
|
||||
assert tender.status == "complete"
|
||||
assert tender.source == "etenders_ocds"
|
||||
assert len(tender.documents) == 1
|
||||
assert tender.documents[0].title == "21 BATTALION CLC ADVERT.pdf"
|
||||
assert tender.buyer is not None
|
||||
assert tender.buyer.name == "Test Department"
|
||||
assert tender.date_published is not None
|
||||
assert tender.closing_date is not None
|
||||
|
||||
def test_from_web_ui(self):
|
||||
tender = Tender.from_web_ui(SAMPLE_WEB_UI_ROW)
|
||||
|
||||
assert tender.internal_id == 160907
|
||||
assert tender.tender_number == "TFR/2026/07/0002/114548/RFI"
|
||||
assert tender.category == "Services: Professional"
|
||||
assert tender.province == "Western Cape"
|
||||
assert tender.department == "Transnet SOC Ltd"
|
||||
assert tender.tender_type == "Request for Information"
|
||||
assert tender.source == "etenders_web_ui"
|
||||
assert len(tender.documents) == 2
|
||||
assert tender.documents[0].file_name == "Notice and Comment advert.pptx"
|
||||
assert tender.documents[1].file_name == "Notice and Comments Advertisement Form.pdf"
|
||||
assert tender.buyer is not None
|
||||
assert tender.buyer.email == "noticeandcomments@transnet.net"
|
||||
|
||||
def test_ocds_to_web_ui_doc_count_difference(self):
|
||||
"""The key test: OCDS gives 1 doc, web UI gives 2 docs for the same tender."""
|
||||
ocds_tender = Tender.from_ocds(SAMPLE_OCDS_RELEASE)
|
||||
web_ui_tender = Tender.from_web_ui(SAMPLE_WEB_UI_ROW)
|
||||
|
||||
assert len(ocds_tender.documents) == 1
|
||||
assert len(web_ui_tender.documents) == 2
|
||||
|
||||
def test_to_dict_serialization(self):
|
||||
tender = Tender.from_ocds(SAMPLE_OCDS_RELEASE)
|
||||
d = tender.to_dict()
|
||||
|
||||
assert d["ocid"] == "ocds-9t57fa-123931"
|
||||
assert d["title"] == "RFQ2025/135"
|
||||
assert d["doc_count"] == 1
|
||||
assert len(d["documents"]) == 1
|
||||
assert d["buyer"]["name"] == "Test Department"
|
||||
|
||||
|
||||
# ── OCDS client tests (mocked) ───────────────────────────────────────────────
|
||||
|
||||
class TestOcdsClient:
|
||||
def test_date_range(self):
|
||||
"""Date range helper returns valid YYYY-MM-DD strings."""
|
||||
date_from, date_to = OcdsClient.date_range(days=7)
|
||||
assert date_from < date_to
|
||||
# Should be valid date strings
|
||||
datetime.strptime(date_from, "%Y-%m-%d")
|
||||
datetime.strptime(date_to, "%Y-%m-%d")
|
||||
|
||||
|
||||
# ── Web UI enrichment tests ──────────────────────────────────────────────────
|
||||
|
||||
class TestWebUiEnrichment:
|
||||
def test_enrichment_merges_documents(self):
|
||||
"""Enriching an OCDS tender with web UI data adds the missing documents."""
|
||||
from src.web_scraper import WebUiScraper
|
||||
|
||||
ocds_tender = Tender.from_ocds(SAMPLE_OCDS_RELEASE)
|
||||
assert len(ocds_tender.documents) == 1 # Only the advert
|
||||
|
||||
scraper = WebUiScraper()
|
||||
# Simulate enrichment with web UI data (different tender, same concept)
|
||||
web_ui_doc = {
|
||||
"supportDocumentID": "new-doc-001",
|
||||
"extension": ".pdf",
|
||||
"fileName": "Additional Document.pdf",
|
||||
"dateModified": "2025-06-01T00:00:00",
|
||||
}
|
||||
ocds_tender = scraper.enrich_tender_documents(
|
||||
ocds_tender, {"supportDocument": [web_ui_doc]}
|
||||
)
|
||||
|
||||
# Should now have 2 documents: the OCDS one + the web UI one
|
||||
assert len(ocds_tender.documents) == 2
|
||||
assert ocds_tender.documents[0].source == "ocds"
|
||||
assert ocds_tender.documents[1].source == "web_ui"
|
||||
assert ocds_tender.documents[1].file_name == "Additional Document.pdf"
|
||||
|
||||
def test_enrichment_does_not_duplicate(self):
|
||||
"""If a document already exists (same ID), it's not added again."""
|
||||
from src.web_scraper import WebUiScraper
|
||||
|
||||
ocds_tender = Tender.from_ocds(SAMPLE_OCDS_RELEASE)
|
||||
assert len(ocds_tender.documents) == 1
|
||||
|
||||
scraper = WebUiScraper()
|
||||
# Add the same document ID as the OCDS one
|
||||
web_ui_doc = {
|
||||
"supportDocumentID": "5f928b6e-0be2-47ec-a7fa-39b1bf6d8e0a",
|
||||
"extension": ".pdf",
|
||||
"fileName": "21 BATTALION CLC ADVERT.pdf",
|
||||
"dateModified": "2025-06-02T00:00:00",
|
||||
}
|
||||
ocds_tender = scraper.enrich_tender_documents(
|
||||
ocds_tender, {"supportDocument": [web_ui_doc]}
|
||||
)
|
||||
|
||||
# Should still be 1 (no duplicate)
|
||||
assert len(ocds_tender.documents) == 1
|
||||
Reference in New Issue
Block a user