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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Public attachments on e-ProPublico, LogIntrade and eB2B."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import ntpath
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
import tempfile
|
|
8
|
+
from urllib.parse import unquote, urljoin, urlsplit
|
|
9
|
+
|
|
10
|
+
from tenderkit.limits import MAX_BYTES, MAX_LISTED
|
|
11
|
+
from tenderkit.model import DocumentRef, PlatformLink
|
|
12
|
+
from tenderkit.platforms.backends import FetchError
|
|
13
|
+
from tenderkit.platforms.base import extension
|
|
14
|
+
from tenderkit.platforms.html import Tree
|
|
15
|
+
from tenderkit.platforms.session import eb2b_file_redirect, tenant_url
|
|
16
|
+
|
|
17
|
+
UUID = r'[a-fA-F0-9]{8}(?:-[a-fA-F0-9]{4}){3}-[a-fA-F0-9]{12}'
|
|
18
|
+
INTEGER_ID = r'[1-9][0-9]*'
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PublicDocuments:
|
|
22
|
+
def __init__(self, session):
|
|
23
|
+
self.session = session
|
|
24
|
+
|
|
25
|
+
def matches(self, url):
|
|
26
|
+
return tenant_url(url, self.domain)
|
|
27
|
+
|
|
28
|
+
def procedure(self, notice):
|
|
29
|
+
url = notice.platform_url or ''
|
|
30
|
+
if not self.matches(url):
|
|
31
|
+
return None
|
|
32
|
+
match = self.procedure_path.fullmatch(unquote(urlsplit(url).path).rstrip('/'))
|
|
33
|
+
if match is None:
|
|
34
|
+
raise FetchError('NO_REFERENCE')
|
|
35
|
+
return PlatformLink(self.name, urljoin(url, match[0]), match[1])
|
|
36
|
+
|
|
37
|
+
def _base(self, link):
|
|
38
|
+
if link.platform != self.name or not self.matches(link.url):
|
|
39
|
+
raise FetchError('UNSAFE_URL')
|
|
40
|
+
return 'https://' + urlsplit(link.url).hostname
|
|
41
|
+
|
|
42
|
+
def _reference(self, page, href, name, **kwargs):
|
|
43
|
+
url = urljoin(page, href)
|
|
44
|
+
parts = urlsplit(url)
|
|
45
|
+
match = self.file_path.fullmatch(unquote(parts.path))
|
|
46
|
+
if (not self.matches(url) or parts.hostname != urlsplit(page).hostname
|
|
47
|
+
or parts.query or parts.fragment or not match):
|
|
48
|
+
raise FetchError('UNSAFE_URL')
|
|
49
|
+
if not name:
|
|
50
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
51
|
+
return DocumentRef(name=name, url=url, key=match[1], **kwargs)
|
|
52
|
+
|
|
53
|
+
def download(self, ref, to):
|
|
54
|
+
self_ref = self._reference(ref.url, ref.url, ref.name)
|
|
55
|
+
if self_ref.key != ref.key:
|
|
56
|
+
raise FetchError('UNSAFE_URL')
|
|
57
|
+
response = self.session.get(ref.url, stream=True, timeout=60,
|
|
58
|
+
headers={'Accept': 'application/octet-stream, */*'})
|
|
59
|
+
size = 0
|
|
60
|
+
digest = hashlib.sha256()
|
|
61
|
+
temporary = None
|
|
62
|
+
try:
|
|
63
|
+
if response.status_code >= 400:
|
|
64
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
65
|
+
if response.url and response.url != ref.url and not eb2b_file_redirect(ref.url, response.url):
|
|
66
|
+
raise FetchError('UNSAFE_URL')
|
|
67
|
+
media = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
|
|
68
|
+
if media in ('text/html', 'application/json'):
|
|
69
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
70
|
+
with tempfile.NamedTemporaryFile(dir=to, suffix='.tmp', delete=False) as stream:
|
|
71
|
+
temporary = Path(stream.name)
|
|
72
|
+
for block in response.iter_content(1 << 16):
|
|
73
|
+
if not size and block.removeprefix(b'\xef\xbb\xbf').lstrip().lower().startswith(
|
|
74
|
+
(b'<!doctype html', b'<html', b'<head', b'<body')):
|
|
75
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
76
|
+
size += len(block)
|
|
77
|
+
if size > MAX_BYTES:
|
|
78
|
+
raise FetchError('TOO_LARGE')
|
|
79
|
+
digest.update(block)
|
|
80
|
+
stream.write(block)
|
|
81
|
+
if not size:
|
|
82
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
83
|
+
suffix = extension(dict(url='', name=ref.name, ext=ref.ext))
|
|
84
|
+
target = Path(to) / f'{digest.hexdigest()}.{suffix}'
|
|
85
|
+
temporary.replace(target)
|
|
86
|
+
return target
|
|
87
|
+
finally:
|
|
88
|
+
response.close()
|
|
89
|
+
if temporary is not None:
|
|
90
|
+
temporary.unlink(missing_ok=True)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class EProPublico(PublicDocuments):
|
|
94
|
+
name = 'e-ProPublico'
|
|
95
|
+
domain = 'e-propublico.pl'
|
|
96
|
+
procedure_path = re.compile(fr'/Ogloszenia/Details/({UUID})', re.I)
|
|
97
|
+
file_path = re.compile(fr'/Dokumenty/Download/({UUID})', re.I)
|
|
98
|
+
|
|
99
|
+
def documents(self, link):
|
|
100
|
+
base = self._base(link)
|
|
101
|
+
match = self.procedure_path.fullmatch(unquote(urlsplit(link.url).path))
|
|
102
|
+
if not match or match[1] != link.key:
|
|
103
|
+
raise FetchError('NO_REFERENCE')
|
|
104
|
+
response = self.session.get(base + '/Ogloszenia/DokumentyZamowienia',
|
|
105
|
+
params={'przetargId': link.key}, timeout=30,
|
|
106
|
+
headers={'Accept': 'text/html'})
|
|
107
|
+
root = Tree(response.text).root
|
|
108
|
+
if not root.find('h5', attr='class', value='section-header'):
|
|
109
|
+
raise FetchError('NOT_FOUND')
|
|
110
|
+
result = []
|
|
111
|
+
for row in root.find_all('tr'):
|
|
112
|
+
anchor = row.find('a', attr='href', value=lambda href: '/dokumenty/download/' in href.lower())
|
|
113
|
+
if anchor:
|
|
114
|
+
cells = row.find_all('td', direct=True)
|
|
115
|
+
result.append(self._reference(base, anchor.attrs['href'], anchor.text(' '),
|
|
116
|
+
ext=cells[2].text() if len(cells) > 2 else None,
|
|
117
|
+
published=cells[4].text() if len(cells) > 4 else None))
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class LogIntrade(PublicDocuments):
|
|
122
|
+
name = 'LogIntrade'
|
|
123
|
+
domain = 'logintrade.net'
|
|
124
|
+
procedure_path = re.compile(fr'/zapytania_email,({INTEGER_ID}),[a-zA-Z0-9_-]+\.html')
|
|
125
|
+
file_path = re.compile(r'/DocumentService,getAttachmentUnlogged,([a-zA-Z0-9_+=/-]+)\.html')
|
|
126
|
+
|
|
127
|
+
def documents(self, link):
|
|
128
|
+
self._base(link)
|
|
129
|
+
match = self.procedure_path.fullmatch(unquote(urlsplit(link.url).path))
|
|
130
|
+
if not match or match[1] != link.key:
|
|
131
|
+
raise FetchError('NO_REFERENCE')
|
|
132
|
+
root = Tree(self.session.get(link.url, timeout=30, headers={'Accept': 'text/html'}).text).root
|
|
133
|
+
attachments = root.find(attr='class', value='attachments')
|
|
134
|
+
if attachments is None:
|
|
135
|
+
raise FetchError('NOT_FOUND')
|
|
136
|
+
result = []
|
|
137
|
+
for row in attachments.find_all(attr='class', value='attachment'):
|
|
138
|
+
anchor = row.find('a', attr='href', value=lambda href: 'getAttachmentUnlogged' in href)
|
|
139
|
+
name = row.find(attr='class', value='attachment-name')
|
|
140
|
+
published = row.find(attr='class', value='attachment-date')
|
|
141
|
+
if anchor and name:
|
|
142
|
+
result.append(self._reference(link.url, anchor.attrs['href'], name.text(' '),
|
|
143
|
+
published=published.text() if published else None))
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Eb2b(PublicDocuments):
|
|
148
|
+
name = 'eB2B'
|
|
149
|
+
domain = 'eb2b.com.pl'
|
|
150
|
+
procedure_path = re.compile(fr'/(?:open-preview-auction|preview-auction|auction)\.html/({INTEGER_ID})(?:/[^/]+)?')
|
|
151
|
+
file_path = re.compile(fr'/auction/auction/download-attachment/id/({INTEGER_ID})')
|
|
152
|
+
|
|
153
|
+
def _data(self, base, route, params):
|
|
154
|
+
payload = self.session.get(base + '/auction/auction/' + route, params=params, timeout=30).json()
|
|
155
|
+
if not isinstance(payload, dict) or payload.get('success') is not True:
|
|
156
|
+
raise FetchError('NOT_FOUND')
|
|
157
|
+
rows = payload.get('data')
|
|
158
|
+
if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
|
|
159
|
+
raise FetchError('NOT_FOUND')
|
|
160
|
+
return rows
|
|
161
|
+
|
|
162
|
+
def procedure(self, notice):
|
|
163
|
+
url = notice.platform_url or ''
|
|
164
|
+
if not self.matches(url):
|
|
165
|
+
return None
|
|
166
|
+
match = self.procedure_path.fullmatch(urlsplit(url).path.rstrip('/'))
|
|
167
|
+
if match:
|
|
168
|
+
return PlatformLink(self.name, url.split('?', 1)[0].split('#', 1)[0].rstrip('/'), match[1])
|
|
169
|
+
if not notice.reference:
|
|
170
|
+
raise FetchError('NO_REFERENCE')
|
|
171
|
+
base = 'https://' + urlsplit(url).hostname
|
|
172
|
+
query = json.dumps([dict(property='auction_number', value=notice.reference)])
|
|
173
|
+
rows = self._data(base, 'list', dict(filter=query, page=1, start=0, limit=25))
|
|
174
|
+
matched = [str(row.get('id')) for row in rows if row.get('auction_number') == notice.reference]
|
|
175
|
+
if len(rows) >= 25 or len(matched) != 1 or not re.fullmatch(INTEGER_ID, matched[0]):
|
|
176
|
+
raise FetchError('NOT_FOUND')
|
|
177
|
+
key = matched[0]
|
|
178
|
+
return PlatformLink(self.name, f'{base}/open-preview-auction.html/{key}', key)
|
|
179
|
+
|
|
180
|
+
def documents(self, link):
|
|
181
|
+
base = self._base(link)
|
|
182
|
+
match = self.procedure_path.fullmatch(urlsplit(link.url).path)
|
|
183
|
+
if not match or match[1] != link.key:
|
|
184
|
+
raise FetchError('NO_REFERENCE')
|
|
185
|
+
result, seen = [], set()
|
|
186
|
+
limit = 100
|
|
187
|
+
for offset in range(0, MAX_LISTED + 1, limit):
|
|
188
|
+
rows = self._data(base, 'list-attachment', dict(id=link.key, leave_date='', deleted=0,
|
|
189
|
+
bidder=0, page=offset // limit + 1, start=offset, limit=limit))
|
|
190
|
+
if len(rows) > limit:
|
|
191
|
+
raise FetchError('TOO_MANY_DOCUMENTS')
|
|
192
|
+
for row in rows:
|
|
193
|
+
key = str(row.get('id'))
|
|
194
|
+
if not re.fullmatch(INTEGER_ID, key) or key in seen:
|
|
195
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
196
|
+
seen.add(key)
|
|
197
|
+
if len(seen) > MAX_LISTED:
|
|
198
|
+
raise FetchError('TOO_MANY_DOCUMENTS')
|
|
199
|
+
if (row.get('is_public') is True and row.get('is_active') is True
|
|
200
|
+
and row.get('is_current') is True and row.get('contractor_id') is None):
|
|
201
|
+
result.append(self._reference(base, f'/auction/auction/download-attachment/id/{key}',
|
|
202
|
+
ntpath.basename(row.get('name') or ''), ext=row.get('extension'),
|
|
203
|
+
published=row.get('publication_date'), origin=row.get('type_name')))
|
|
204
|
+
if len(rows) < limit:
|
|
205
|
+
return result
|
|
206
|
+
raise FetchError('TOO_MANY_DOCUMENTS')
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""platformazakupowa.pl public HTML: attachments, messages and offer folders."""
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
from tenderkit.platforms.html import Tree
|
|
6
|
+
|
|
7
|
+
BASE_URL = 'https://platformazakupowa.pl'
|
|
8
|
+
RATE_LIMIT_MARKER = 'Wykryto nietypowy ruch'
|
|
9
|
+
PROCEEDING_STATUSES = ('active', 'ended', 'canceled', 'closed')
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def resolve_transaction_url(org_slug, reference_number, session):
|
|
13
|
+
for status in PROCEEDING_STATUSES:
|
|
14
|
+
response = session.get(f'{BASE_URL}/pn/{org_slug}/proceedings/ajax',
|
|
15
|
+
params=dict(status=status, page=1, query=reference_number, **{'sort-field': 'endsAt', 'sort-type': 'DESC'}), timeout=30)
|
|
16
|
+
response.raise_for_status()
|
|
17
|
+
auctions = response.json().get('auctions') or []
|
|
18
|
+
if len(auctions) == 1:
|
|
19
|
+
return f"{BASE_URL}/transakcja/{auctions[0]['id']}"
|
|
20
|
+
if len(auctions) > 1:
|
|
21
|
+
raise LookupError('Ambiguous procedure reference')
|
|
22
|
+
raise LookupError('Procedure not found')
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_attachments(html):
|
|
26
|
+
table = Tree(html).root.find('table', attr='class', value='all-attachments-table')
|
|
27
|
+
if table is None:
|
|
28
|
+
return []
|
|
29
|
+
body = table.find('tbody') or table
|
|
30
|
+
documents = []
|
|
31
|
+
for row in body.find_all('tr'):
|
|
32
|
+
cells = row.find_all('td')
|
|
33
|
+
link = row.find('a', attr='class', value='proceeding-file-download')
|
|
34
|
+
if len(cells) < 6 or link is None or not link.attrs.get('href'):
|
|
35
|
+
continue
|
|
36
|
+
documents.append(dict(name=cells[0].text(), extension=cells[1].text(), sizeKb=cells[2].text(),
|
|
37
|
+
publishedDate=cells[3].text(' '), source=cells[4].text(), downloadUrl=urljoin(BASE_URL, link.attrs['href'])))
|
|
38
|
+
return documents
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_offers(html):
|
|
42
|
+
root = Tree(html).root
|
|
43
|
+
offers = []
|
|
44
|
+
for head in root.find_all(attr='id', value=lambda v: v.startswith('offer-attachment-accordion-heading-')):
|
|
45
|
+
offer_id = head.attrs['id'].rsplit('-', 1)[-1]
|
|
46
|
+
text = re.sub(r'\s+', ' ', head.text(' '))
|
|
47
|
+
name = re.search(r'Nazwa firmy:\s*(.*?)\s*NIP:', text)
|
|
48
|
+
submitted = re.search(r'Data z[łl]o[żz]enia oferty:\s*([\d-]+ [\d:]+)', text)
|
|
49
|
+
body = root.find(attr='id', value='offer-attachment-accordion-collapse-' + offer_id)
|
|
50
|
+
files = []
|
|
51
|
+
for row in body.find_all('tr') if body else []:
|
|
52
|
+
link = row.find('a', attr='href', value=lambda v: 'get_new' in v)
|
|
53
|
+
cells = row.find_all('td')
|
|
54
|
+
if link is not None and cells:
|
|
55
|
+
files.append(dict(name=cells[0].text(), sizeKb=cells[2].text() if len(cells)>2 else None,
|
|
56
|
+
downloadUrl=urljoin(BASE_URL, link.attrs['href'])))
|
|
57
|
+
offers.append(dict(id=offer_id, name=(name[1].strip(' -') or None) if name else None,
|
|
58
|
+
submitted=submitted[1] if submitted else None, files=files))
|
|
59
|
+
return offers
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_messages(html):
|
|
63
|
+
table = Tree(html).root.find(attr='id', value='comments_info')
|
|
64
|
+
messages = {}
|
|
65
|
+
for row in table.find_all('tr') if table else []:
|
|
66
|
+
cells = row.find_all('td', direct=True)
|
|
67
|
+
stamp = re.search(r'(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})', cells[0].text(' ')) if cells else None
|
|
68
|
+
if stamp and len(cells) >= 3:
|
|
69
|
+
messages[f'{stamp[1]} {stamp[2]}'] = re.sub(r'\s+', ' ', cells[2].text(' ')).strip()[:500]
|
|
70
|
+
return messages
|
|
71
|
+
|
|
72
|
+
OPENING = re.compile(r'zestawienie\s+(z[łl]o[żz]onych\s+|otwartych\s+)?ofert|z\s+otwarcia\s+ofert|protok[oó][łl]\s+z\s+otwarcia|informacja\s+z\s+otwarcia|informacja\s+o\s+z[łl]o[żz]onych\s+ofert|art\.?\s*222\s*(ust\.?\s*)?5|art\.?\s*222_5', re.I)
|
|
73
|
+
|
|
74
|
+
RESULT = re.compile(r'wyb[oó]r|wybor|wynik|uniewa[żz]ni|rozstrzygni|najkorzystniejsz', re.I)
|
|
75
|
+
|
|
76
|
+
NEEDED = re.compile(r'otwarci|zestawieni', re.I)
|
|
77
|
+
|
|
78
|
+
CHECK = re.compile(r'formularz|cenow|ofert|kwot|zestawien|wykonawc|cen[ay]\b', re.I)
|
|
79
|
+
|
|
80
|
+
OFFER_FILE = re.compile(r'\.(zip|7z|rar|pdf|xlsx?|docx?|ods|odt)$', re.I)
|
|
81
|
+
|
|
82
|
+
NOT_OFFER = re.compile(r'swz|specyfikacj|ogłosz|ogloszen|wyjaśni|wyjasni|pytan|odpowied|modyfikac|zmian|umow|kwot|unieważn|protok', re.I)
|
|
83
|
+
|
|
84
|
+
ATTACHED = 'Publiczna Sesja Otwarcia Ofert - Oferta z załącznika '
|
|
85
|
+
|
|
86
|
+
ARCHIVE = re.compile(r'\.(zip|7z|rar)$', re.I)
|
|
87
|
+
|
|
88
|
+
def is_opening(name, text=None):
|
|
89
|
+
return not ARCHIVE.search(name) and bool(OPENING.search(name if text is None else text))
|
|
90
|
+
|
|
91
|
+
def attached_offers(documents):
|
|
92
|
+
"""Mark attachments that are offers when the offers section has no files: published at or after the first
|
|
93
|
+
opening summary, not a letter, notice or SWZ. Each becomes an anonymous offer, identified later by its amounts."""
|
|
94
|
+
if any(d.get('offerId') for d in documents):
|
|
95
|
+
return
|
|
96
|
+
opened = min((d['publishedDate'] or '' for d in documents if d['folder'] == 'Informacja z otwarcia ofert'), default=None)
|
|
97
|
+
if not opened:
|
|
98
|
+
return
|
|
99
|
+
for i, d in enumerate(documents, 1):
|
|
100
|
+
if (d['folder'] is None and (d['publishedDate'] or '') >= opened and OFFER_FILE.search(d['name'])
|
|
101
|
+
and not NOT_OFFER.search(d['name']) and not RESULT.search(d['name']) and not RESULT.search(d.get('message') or '')):
|
|
102
|
+
d['folder'] = ATTACHED + str(i)
|
|
103
|
+
d['attachedOffer'] = True
|
|
104
|
+
|
|
105
|
+
def documents_from_page(html):
|
|
106
|
+
"""Every public document of a transaction page, classified as in the manifest: the attachments table (the opening
|
|
107
|
+
summary gets the folder 'Informacja z otwarcia ofert'), the files of each offer ('Publiczna Sesja Otwarcia Ofert -
|
|
108
|
+
<firma>'; the platform hides the name of most) and, when the offers section has no files, the attachments published
|
|
109
|
+
with the opening summary. Also used by the download queue (tools/fetch_worker.py)."""
|
|
110
|
+
documents = [dict(name=a['name'] if a['name'].lower().endswith('.'+a['extension'].lower()) else f"{a['name']}.{a['extension']}",
|
|
111
|
+
folder='Informacja z otwarcia ofert' if is_opening(a['name']) else None,
|
|
112
|
+
publishedDate=a['publishedDate'], sizeText=a['sizeKb']+' KB', downloadUrl=a['downloadUrl'], kind=a['source'])
|
|
113
|
+
for a in parse_attachments(html)]
|
|
114
|
+
# An attachment shares its publication time with the message it came with.
|
|
115
|
+
messages = parse_messages(html)
|
|
116
|
+
for d in documents:
|
|
117
|
+
message = messages.get(re.sub(r'\s+', ' ', d['publishedDate'] or '').strip())
|
|
118
|
+
if message:
|
|
119
|
+
d['message'] = message
|
|
120
|
+
if d['folder'] is None and is_opening(d['name'], message) and not RESULT.search(message):
|
|
121
|
+
d['folder'] = 'Informacja z otwarcia ofert'
|
|
122
|
+
for offer in parse_offers(html):
|
|
123
|
+
folder = f"Publiczna Sesja Otwarcia Ofert - {offer['name'] or 'Oferta '+offer['id']}"
|
|
124
|
+
documents += [dict(name=f['name'], folder=folder, publishedDate=offer['submitted'], sizeText=f"{f['sizeKb']} KB",
|
|
125
|
+
downloadUrl=f['downloadUrl'], offerId=offer['id'], hiddenName=offer['name'] is None) for f in offer['files']]
|
|
126
|
+
attached_offers(documents)
|
|
127
|
+
return documents
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Cookie-preserving, paced public platform HTTP; redirects stay on platform hosts."""
|
|
2
|
+
from http.cookiejar import CookieJar
|
|
3
|
+
import time
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlsplit, parse_qs
|
|
6
|
+
from urllib.request import build_opener, HTTPCookieProcessor, HTTPRedirectHandler
|
|
7
|
+
|
|
8
|
+
from tenderkit.http import Session
|
|
9
|
+
from tenderkit.platforms.backends import FetchError
|
|
10
|
+
|
|
11
|
+
BK_HOST = 'bazakonkurencyjnosci.funduszeeuropejskie.gov.pl'
|
|
12
|
+
DOMAINS = ('platformazakupowa.pl', 'ezamawiajacy.pl', 'ezamowienia.gov.pl')
|
|
13
|
+
DOCUMENT_DOMAINS = ('e-propublico.pl', 'logintrade.net', 'eb2b.com.pl')
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def tenant_url(url, domain):
|
|
17
|
+
try:
|
|
18
|
+
parts = urlsplit(url)
|
|
19
|
+
host = parts.hostname or ''
|
|
20
|
+
return (parts.scheme == 'https' and parts.username is None and parts.port in (None, 443)
|
|
21
|
+
and (host == domain or host.endswith('.' + domain)))
|
|
22
|
+
except ValueError:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def eb2b_file_redirect(source, target):
|
|
27
|
+
if not tenant_url(source, 'eb2b.com.pl') or not tenant_url(target, 's3.eu-central-1.amazonaws.com'):
|
|
28
|
+
return False
|
|
29
|
+
original, destination = urlsplit(source), urlsplit(target)
|
|
30
|
+
return (re.fullmatch(r'/auction/auction/download-attachment/id/[1-9][0-9]*', original.path) is not None
|
|
31
|
+
and destination.hostname == 'eb2b-platforma.s3.eu-central-1.amazonaws.com'
|
|
32
|
+
and bool(parse_qs(destination.query).get('X-Amz-Signature')))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def bk_platform_url(url):
|
|
36
|
+
try:
|
|
37
|
+
parts = urlsplit(url)
|
|
38
|
+
return (parts.scheme == 'https' and parts.hostname == BK_HOST
|
|
39
|
+
and parts.username is None and parts.port in (None, 443))
|
|
40
|
+
except ValueError:
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def native_platform_url(url):
|
|
45
|
+
try:
|
|
46
|
+
parts = urlsplit(url)
|
|
47
|
+
return (parts.scheme == 'https' and parts.hostname == 'ezamowienia.gov.pl'
|
|
48
|
+
and parts.username is None and parts.port in (None, 443))
|
|
49
|
+
except ValueError:
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def public_platform_url(url):
|
|
54
|
+
if bk_platform_url(url):
|
|
55
|
+
return True
|
|
56
|
+
if any(tenant_url(url, domain) for domain in DOCUMENT_DOMAINS):
|
|
57
|
+
return True
|
|
58
|
+
try:
|
|
59
|
+
parts = urlsplit(url)
|
|
60
|
+
host = (parts.hostname or '').lower()
|
|
61
|
+
return (parts.scheme in ('http', 'https') and not parts.username and not parts.password
|
|
62
|
+
and parts.port in (None, 80, 443)
|
|
63
|
+
and any(host == domain or host.endswith('.' + domain) for domain in DOMAINS))
|
|
64
|
+
except ValueError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PlatformRedirect(HTTPRedirectHandler):
|
|
69
|
+
def redirect_request(self, request, response, code, message, headers, newurl):
|
|
70
|
+
source = request.full_url
|
|
71
|
+
if eb2b_file_redirect(source, newurl):
|
|
72
|
+
return super().redirect_request(request, response, code, message, headers, newurl)
|
|
73
|
+
if urlsplit(source).hostname == 'eb2b-platforma.s3.eu-central-1.amazonaws.com':
|
|
74
|
+
raise FetchError('UNSAFE_URL')
|
|
75
|
+
for domain in DOCUMENT_DOMAINS:
|
|
76
|
+
if tenant_url(source, domain) and (not tenant_url(newurl, domain)
|
|
77
|
+
or urlsplit(source).hostname != urlsplit(newurl).hostname):
|
|
78
|
+
raise FetchError('UNSAFE_URL')
|
|
79
|
+
if bk_platform_url(request.full_url) and not bk_platform_url(newurl):
|
|
80
|
+
raise FetchError('UNSAFE_URL')
|
|
81
|
+
if urlsplit(request.full_url).hostname == 'ezamowienia.gov.pl' and not native_platform_url(newurl):
|
|
82
|
+
raise FetchError('UNSAFE_URL')
|
|
83
|
+
if not public_platform_url(newurl):
|
|
84
|
+
target = urlsplit(newurl)
|
|
85
|
+
query = parse_qs(target.query)
|
|
86
|
+
callback = query.get('redirect_uri', [''])[0]
|
|
87
|
+
anonymous_probe = (
|
|
88
|
+
request.get_method() == 'GET' and urlsplit(request.full_url).hostname in ('platformazakupowa.pl', 'www.platformazakupowa.pl')
|
|
89
|
+
and target.scheme == 'https' and target.hostname == 'accounts.opennexus.com'
|
|
90
|
+
and not target.username and target.port in (None, 443)
|
|
91
|
+
and target.path == '/sso/v1/oauth/authorize' and query.get('prompt') == ['none']
|
|
92
|
+
and public_platform_url(callback) and urlsplit(callback).hostname in ('platformazakupowa.pl', 'www.platformazakupowa.pl'))
|
|
93
|
+
if not anonymous_probe:
|
|
94
|
+
raise FetchError('UNSAFE_URL')
|
|
95
|
+
return super().redirect_request(request, response, code, message, headers, newurl)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PlatformSession(Session):
|
|
99
|
+
def __init__(self, delay=2.1, sleep=time.sleep):
|
|
100
|
+
super().__init__(build_opener(HTTPCookieProcessor(CookieJar()), PlatformRedirect()))
|
|
101
|
+
self.delay, self.sleep = delay, sleep
|
|
102
|
+
|
|
103
|
+
def request(self, method, url, **kwargs):
|
|
104
|
+
if not public_platform_url(url):
|
|
105
|
+
raise FetchError('UNSAFE_URL')
|
|
106
|
+
self.sleep(self.delay)
|
|
107
|
+
response = super().request(method, url, **kwargs)
|
|
108
|
+
if response.url and not (public_platform_url(response.url) or eb2b_file_redirect(url, response.url)):
|
|
109
|
+
response.close()
|
|
110
|
+
raise FetchError('LOGIN_REQUIRED')
|
|
111
|
+
if response.status_code >= 400:
|
|
112
|
+
response.close()
|
|
113
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
114
|
+
return response
|
tenderkit/profile.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""An industry profile: which notices a project wants, as a TOML file (tomllib, standard library).
|
|
2
|
+
|
|
3
|
+
name = "leki"
|
|
4
|
+
cpv = ["336*"] # a code, or a prefix ending in *
|
|
5
|
+
exclude_cpv = ["33696*", "336316*"] # codes that never count, even inside cpv
|
|
6
|
+
keywords_any = ["lek", "farmaceut"] # empty: no keyword condition
|
|
7
|
+
exclude_keywords = ["weterynar"]
|
|
8
|
+
|
|
9
|
+
Keywords match from the start of a word in the title, description or lot titles. Use *term* for a substring.
|
|
10
|
+
Matching is case- and diacritics-insensitive. Profile.matches applies the filters the source cannot handle.
|
|
11
|
+
"""
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from decimal import Decimal, InvalidOperation
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from zoneinfo import ZoneInfo
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
import re
|
|
19
|
+
import tomllib
|
|
20
|
+
import unicodedata
|
|
21
|
+
|
|
22
|
+
from tenderkit.interfaces import SourceQuery
|
|
23
|
+
from tenderkit.model import Notice
|
|
24
|
+
|
|
25
|
+
_CPV = re.compile(r'^\d{1,8}\*?$')
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ValueRange:
|
|
30
|
+
min: Decimal | None = None
|
|
31
|
+
max: Decimal | None = None
|
|
32
|
+
currency: str = 'PLN'
|
|
33
|
+
|
|
34
|
+
def __post_init__(self):
|
|
35
|
+
for name in ('min', 'max'):
|
|
36
|
+
value = getattr(self, name)
|
|
37
|
+
if value is not None:
|
|
38
|
+
try:
|
|
39
|
+
value = Decimal(str(value))
|
|
40
|
+
except InvalidOperation as exc:
|
|
41
|
+
raise ValueError('Invalid value bound') from exc
|
|
42
|
+
if not value.is_finite() or value < 0:
|
|
43
|
+
raise ValueError('Value bounds must be finite and nonnegative')
|
|
44
|
+
object.__setattr__(self, name, value)
|
|
45
|
+
if self.min is not None and self.max is not None and self.min > self.max:
|
|
46
|
+
raise ValueError('Value min exceeds max')
|
|
47
|
+
if not re.fullmatch(r'[A-Z]{3}', self.currency):
|
|
48
|
+
raise ValueError('Currency must be a three-letter code')
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class DeadlineRange:
|
|
53
|
+
min: int = 0
|
|
54
|
+
max: int = 120
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
if type(self.min) is not int or type(self.max) is not int or not 0 <= self.min <= self.max <= 3650:
|
|
58
|
+
raise ValueError('Invalid deadline_days bounds')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fold(text):
|
|
62
|
+
"""Lower case without diacritics ('Leków' -> 'lekow'), the form keywords are matched in."""
|
|
63
|
+
text = unicodedata.normalize('NFKD', (text or '').replace('ł', 'l').replace('Ł', 'L'))
|
|
64
|
+
return ''.join(c for c in text if not unicodedata.combining(c)).casefold()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@lru_cache(maxsize=1024)
|
|
68
|
+
def _keyword_pattern(keyword):
|
|
69
|
+
term = fold(keyword.strip())
|
|
70
|
+
substring = len(term) > 2 and term.startswith('*') and term.endswith('*')
|
|
71
|
+
if substring:
|
|
72
|
+
term = term[1:-1]
|
|
73
|
+
phrase = r'\s+'.join(re.escape(word) for word in term.split())
|
|
74
|
+
return re.compile(('' if substring else r'(?<!\w)') + phrase) if phrase else re.compile(r'(?!)')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _keyword_fields(notice):
|
|
78
|
+
yield 'title', None, notice.title
|
|
79
|
+
for paragraph in (notice.description or '').split('\n\n'):
|
|
80
|
+
yield 'description', None, paragraph
|
|
81
|
+
for lot in notice.lots:
|
|
82
|
+
if lot.title:
|
|
83
|
+
yield 'lot_title', lot.number, lot.title
|
|
84
|
+
for title in notice.lot_titles:
|
|
85
|
+
yield 'lot_title', None, title
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Profile:
|
|
90
|
+
name: str
|
|
91
|
+
cpv: tuple[str, ...] = ()
|
|
92
|
+
exclude_cpv: tuple[str, ...] = ()
|
|
93
|
+
keywords_any: tuple[str, ...] = ()
|
|
94
|
+
exclude_keywords: tuple[str, ...] = ()
|
|
95
|
+
notice_types: tuple[str, ...] = ()
|
|
96
|
+
value: ValueRange | None = None
|
|
97
|
+
regions: tuple[str, ...] = ()
|
|
98
|
+
deadline_days: DeadlineRange | None = None
|
|
99
|
+
exclude_buyers: tuple[str, ...] = ()
|
|
100
|
+
documents: tuple[str, ...] = ()
|
|
101
|
+
|
|
102
|
+
def __post_init__(self):
|
|
103
|
+
if not isinstance(self.name, str) or not self.name.strip():
|
|
104
|
+
raise ValueError('Profile name must be nonempty')
|
|
105
|
+
for name in ('cpv', 'exclude_cpv', 'keywords_any', 'exclude_keywords', 'notice_types', 'regions', 'exclude_buyers', 'documents'):
|
|
106
|
+
values = getattr(self, name)
|
|
107
|
+
if (not isinstance(values, (list, tuple)) or len(values) > 64
|
|
108
|
+
or any(not isinstance(v, str) or not v.strip() or len(v) > 200 for v in values)):
|
|
109
|
+
raise ValueError(f'{name} must be an array of nonempty strings')
|
|
110
|
+
object.__setattr__(self, name, tuple(values))
|
|
111
|
+
bad = [c for c in self.cpv + self.exclude_cpv if not _CPV.match(c)]
|
|
112
|
+
if bad:
|
|
113
|
+
raise ValueError(f'profile {self.name}: CPV must be digits, optionally ending in *: {bad}')
|
|
114
|
+
if self.value is not None and not isinstance(self.value, ValueRange):
|
|
115
|
+
raise ValueError('value must be a ValueRange')
|
|
116
|
+
if self.deadline_days is not None and not isinstance(self.deadline_days, DeadlineRange):
|
|
117
|
+
raise ValueError('deadline_days must be a DeadlineRange')
|
|
118
|
+
if any(not re.fullmatch(r'\d{2}', r) for r in self.regions):
|
|
119
|
+
raise ValueError('regions must contain two-digit TERYT voivodeship codes')
|
|
120
|
+
|
|
121
|
+
def cpv_prefixes(self):
|
|
122
|
+
"""'336*' -> '336'; a full code stays as it is."""
|
|
123
|
+
return tuple(c.rstrip('*') for c in self.cpv)
|
|
124
|
+
|
|
125
|
+
def source_query(self):
|
|
126
|
+
return SourceQuery(cpv_prefixes=self.cpv_prefixes(), notice_types=self.notice_types)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def _cpv_hit(code, patterns):
|
|
130
|
+
return any(code.startswith(p[:-1]) if p.endswith('*') else code == p for p in patterns)
|
|
131
|
+
|
|
132
|
+
def matches_cpv(self, codes):
|
|
133
|
+
"""Whether one of the notice's codes (main or lot) is wanted: inside cpv (or cpv is empty) and not in exclude_cpv.
|
|
134
|
+
A notice whose every code is excluded does not match; one with medicines beside reagents still does."""
|
|
135
|
+
codes = [str(code) for code in codes]
|
|
136
|
+
if self.exclude_cpv:
|
|
137
|
+
kept = [code for code in codes if not self._cpv_hit(code, self.exclude_cpv)]
|
|
138
|
+
if codes and not kept:
|
|
139
|
+
return False
|
|
140
|
+
codes = kept
|
|
141
|
+
return not self.cpv or any(self._cpv_hit(code, self.cpv) for code in codes)
|
|
142
|
+
|
|
143
|
+
def matches_text(self, text):
|
|
144
|
+
return self._matches_keywords((text,))
|
|
145
|
+
|
|
146
|
+
def _matches_keywords(self, texts):
|
|
147
|
+
if not (self.keywords_any or self.exclude_keywords):
|
|
148
|
+
return True
|
|
149
|
+
folded = tuple(fold(text) for text in texts if text)
|
|
150
|
+
if any(_keyword_pattern(term).search(text) for term in self.exclude_keywords for text in folded):
|
|
151
|
+
return False
|
|
152
|
+
return not self.keywords_any or any(
|
|
153
|
+
_keyword_pattern(term).search(text) for term in self.keywords_any for text in folded)
|
|
154
|
+
|
|
155
|
+
def keyword_matches(self, notice):
|
|
156
|
+
"""Return the configured inclusion terms that matched and their source fields."""
|
|
157
|
+
if not self.keywords_any:
|
|
158
|
+
return []
|
|
159
|
+
fields = [(field, lot, fold(text)) for field, lot, text in _keyword_fields(notice) if text]
|
|
160
|
+
hits = dict.fromkeys(
|
|
161
|
+
(term, field, lot)
|
|
162
|
+
for term in self.keywords_any for field, lot, text in fields
|
|
163
|
+
if _keyword_pattern(term).search(text)
|
|
164
|
+
)
|
|
165
|
+
return [dict(keyword=term, field=field, lot=lot) if lot is not None else dict(keyword=term, field=field)
|
|
166
|
+
for term, field, lot in hits]
|
|
167
|
+
|
|
168
|
+
def matches(self, notice: Notice, *, today=None):
|
|
169
|
+
if not self.matches_cpv(notice.cpv):
|
|
170
|
+
return False
|
|
171
|
+
if self.notice_types and notice.notice_type not in self.notice_types:
|
|
172
|
+
return False
|
|
173
|
+
if self.regions and notice.buyer_region not in self.regions:
|
|
174
|
+
return False
|
|
175
|
+
if any(fold(b) in fold(notice.buyer_name) or b == notice.buyer_id for b in self.exclude_buyers):
|
|
176
|
+
return False
|
|
177
|
+
if self.value:
|
|
178
|
+
if notice.value is None or notice.currency != self.value.currency:
|
|
179
|
+
return False
|
|
180
|
+
if self.value.min is not None and notice.value < self.value.min:
|
|
181
|
+
return False
|
|
182
|
+
if self.value.max is not None and notice.value > self.value.max:
|
|
183
|
+
return False
|
|
184
|
+
if self.deadline_days:
|
|
185
|
+
if notice.deadline is None:
|
|
186
|
+
return False
|
|
187
|
+
deadline = notice.deadline.astimezone(ZoneInfo('Europe/Warsaw')) if notice.deadline.tzinfo else notice.deadline
|
|
188
|
+
days = (deadline.date() - (today or datetime.now(ZoneInfo('Europe/Warsaw')).date())).days
|
|
189
|
+
if not self.deadline_days.min <= days <= self.deadline_days.max:
|
|
190
|
+
return False
|
|
191
|
+
return self._matches_keywords(text for _, _, text in _keyword_fields(notice))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_profile(path):
|
|
195
|
+
source = path if hasattr(path, 'read_text') else Path(path) # a path or an importlib.resources file
|
|
196
|
+
data = tomllib.loads(source.read_text(encoding='utf-8'))
|
|
197
|
+
return profile_from_dict(data, origin=str(path))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def profile_from_dict(data, *, origin='profile'):
|
|
201
|
+
unknown = set(data) - set(Profile.__dataclass_fields__)
|
|
202
|
+
if unknown:
|
|
203
|
+
raise ValueError(f'{origin}: unknown profile keys {sorted(unknown)}')
|
|
204
|
+
data = dict(data)
|
|
205
|
+
for key, cls in (('value', ValueRange), ('deadline_days', DeadlineRange)):
|
|
206
|
+
if key in data and data[key] is not None:
|
|
207
|
+
if not isinstance(data[key], dict):
|
|
208
|
+
raise ValueError(f'{key} must be a table')
|
|
209
|
+
try:
|
|
210
|
+
data[key] = cls(**data[key])
|
|
211
|
+
except TypeError as exc:
|
|
212
|
+
raise ValueError(f'Invalid {key} fields') from exc
|
|
213
|
+
try:
|
|
214
|
+
return Profile(**data)
|
|
215
|
+
except TypeError as exc:
|
|
216
|
+
raise ValueError('Invalid profile fields') from exc
|