tenderkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tenderkit/__init__.py +9 -0
- tenderkit/__main__.py +5 -0
- tenderkit/cli.py +248 -0
- tenderkit/documents.py +260 -0
- tenderkit/errors.py +52 -0
- tenderkit/http.py +94 -0
- tenderkit/interfaces.py +42 -0
- tenderkit/limits.py +7 -0
- tenderkit/mcp.py +221 -0
- tenderkit/model.py +78 -0
- tenderkit/pdf_ocr.py +78 -0
- tenderkit/platforms/__init__.py +33 -0
- tenderkit/platforms/backends.py +163 -0
- tenderkit/platforms/base.py +79 -0
- tenderkit/platforms/bk.py +78 -0
- tenderkit/platforms/ezamowienia.py +85 -0
- tenderkit/platforms/html.py +67 -0
- tenderkit/platforms/marketplanet_html.py +128 -0
- tenderkit/platforms/public_documents.py +206 -0
- tenderkit/platforms/pz_html.py +127 -0
- tenderkit/platforms/session.py +114 -0
- tenderkit/profile.py +216 -0
- tenderkit/profiles/__init__.py +13 -0
- tenderkit/profiles/leki.toml +5 -0
- tenderkit/profiles/oprogramowanie.toml +4 -0
- tenderkit/profiles/wyroby-medyczne.toml +4 -0
- tenderkit/queue.py +126 -0
- tenderkit/read_document.py +309 -0
- tenderkit/serialization.py +63 -0
- tenderkit/service.py +164 -0
- tenderkit/sources/__init__.py +1 -0
- tenderkit/sources/bk.py +145 -0
- tenderkit/sources/bzp.py +142 -0
- tenderkit/sources/mapping.py +264 -0
- tenderkit/sources/ted.py +116 -0
- tenderkit/sources/ted_xml.py +97 -0
- tenderkit/store/__init__.py +1 -0
- tenderkit/store/documents.py +168 -0
- tenderkit/store/state.py +202 -0
- tenderkit/ui.py +243 -0
- tenderkit/watch.py +247 -0
- tenderkit/web/index.html +59 -0
- tenderkit/web/style.css +221 -0
- tenderkit-0.1.0.dist-info/METADATA +219 -0
- tenderkit-0.1.0.dist-info/RECORD +49 -0
- tenderkit-0.1.0.dist-info/WHEEL +5 -0
- tenderkit-0.1.0.dist-info/entry_points.txt +2 -0
- tenderkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- tenderkit-0.1.0.dist-info/top_level.txt +1 -0
tenderkit/service.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""One application path for CLI and MCP: fetch to SQLite, then filter the same notices."""
|
|
2
|
+
from dataclasses import asdict, replace
|
|
3
|
+
from datetime import date, datetime, timedelta
|
|
4
|
+
from importlib.metadata import entry_points
|
|
5
|
+
import json
|
|
6
|
+
from zoneinfo import ZoneInfo
|
|
7
|
+
|
|
8
|
+
from tenderkit.interfaces import Source
|
|
9
|
+
from tenderkit.profile import fold
|
|
10
|
+
from tenderkit.serialization import dumps, notice_record
|
|
11
|
+
from tenderkit.sources.bzp import Bzp
|
|
12
|
+
from tenderkit.sources.bk import Bk
|
|
13
|
+
from tenderkit.sources.ted import Ted
|
|
14
|
+
|
|
15
|
+
MAX_WINDOW_DAYS = 120
|
|
16
|
+
MAX_NOTICES = 20000
|
|
17
|
+
MAX_RESULTS = 500
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def today():
|
|
21
|
+
return datetime.now(ZoneInfo('Europe/Warsaw')).date()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def window(start=None, end=None):
|
|
25
|
+
end = date.fromisoformat(end) if isinstance(end, str) else end or today()
|
|
26
|
+
start = date.fromisoformat(start) if isinstance(start, str) else start or end - timedelta(days=29)
|
|
27
|
+
if not 0 <= (end - start).days < MAX_WINDOW_DAYS:
|
|
28
|
+
raise ValueError(f'Publication window must contain 1 to {MAX_WINDOW_DAYS} days')
|
|
29
|
+
return start, end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def sources(*, include_bk=False):
|
|
33
|
+
registered = {'BZP': Bzp(), 'TED': Ted()}
|
|
34
|
+
if include_bk:
|
|
35
|
+
registered['BK'] = Bk()
|
|
36
|
+
for entry in entry_points(group='tenderkit.sources'):
|
|
37
|
+
source = entry.load()()
|
|
38
|
+
if not isinstance(source, Source) or source.name in registered:
|
|
39
|
+
raise ValueError(f'Invalid or duplicate source entry point: {entry.name}')
|
|
40
|
+
registered[source.name] = source
|
|
41
|
+
return registered
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh(state, profile, start, end, adapters=None, include_results=False, include_amendments=False):
|
|
45
|
+
start, end = window(start, end)
|
|
46
|
+
adapters = sources() if adapters is None else adapters
|
|
47
|
+
summaries = []
|
|
48
|
+
query = profile.source_query()
|
|
49
|
+
if include_results or include_amendments:
|
|
50
|
+
query = replace(query, include_results=include_results, include_amendments=include_amendments)
|
|
51
|
+
for source, adapter in adapters.items():
|
|
52
|
+
try:
|
|
53
|
+
notices = []
|
|
54
|
+
for notice in adapter.fetch(start, end, query):
|
|
55
|
+
if len(notices) >= MAX_NOTICES:
|
|
56
|
+
raise ValueError('Notice safety limit reached')
|
|
57
|
+
if notice.source != source or notice.published is None or not start <= notice.published <= end:
|
|
58
|
+
raise ValueError('Source returned a notice outside its declared window')
|
|
59
|
+
notices.append(notice)
|
|
60
|
+
state.record_refresh(source, start, end, asdict(query), notices)
|
|
61
|
+
summaries.append(dict(source=source, status='COMPLETED', notices=len(notices)))
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
state.record_refresh(source, start, end, asdict(query), error=type(exc).__name__)
|
|
64
|
+
summaries.append(dict(source=source, status='FAILED', error=type(exc).__name__))
|
|
65
|
+
return summaries
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def filtered(state, profile, *, start=None, end=None, source_names=(), on=None):
|
|
69
|
+
if start is not None or end is not None:
|
|
70
|
+
start, end = window(start, end)
|
|
71
|
+
on = on or today()
|
|
72
|
+
for notice in state.notices(start, end, source_names):
|
|
73
|
+
if profile.matches(notice, today=on):
|
|
74
|
+
yield notice
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def search(state, profile, *, start=None, end=None, limit=100, offset=0, source_names=()):
|
|
78
|
+
if type(limit) is not int or not 1 <= limit <= MAX_RESULTS or type(offset) is not int or not 0 <= offset <= MAX_NOTICES:
|
|
79
|
+
raise ValueError('Invalid result limit or offset')
|
|
80
|
+
found, more = [], False
|
|
81
|
+
for index, notice in enumerate(filtered(state, profile, start=start, end=end, source_names=source_names)):
|
|
82
|
+
if index < offset:
|
|
83
|
+
continue
|
|
84
|
+
if len(found) == limit:
|
|
85
|
+
more = True
|
|
86
|
+
break
|
|
87
|
+
found.append(matched_notice_record(notice, profile))
|
|
88
|
+
return dict(notices=found, offset=offset, next_offset=offset + len(found) if more else None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def matched_notice_record(notice, profile, *, raw=False):
|
|
92
|
+
record = notice_record(notice, raw=raw)
|
|
93
|
+
if profile.keywords_any:
|
|
94
|
+
record['keyword_matches'] = profile.keyword_matches(notice)
|
|
95
|
+
return record
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _award_record(award, notice):
|
|
99
|
+
record = json_roundtrip(asdict(award))
|
|
100
|
+
return dict(record, source=notice.source, number=notice.number,
|
|
101
|
+
published=str(notice.published) if notice.published else None,
|
|
102
|
+
title=notice.title, buyer_name=notice.buyer_name)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def json_roundtrip(value):
|
|
106
|
+
return json.loads(dumps(value))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def latest_awards(notices):
|
|
110
|
+
"""Latest formal result per source, procedure and lot; a later notice replaces an earlier one."""
|
|
111
|
+
latest = {}
|
|
112
|
+
for notice in sorted(notices, key=lambda n: (n.published or date.min, n.number)):
|
|
113
|
+
if not notice.awards:
|
|
114
|
+
continue
|
|
115
|
+
key = notice.procedure_id or notice.number
|
|
116
|
+
for award in notice.awards:
|
|
117
|
+
latest[(notice.source, key, award.lot)] = (award, notice)
|
|
118
|
+
return [latest[key] for key in sorted(latest, key=lambda k: (k[0], k[1], k[2]))]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def procedure_results(state, source, number):
|
|
122
|
+
"""One procedure's contract and result notices, linked by procedure_id, with the latest award per lot."""
|
|
123
|
+
notice = state.get(source, number)
|
|
124
|
+
if notice is None:
|
|
125
|
+
raise ValueError('Notice not found in local state')
|
|
126
|
+
related = [notice]
|
|
127
|
+
if notice.procedure_id:
|
|
128
|
+
related = [n for n in state.notices(sources=(source,)) if n.procedure_id == notice.procedure_id]
|
|
129
|
+
if not any(n.number == notice.number for n in related):
|
|
130
|
+
related.append(notice)
|
|
131
|
+
related.sort(key=lambda n: (n.published or date.min, n.number))
|
|
132
|
+
return dict(
|
|
133
|
+
notice=notice_record(notice, raw=False),
|
|
134
|
+
procedure_id=notice.procedure_id,
|
|
135
|
+
contracts=[notice_record(n, raw=False) for n in related if not n.awards],
|
|
136
|
+
results=[notice_record(n, raw=False) for n in related if n.awards],
|
|
137
|
+
awards=[_award_record(award, owner) for award, owner in latest_awards(related)])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def search_results(state, *, buyer=None, cpv=(), start=None, end=None, limit=100, offset=0, source_names=()):
|
|
141
|
+
"""Earlier awards by buyer text and CPV, latest per procedure and lot, paginated like notice search."""
|
|
142
|
+
if type(limit) is not int or not 1 <= limit <= MAX_RESULTS or type(offset) is not int or not 0 <= offset <= MAX_NOTICES:
|
|
143
|
+
raise ValueError('Invalid result limit or offset')
|
|
144
|
+
if start is not None or end is not None:
|
|
145
|
+
start, end = window(start, end)
|
|
146
|
+
patterns = tuple(str(code).strip() for code in cpv)
|
|
147
|
+
if any(not pattern or not (pattern.rstrip('*').isdigit()) for pattern in patterns):
|
|
148
|
+
raise ValueError('Result CPV must be digits, optionally ending in *')
|
|
149
|
+
folded_buyer = fold(buyer) if buyer else None
|
|
150
|
+
candidates = []
|
|
151
|
+
for notice in state.notices(start, end, source_names):
|
|
152
|
+
if not notice.awards:
|
|
153
|
+
continue
|
|
154
|
+
if folded_buyer and folded_buyer not in fold(notice.buyer_name):
|
|
155
|
+
continue
|
|
156
|
+
if patterns and not any(
|
|
157
|
+
str(code).startswith(pattern[:-1]) if pattern.endswith('*') else str(code) == pattern
|
|
158
|
+
for pattern in patterns for code in notice.cpv):
|
|
159
|
+
continue
|
|
160
|
+
candidates.append(notice)
|
|
161
|
+
awards = [_award_record(award, owner) for award, owner in latest_awards(candidates)]
|
|
162
|
+
page = awards[offset:offset + limit]
|
|
163
|
+
return dict(awards=page, offset=offset, total=len(awards),
|
|
164
|
+
next_offset=offset + len(page) if offset + len(page) < len(awards) else None)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Public notice sources. Network clients can be supplied for offline testing."""
|
tenderkit/sources/bk.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Bounded reads of public BK2021 notices from the portal's backend."""
|
|
2
|
+
from datetime import date, datetime, timezone
|
|
3
|
+
import re
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
from tenderkit.model import Lot, Notice
|
|
7
|
+
from tenderkit.platforms.session import BK_HOST, PlatformSession
|
|
8
|
+
from tenderkit.sources.mapping import CPV, amount, html_to_text, instant
|
|
9
|
+
|
|
10
|
+
BASE = f'https://{BK_HOST}'
|
|
11
|
+
WARSAW = ZoneInfo('Europe/Warsaw')
|
|
12
|
+
NOTICE_TYPE = 'BKAnnouncement'
|
|
13
|
+
STATUSES = ('PUBLISHED', 'CLOSED', 'RESOLVED', 'CANCELLED')
|
|
14
|
+
MAX_TOTAL = 1000
|
|
15
|
+
# Publication-date ordering can repeat records across page boundaries.
|
|
16
|
+
# Request the bounded batch in one response when the portal permits it.
|
|
17
|
+
PAGE_SIZE = MAX_TOTAL
|
|
18
|
+
CPV_BATCH = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BkIncomplete(RuntimeError):
|
|
22
|
+
"""The returned page or detail did not match the public search."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _data(session, path, params=None):
|
|
26
|
+
response = session.get(BASE + path, params=params, timeout=30)
|
|
27
|
+
response.raise_for_status()
|
|
28
|
+
result = response.json()['data']
|
|
29
|
+
if not isinstance(result, dict):
|
|
30
|
+
raise BkIncomplete('Expected a BK data object')
|
|
31
|
+
return result
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def detail(session, identifier):
|
|
35
|
+
row = _data(session, f'/api/announcements/{identifier}')['advertisement']
|
|
36
|
+
if row['advertisement']['id'] != identifier or type(row['id']) is not int or row['id'] < 1:
|
|
37
|
+
raise BkIncomplete('BK detail does not match the search result')
|
|
38
|
+
return row
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _codes(session, prefixes):
|
|
42
|
+
codes = set()
|
|
43
|
+
for prefix in dict.fromkeys(prefixes):
|
|
44
|
+
data = _data(session, '/api/dictionaries/cpv', {'search_key': prefix, 'request_id': 1})
|
|
45
|
+
rows = data['cpv_items']
|
|
46
|
+
if data['meta']['last_page'] != 1 or data['meta']['total'] != len(rows):
|
|
47
|
+
raise BkIncomplete('Incomplete BK CPV dictionary')
|
|
48
|
+
for row in rows:
|
|
49
|
+
code = row['code']
|
|
50
|
+
if not re.fullmatch(r'\d{8}-\d', code):
|
|
51
|
+
raise BkIncomplete('Invalid BK CPV code')
|
|
52
|
+
if code.startswith(prefix):
|
|
53
|
+
codes.add(code)
|
|
54
|
+
return sorted(codes)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _list(session, start, end, codes):
|
|
58
|
+
params = [('limit', PAGE_SIZE), ('sort', 'publicationDate'),
|
|
59
|
+
('publicationDateRange[from]', str(start)), ('publicationDateRange[to]', str(end))]
|
|
60
|
+
params += [(f'status[{index}]', status) for index, status in enumerate(STATUSES)]
|
|
61
|
+
params += [('cpvItem[]', code) for code in codes]
|
|
62
|
+
found, expected, page = {}, None, 1
|
|
63
|
+
while True:
|
|
64
|
+
data = _data(session, '/api/announcements/search', [*params, ('page', page)])
|
|
65
|
+
total, rows = data['meta']['total'], data['advertisements']
|
|
66
|
+
if (type(total) is not int or not 0 <= total <= MAX_TOTAL
|
|
67
|
+
or expected is not None and total != expected
|
|
68
|
+
or not isinstance(rows, list) or len(rows) != min(PAGE_SIZE, total - len(found))):
|
|
69
|
+
raise BkIncomplete('BK count changed or exceeded the fetch limit; narrow the window')
|
|
70
|
+
expected = total
|
|
71
|
+
for row in rows:
|
|
72
|
+
identifier = row['id']
|
|
73
|
+
published = date.fromisoformat(row['publication_date'])
|
|
74
|
+
if type(identifier) is not int or identifier < 1 or identifier in found or not start <= published <= end:
|
|
75
|
+
raise BkIncomplete('Duplicate or invalid BK search result')
|
|
76
|
+
found[identifier] = row
|
|
77
|
+
if len(found) == total:
|
|
78
|
+
return found
|
|
79
|
+
page += 1
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def bk_notice(summary, row, acquired):
|
|
83
|
+
identifier = summary['id']
|
|
84
|
+
published = date.fromisoformat(row['publication_date'])
|
|
85
|
+
if published != date.fromisoformat(summary['publication_date']):
|
|
86
|
+
raise BkIncomplete('BK publication date changed during the fetch')
|
|
87
|
+
descriptions, lots = [], []
|
|
88
|
+
orders = row['orders']
|
|
89
|
+
for order in orders:
|
|
90
|
+
codes = []
|
|
91
|
+
for item in order['order_items']:
|
|
92
|
+
descriptions.append(html_to_text(item.get('description')).strip())
|
|
93
|
+
codes.extend(match for cpv in item['cpv_items'] for match in CPV.findall(cpv['code']))
|
|
94
|
+
estimate = amount(order.get('estimated_value'))
|
|
95
|
+
lots.append(Lot(str(order['id']), title=order.get('title'), cpv=tuple(dict.fromkeys(codes)),
|
|
96
|
+
value=estimate, currency='PLN' if estimate is not None else None))
|
|
97
|
+
# A single order's estimate can be used directly. Variants and partial orders
|
|
98
|
+
# cannot be added safely without an explicit whole-notice total.
|
|
99
|
+
value = lots[0].value if len(orders) == 1 and not orders[0].get('is_variant') else None
|
|
100
|
+
deadline = instant(row.get('submission_deadline'))
|
|
101
|
+
if deadline is not None and deadline.tzinfo is None:
|
|
102
|
+
deadline = deadline.replace(tzinfo=WARSAW)
|
|
103
|
+
address = row.get('advertiser_address_details') or {}
|
|
104
|
+
url = f'{BASE}/ogloszenia/{identifier}'
|
|
105
|
+
return Notice(
|
|
106
|
+
'BK', str(identifier), procedure_id=str(identifier), title=row['title'], published=published,
|
|
107
|
+
deadline=deadline, buyer_name=summary.get('advertiser_name') or address.get('name'),
|
|
108
|
+
buyer_city=(address.get('address') or {}).get('locality'),
|
|
109
|
+
cpv=tuple(dict.fromkeys(code for lot in lots for code in lot.cpv)), lots=tuple(lots),
|
|
110
|
+
description='\n\n'.join(dict.fromkeys(text for text in descriptions if text)) or None,
|
|
111
|
+
value=value, currency='PLN' if value is not None else None, url=url, platform_url=url,
|
|
112
|
+
reference=row['advertisement'].get('number'), notice_type=NOTICE_TYPE, lot_count=len(lots),
|
|
113
|
+
raw={'version': row['id']}, source_attribution={
|
|
114
|
+
'publisher': 'Ministerstwo Funduszy i Polityki Regionalnej',
|
|
115
|
+
'acquired_at': acquired, 'processing': 'Selected and normalized from the public BK2021 notice',
|
|
116
|
+
'url': url,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Bk:
|
|
121
|
+
name = 'BK'
|
|
122
|
+
|
|
123
|
+
def __init__(self, session=None):
|
|
124
|
+
self.session = session or PlatformSession()
|
|
125
|
+
|
|
126
|
+
def fetch(self, start, end, query):
|
|
127
|
+
if query.notice_types and NOTICE_TYPE not in query.notice_types:
|
|
128
|
+
return
|
|
129
|
+
if any(not re.fullmatch(r'\d{1,8}', prefix) for prefix in query.cpv_prefixes):
|
|
130
|
+
raise ValueError('Invalid BK CPV prefix')
|
|
131
|
+
codes = _codes(self.session, query.cpv_prefixes)
|
|
132
|
+
batches = [codes[i:i + CPV_BATCH] for i in range(0, len(codes), CPV_BATCH)] if query.cpv_prefixes else [()]
|
|
133
|
+
found = {}
|
|
134
|
+
for batch in batches:
|
|
135
|
+
for identifier, row in _list(self.session, start, end, batch).items():
|
|
136
|
+
if identifier in found and any(
|
|
137
|
+
found[identifier].get(key) != row.get(key)
|
|
138
|
+
for key in ('publication_date', 'submission_deadline', 'title', 'advertiser_name')
|
|
139
|
+
):
|
|
140
|
+
raise BkIncomplete('BK search result changed between CPV batches')
|
|
141
|
+
found[identifier] = row
|
|
142
|
+
if len(found) > MAX_TOTAL:
|
|
143
|
+
raise BkIncomplete('BK detail request limit exceeded; narrow the window')
|
|
144
|
+
for identifier, row in found.items():
|
|
145
|
+
yield bk_notice(row, detail(self.session, identifier), datetime.now(timezone.utc).isoformat())
|
tenderkit/sources/bzp.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Bounded BZP pagination shared by ingestion and read-only discovery."""
|
|
2
|
+
from datetime import datetime, timedelta, timezone
|
|
3
|
+
import time
|
|
4
|
+
from zoneinfo import ZoneInfo
|
|
5
|
+
|
|
6
|
+
WARSAW = ZoneInfo('Europe/Warsaw')
|
|
7
|
+
FRESH_NOTICE_TYPES = ('ContractNotice', 'SmallContractNotice', 'CompetitionNotice')
|
|
8
|
+
RESULT_NOTICE_TYPES = ('TenderResultNotice',)
|
|
9
|
+
AMENDMENT_TYPE = 'NoticeUpdateNotice'
|
|
10
|
+
ENDPOINT = 'https://ezamowienia.gov.pl/mo-board/api/v1/notice'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def timestamp(value):
|
|
14
|
+
result = datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
15
|
+
if result.tzinfo is None:
|
|
16
|
+
raise ValueError('Publication timestamp has no timezone')
|
|
17
|
+
return result.astimezone(timezone.utc)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bounds(date_from, date_to):
|
|
21
|
+
if date_from > date_to:
|
|
22
|
+
raise ValueError('Invalid date interval')
|
|
23
|
+
start = datetime.combine(date_from, datetime.min.time(), WARSAW)
|
|
24
|
+
end = datetime.combine(date_to + timedelta(days=1), datetime.min.time(), WARSAW)
|
|
25
|
+
return start.astimezone(timezone.utc), end.astimezone(timezone.utc)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FetchFailure(RuntimeError):
|
|
29
|
+
def __init__(self, message, audit, notices):
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
self.audit, self.notices = audit, notices
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def fetch_window(session, date_from, date_to, kind, cpv_code='336', page_size=500,
|
|
35
|
+
delay=0.3, observed_at=None, save_page=None, max_pages=100,
|
|
36
|
+
retries=3):
|
|
37
|
+
"""Fetch broad API date bounds, then enforce exact Warsaw bounds locally.
|
|
38
|
+
|
|
39
|
+
save_page is called before validation, preserving malformed/error evidence.
|
|
40
|
+
Failed windows carry partial notices but must never be marked complete.
|
|
41
|
+
"""
|
|
42
|
+
if not 1 <= page_size <= 500 or max_pages < 1 or retries < 1:
|
|
43
|
+
raise ValueError('Invalid pagination configuration')
|
|
44
|
+
start, end = bounds(date_from, date_to)
|
|
45
|
+
observed_at = observed_at or datetime.now(timezone.utc)
|
|
46
|
+
if observed_at.tzinfo is None:
|
|
47
|
+
raise ValueError('Observation timestamp has no timezone')
|
|
48
|
+
cutoff = min(end, observed_at)
|
|
49
|
+
params = dict(NoticeType=kind, PageSize=page_size,
|
|
50
|
+
PublicationDateFrom=start.strftime('%Y-%m-%dT00:00:00'),
|
|
51
|
+
PublicationDateTo=end.strftime('%Y-%m-%dT23:59:59'))
|
|
52
|
+
if cpv_code:
|
|
53
|
+
params['CpvCode'] = cpv_code
|
|
54
|
+
audit = dict(notice_type=kind, from_date=str(date_from), to_date=str(date_to),
|
|
55
|
+
start_inclusive=start.isoformat(), end_exclusive=end.isoformat(),
|
|
56
|
+
observed_at=observed_at.isoformat(), pages=0, returned_unique=0,
|
|
57
|
+
included=0, duplicates=0, outside_window=0, complete=False,
|
|
58
|
+
provisional=date_to >= observed_at.astimezone(WARSAW).date())
|
|
59
|
+
seen, accepted = {}, {}
|
|
60
|
+
try:
|
|
61
|
+
for page_number in range(1, max_pages + 1):
|
|
62
|
+
for attempt in range(retries):
|
|
63
|
+
if delay:
|
|
64
|
+
time.sleep(delay * (2 ** attempt))
|
|
65
|
+
try:
|
|
66
|
+
response = session.get(ENDPOINT, params=params,
|
|
67
|
+
headers={'Accept': 'application/json'}, timeout=40)
|
|
68
|
+
response.raise_for_status()
|
|
69
|
+
page = response.json()
|
|
70
|
+
break
|
|
71
|
+
except Exception:
|
|
72
|
+
if attempt == retries - 1:
|
|
73
|
+
raise
|
|
74
|
+
if save_page:
|
|
75
|
+
save_page(kind, page_number, dict(params), page)
|
|
76
|
+
audit['pages'] += 1
|
|
77
|
+
if not isinstance(page, list):
|
|
78
|
+
raise ValueError('Expected notice array, not an empty/error object')
|
|
79
|
+
before = len(seen)
|
|
80
|
+
for notice in page:
|
|
81
|
+
if not isinstance(notice, dict) or not notice.get('objectId'):
|
|
82
|
+
raise ValueError('Notice is missing objectId')
|
|
83
|
+
nid = notice['objectId']
|
|
84
|
+
if nid in seen:
|
|
85
|
+
if seen[nid] != notice:
|
|
86
|
+
raise ValueError('Conflicting content for the same objectId')
|
|
87
|
+
audit['duplicates'] += 1
|
|
88
|
+
continue
|
|
89
|
+
seen[nid] = notice
|
|
90
|
+
audit['returned_unique'] = len(seen)
|
|
91
|
+
if notice.get('noticeType') != kind:
|
|
92
|
+
raise ValueError('Unexpected notice type')
|
|
93
|
+
published = timestamp(notice['publicationDate'])
|
|
94
|
+
if not start <= published < cutoff:
|
|
95
|
+
audit['outside_window'] += 1
|
|
96
|
+
continue
|
|
97
|
+
accepted[nid] = notice
|
|
98
|
+
audit['included'] = len(accepted)
|
|
99
|
+
if len(page) < page_size:
|
|
100
|
+
audit['complete'] = not audit['provisional']
|
|
101
|
+
audit['fetch_succeeded'] = True
|
|
102
|
+
return list(accepted.values()), audit
|
|
103
|
+
cursor = page[-1]['objectId']
|
|
104
|
+
if len(seen) == before or cursor == params.get('SearchAfter'):
|
|
105
|
+
raise ValueError('Pagination stalled')
|
|
106
|
+
params['SearchAfter'] = cursor
|
|
107
|
+
raise ValueError('Pagination safety limit reached')
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
audit.update(error_type=type(exc).__name__, fetch_succeeded=False,
|
|
110
|
+
http_status=getattr(getattr(exc, 'response', None), 'status_code', None))
|
|
111
|
+
# Avoid printing connection strings or response bodies from exceptions.
|
|
112
|
+
raise FetchFailure(type(exc).__name__, audit, list(accepted.values())) from exc
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Bzp:
|
|
116
|
+
name = 'BZP'
|
|
117
|
+
|
|
118
|
+
def __init__(self, session=None, delay=2.1):
|
|
119
|
+
from tenderkit.http import Session
|
|
120
|
+
self.session, self.delay = session or Session(), delay
|
|
121
|
+
|
|
122
|
+
def fetch(self, start, end, query):
|
|
123
|
+
from tenderkit.sources.mapping import bzp_amendment, bzp_notice
|
|
124
|
+
known = FRESH_NOTICE_TYPES + RESULT_NOTICE_TYPES
|
|
125
|
+
kinds = tuple(k for k in query.notice_types if k in known) if query.notice_types else FRESH_NOTICE_TYPES
|
|
126
|
+
if query.include_results:
|
|
127
|
+
kinds = kinds + tuple(k for k in RESULT_NOTICE_TYPES if k not in kinds)
|
|
128
|
+
found = {}
|
|
129
|
+
for kind in kinds:
|
|
130
|
+
for prefix in query.cpv_prefixes or ('',):
|
|
131
|
+
rows, _ = fetch_window(self.session, start, end, kind, cpv_code=prefix, delay=self.delay)
|
|
132
|
+
for raw in rows:
|
|
133
|
+
notice = bzp_notice(raw)
|
|
134
|
+
found[notice.number] = notice
|
|
135
|
+
if query.include_amendments and any(kind in FRESH_NOTICE_TYPES for kind in kinds):
|
|
136
|
+
# Amendment records do not carry CPV codes. Keep the reference and changed deadline.
|
|
137
|
+
rows, _ = fetch_window(self.session, start, end, AMENDMENT_TYPE, cpv_code='', delay=self.delay)
|
|
138
|
+
for raw in rows:
|
|
139
|
+
notice = bzp_amendment(raw)
|
|
140
|
+
if notice is not None:
|
|
141
|
+
found[notice.number] = notice
|
|
142
|
+
return list(found.values())
|