From 02378baa80eaf99b0be86cb36e8e180eb601cde0 Mon Sep 17 00:00:00 2001
From: andreighh <61791875+andreighh@users.noreply.github.com>
Date: Fri, 9 Oct 2026 15:26:27 +0300
Subject: [PATCH 1/3] Fix UNGM scraper after site changes
UNGM's notice search now requires an ASP.NET antiforgery token and the
list/notice markup changed, so the scraper got no results.
- Load the search page and POST in one requests.Session, sending the
__RequestVerificationToken back in the RequestVerificationToken header
- Add the new search payload fields sent by the site
- Use consistent, current Firefox headers for GET and POST
- Add timeouts to every UNGM request, longer backoff between retries
- Pause 2-5s between notice requests and skip notices that fail to load
- Parse list rows by tableCell instead of positional .contents indexes
- Limit notice parsing to #noticeDetail (the menu reuses status-tag)
- Tests and fixtures for the 2026 list and notice pages
---
app/parsers/ungm.py | 35 ++--
app/server_requests.py | 109 ++++++++----
app/tests/parser_files/ungm_notice_2026.html | 155 +++++++++++++++++
.../parser_files/ungm_notice_list_2026.html | 160 ++++++++++++++++++
app/tests/test_ungm_parser.py | 51 ++++++
5 files changed, 466 insertions(+), 44 deletions(-)
create mode 100644 app/tests/parser_files/ungm_notice_2026.html
create mode 100644 app/tests/parser_files/ungm_notice_list_2026.html
diff --git a/app/parsers/ungm.py b/app/parsers/ungm.py
index 40307bb..043d712 100644
--- a/app/parsers/ungm.py
+++ b/app/parsers/ungm.py
@@ -14,7 +14,9 @@
from app.exceptions import UNSPCCodesNotFound
from app.models import UNSPSCCode, WorkerLog, Tender, TenderDocument
-from app.server_requests import get_request_class
+from app.server_requests import (
+ get_request_class, GET_HEADERS, REQUEST_TIMEOUT
+)
from scratch import settings
@@ -65,14 +67,15 @@ def parse_ungm_notice_list(html):
tenders_list = []
for tender in tenders:
+ # Columns: options, title, deadline, published, organization,
+ # type, reference, country
+ cells = tender.find_all('div', class_='tableCell', recursive=False)
+ href = cells[1].a['href'].strip() if cells[1].a else ''
tender_dict = {
'published': UNGMWorker.parse_date(
- tender.contents[7].span.string.strip(), '%d-%b-%Y'),
- 'reference': tender.contents[13].span.string.strip(),
- 'url': (
- endpoint + tender.contents[3].a['href'].strip()
- if tender.contents[3].a['href'].strip() else ''
- ),
+ cells[3].span.get_text().strip(), '%d-%b-%Y'),
+ 'reference': cells[6].span.get_text().strip(),
+ 'url': endpoint + href if href else '',
}
if tender_dict['url'] == 'http://google.com':
# This is a bug that should be investigated
@@ -96,6 +99,9 @@ def parse_ungm_notice(html, url, codes):
the tender is invalid.
"""
soup = BeautifulSoup(html, 'html.parser')
+ # Restrict to the notice itself, the page menu also uses classes
+ # like "status-tag"
+ soup = soup.find(id='noticeDetail') or soup
documents = UNGMWorker.find_by_class(soup, "lnkShowDocument", "a")
description = UNGMWorker.find_by_class(
soup, "ungm-list-item ungm-background", "div")
@@ -175,7 +181,11 @@ def parsed_tenders(self, tenders):
url = tender.url
except AttributeError:
url = tender['url']
+ sleep(randint(2, 5))
html = self.requester.get_request(url)
+ if not html:
+ logging.warning('Skipping UNGM tender %s, request failed.', url)
+ continue
parsed_tender = self.parse_ungm_notice(html, url, codes)
if parsed_tender:
yield parsed_tender
@@ -247,11 +257,14 @@ def update_ungm_tenders(parsed_tenders):
@staticmethod
def download_document(tender_doc):
with TemporaryFile() as content:
- headers = {
- 'User-Agent': 'Mozilla/5.0'
- }
sleep(randint(2, 4))
- response = requests.get(tender_doc.download_url, headers=headers, stream=True)
+ try:
+ response = requests.get(
+ tender_doc.download_url, headers=GET_HEADERS,
+ stream=True, timeout=REQUEST_TIMEOUT)
+ except requests.exceptions.RequestException as e:
+ logging.warning(e)
+ return
if response.status_code == 200:
for chunk in response.iter_content(chunk_size=4096):
content.write(chunk)
diff --git a/app/server_requests.py b/app/server_requests.py
index bd5984b..141c0b0 100644
--- a/app/server_requests.py
+++ b/app/server_requests.py
@@ -7,9 +7,9 @@
from random import randint
from time import sleep
-from urllib.parse import urlparse
from urllib3.exceptions import NewConnectionError
+from bs4 import BeautifulSoup
from django.conf import settings
from app.models import UNSPSCCode
@@ -39,6 +39,11 @@
'NoticeTypes': [],
'Reference': '',
'DeadlineTo': '',
+ 'IsSustainable': False,
+ 'IsActive': True,
+ 'NoticeDisplayType': None,
+ 'NoticeSearchTotalLabelId': 'noticeSearchTotal',
+ 'TypeOfCompetitions': [],
},
'awards': {
'PageIndex': 0,
@@ -63,21 +68,35 @@
}
+# Keep these consistent with a single current browser: UNGM sits behind a
+# bot filter and mismatched or outdated User-Agents get flagged.
+USER_AGENT = (
+ 'Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0'
+)
+
+GET_HEADERS = {
+ 'User-Agent': USER_AGENT,
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+}
+
POST_HEADERS = {
+ 'User-Agent': USER_AGENT,
'Accept': '*/*',
- 'Accept-Encoding': 'gzip, deflate',
- 'Accept-Language': 'en-US,en;q=0.5',
- 'Content-Type': 'application/json; charset=UTF-8',
- 'Host': urlparse(LIVE_ENDPOINT_URI).netloc,
- 'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:31.0)'
- ' Gecko/20100101 Firefox/31.0',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ 'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
+ 'Origin': LIVE_ENDPOINT_URI,
}
-GET_HEADERS = {
- 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
- 'AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36'
-}
+# (connect, read) timeout in seconds for every request made to UNGM
+REQUEST_TIMEOUT = (10, 60)
+
+# Name of the ASP.NET antiforgery field rendered in the page; its value must
+# be sent back in the RequestVerificationToken header, together with the
+# matching cookie the server set on the same session.
+ANTIFORGERY_FIELD = '__RequestVerificationToken'
+ANTIFORGERY_HEADER = 'RequestVerificationToken'
UNSPSC_CODES = UNSPSCCode.objects.values_list("id", flat=True)
@@ -96,12 +115,16 @@ class Requester(object):
def get_request(self, url):
try:
- response = requests.get(url, headers=GET_HEADERS)
- except requests.exceptions.ConnectionError:
+ response = requests.get(
+ url, headers=GET_HEADERS, timeout=REQUEST_TIMEOUT)
+ except requests.exceptions.RequestException as e:
+ logging.warning(e)
return None
if response.status_code == 200:
return response.content
+ logging.warning(
+ 'GET %s failed with status code: %s', url, response.status_code)
return None
def request_document(self, url):
@@ -141,47 +164,67 @@ def request(self, url, last_date, index):
url, url + '/Search', self.get_data(url, last_date, index))
if html:
return html
- sleep(randint(10, 15))
+ sleep(randint(30, 60))
raise RequestsFailedError
- def post_request(
- self, get_url, post_url, data, headers=POST_HEADERS, content_type=None):
+ @staticmethod
+ def get_antiforgery_token(html):
+ soup = BeautifulSoup(html, 'html.parser')
+ field = soup.find('input', {'name': ANTIFORGERY_FIELD})
+ if field and field.get('value'):
+ return field['value']
+ meta = soup.find('meta', {'name': ANTIFORGERY_HEADER})
+ if meta and meta.get('content'):
+ return meta['content']
+ return None
+
+ def post_request(self, get_url, post_url, data, headers=None):
"""
- AJAX-like POST request. Does a GET initially to receive cookies that
- are used to the subsequent POST request.
+ AJAX-like POST request. Loads `get_url` first, in the same session,
+ to receive the cookies and the antiforgery token the POST requires.
Returns HTML, NOT Response object.
"""
+ session = requests.Session()
+ session.cookies.set('UNGM.UserPreferredLanguage', 'en')
try:
- resp = requests.get(get_url, headers=GET_HEADERS)
- except requests.exceptions.ConnectionError as e:
+ resp = session.get(
+ get_url, headers=GET_HEADERS, timeout=REQUEST_TIMEOUT)
+ except requests.exceptions.RequestException as e:
logging.warning(e)
return None
- cookies = dict(resp.cookies)
- cookies.update({'UNGM.UserPreferredLanguage': 'en'})
- headers.update({
- 'Cookie': '; '.join(
- ['{0}={1}'.format(k, v) for k, v in cookies.items()]),
+ if resp.status_code != 200:
+ logging.warning(
+ 'GET %s failed with status code: %s', get_url,
+ resp.status_code)
+ return None
+
+ token = self.get_antiforgery_token(resp.content)
+ if not token:
+ logging.warning('No antiforgery token found on %s', get_url)
+ return None
+
+ post_headers = dict(POST_HEADERS, **(headers or {}))
+ post_headers.update({
+ ANTIFORGERY_HEADER: token,
'Referer': get_url,
- 'Content-Length': str(len(data)),
})
- if content_type:
- headers.update({'Content-Type': content_type})
try:
sleep(randint(2, 5))
- resp = requests.post(post_url, data=data, cookies=cookies,
- headers=headers)
- except requests.exceptions.ConnectionError as e:
+ resp = session.post(
+ post_url, data=data, headers=post_headers,
+ timeout=REQUEST_TIMEOUT)
+ except requests.exceptions.RequestException as e:
logging.warning(e)
return None
-
if resp.status_code == 200:
return resp.content
- logging.warning("Request failed with status code: " + str(resp.status_code))
+ logging.warning(
+ 'POST %s failed with status code: %s', post_url, resp.status_code)
return None
#
diff --git a/app/tests/parser_files/ungm_notice_2026.html b/app/tests/parser_files/ungm_notice_2026.html
new file mode 100644
index 0000000..c28daae
--- /dev/null
+++ b/app/tests/parser_files/ungm_notice_2026.html
@@ -0,0 +1,155 @@
+
+
+
+
+ Procurement opportunities
+
+
+
+
+
+
+
+
+ UNDP
+
+
+
+
+ Fiscal space analysis 4 non-communicable diseases & increased domestic resource
+ Request for proposal
+
+
+
+
+
+ Reference:
+ UNDP-SLE-00324
+
+
+ Beneficiary countries or territories:
+ Sierra Leone
+
+
+
+ Registration level:
+ Basic
+
+
+ Published on:
+ 16-Sep-2026
+
+
+ Deadline on:
+ 07-Oct-2026 14:59 (GMT -4.00)
+
+
+
+
+
+
+
Description
+
RFP- 006/26 - consultancy for a firm to provide Fiscal space analysis for non-communicable diseases and increased domestic resource mobilisation through health taxation