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.
Files changed (49) hide show
  1. tenderkit/__init__.py +9 -0
  2. tenderkit/__main__.py +5 -0
  3. tenderkit/cli.py +248 -0
  4. tenderkit/documents.py +260 -0
  5. tenderkit/errors.py +52 -0
  6. tenderkit/http.py +94 -0
  7. tenderkit/interfaces.py +42 -0
  8. tenderkit/limits.py +7 -0
  9. tenderkit/mcp.py +221 -0
  10. tenderkit/model.py +78 -0
  11. tenderkit/pdf_ocr.py +78 -0
  12. tenderkit/platforms/__init__.py +33 -0
  13. tenderkit/platforms/backends.py +163 -0
  14. tenderkit/platforms/base.py +79 -0
  15. tenderkit/platforms/bk.py +78 -0
  16. tenderkit/platforms/ezamowienia.py +85 -0
  17. tenderkit/platforms/html.py +67 -0
  18. tenderkit/platforms/marketplanet_html.py +128 -0
  19. tenderkit/platforms/public_documents.py +206 -0
  20. tenderkit/platforms/pz_html.py +127 -0
  21. tenderkit/platforms/session.py +114 -0
  22. tenderkit/profile.py +216 -0
  23. tenderkit/profiles/__init__.py +13 -0
  24. tenderkit/profiles/leki.toml +5 -0
  25. tenderkit/profiles/oprogramowanie.toml +4 -0
  26. tenderkit/profiles/wyroby-medyczne.toml +4 -0
  27. tenderkit/queue.py +126 -0
  28. tenderkit/read_document.py +309 -0
  29. tenderkit/serialization.py +63 -0
  30. tenderkit/service.py +164 -0
  31. tenderkit/sources/__init__.py +1 -0
  32. tenderkit/sources/bk.py +145 -0
  33. tenderkit/sources/bzp.py +142 -0
  34. tenderkit/sources/mapping.py +264 -0
  35. tenderkit/sources/ted.py +116 -0
  36. tenderkit/sources/ted_xml.py +97 -0
  37. tenderkit/store/__init__.py +1 -0
  38. tenderkit/store/documents.py +168 -0
  39. tenderkit/store/state.py +202 -0
  40. tenderkit/ui.py +243 -0
  41. tenderkit/watch.py +247 -0
  42. tenderkit/web/index.html +59 -0
  43. tenderkit/web/style.css +221 -0
  44. tenderkit-0.1.0.dist-info/METADATA +219 -0
  45. tenderkit-0.1.0.dist-info/RECORD +49 -0
  46. tenderkit-0.1.0.dist-info/WHEEL +5 -0
  47. tenderkit-0.1.0.dist-info/entry_points.txt +2 -0
  48. tenderkit-0.1.0.dist-info/licenses/LICENSE +202 -0
  49. 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