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,163 @@
|
|
|
1
|
+
"""Transport adapters extracted from pharma; legacy byte APIs remain for compatibility."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from tenderkit.limits import MAX_BYTES, MAX_DOCUMENTS
|
|
7
|
+
|
|
8
|
+
class FetchError(Exception):
|
|
9
|
+
"""A job failure with a short code shown in the panel."""
|
|
10
|
+
def __init__(self, code):
|
|
11
|
+
super().__init__(code)
|
|
12
|
+
self.code = code
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PlatformaZakupowa:
|
|
16
|
+
"""platformazakupowa.pl: the transaction page lists every attachment with a direct download link."""
|
|
17
|
+
label = 'platformazakupowa.pl'
|
|
18
|
+
|
|
19
|
+
# Waits before retrying a throttled request; the first live run was throttled after 20 files 1.5 s apart.
|
|
20
|
+
BACKOFF = (60, 180)
|
|
21
|
+
|
|
22
|
+
def __init__(self, session, delay=2.0, sleep=time.sleep):
|
|
23
|
+
from tenderkit.platforms.pz_html import BASE_URL, RATE_LIMIT_MARKER, resolve_transaction_url
|
|
24
|
+
from tenderkit.platforms.pz_html import documents_from_page
|
|
25
|
+
self.session, self.delay, self.sleep = session, delay, sleep
|
|
26
|
+
self.base, self.marker, self.parse, self.resolve = BASE_URL, RATE_LIMIT_MARKER, documents_from_page, resolve_transaction_url
|
|
27
|
+
|
|
28
|
+
def transaction(self, notice):
|
|
29
|
+
"""The procedure's /transakcja/<id> page; a buyer profile is resolved by the notice's reference."""
|
|
30
|
+
url = notice['platform_url'] or ''
|
|
31
|
+
found = re.search(r'platformazakupowa\.pl/transakcja/(\d+)', url)
|
|
32
|
+
if found:
|
|
33
|
+
return f'{self.base}/transakcja/{found[1]}', False
|
|
34
|
+
profile = re.search(r'platformazakupowa\.pl/pn/([^/?#\s]+)', url)
|
|
35
|
+
if not profile:
|
|
36
|
+
raise FetchError('NOT_FOUND')
|
|
37
|
+
if not notice['reference']:
|
|
38
|
+
# Only the buyer's profile and no reference to search it by (common in TED notices).
|
|
39
|
+
raise FetchError('NO_REFERENCE')
|
|
40
|
+
try:
|
|
41
|
+
return self.resolve(profile[1], notice['reference'], session=self.session), True
|
|
42
|
+
except LookupError:
|
|
43
|
+
raise FetchError('NOT_FOUND')
|
|
44
|
+
|
|
45
|
+
def get(self, url, limit=None):
|
|
46
|
+
"""The body of url; a throttled request is retried after each BACKOFF wait before failing RATE_LIMITED."""
|
|
47
|
+
limit = limit or 20 * 1024 * 1024
|
|
48
|
+
for wait in (0, *self.BACKOFF):
|
|
49
|
+
if wait:
|
|
50
|
+
self.sleep(wait)
|
|
51
|
+
response = self.session.get(url, timeout=60, stream=True)
|
|
52
|
+
try:
|
|
53
|
+
if response.status_code == 429:
|
|
54
|
+
continue
|
|
55
|
+
if response.status_code >= 400:
|
|
56
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
57
|
+
content = bytearray()
|
|
58
|
+
for block in response.iter_content(1 << 16):
|
|
59
|
+
content += block
|
|
60
|
+
if len(content) > limit:
|
|
61
|
+
raise FetchError('TOO_LARGE')
|
|
62
|
+
finally:
|
|
63
|
+
if hasattr(response, 'close'):
|
|
64
|
+
response.close()
|
|
65
|
+
# The platform throttles with HTTP 200 and an HTML notice instead of the file.
|
|
66
|
+
if self.marker not in bytes(content[:4000]).decode('utf-8', errors='ignore'):
|
|
67
|
+
return bytes(content)
|
|
68
|
+
raise FetchError('RATE_LIMITED')
|
|
69
|
+
|
|
70
|
+
def list(self, notice):
|
|
71
|
+
url, resolved = self.transaction(notice)
|
|
72
|
+
return self.documents_at(url, resolved, notice['reference'])
|
|
73
|
+
|
|
74
|
+
def documents_at(self, url, resolved, reference):
|
|
75
|
+
page = self.get(url).decode('utf-8', errors='replace')
|
|
76
|
+
if resolved and key(reference) not in key(page):
|
|
77
|
+
raise FetchError('REFERENCE_MISMATCH')
|
|
78
|
+
# The origin is the folder the reader needs: the opening summary and one folder per offer, else the platform's
|
|
79
|
+
# own section column (the files of offers are listed on the page apart from the attachments table).
|
|
80
|
+
return [dict(name=d['name'], ext=None, url=d['downloadUrl'], key=d['downloadUrl'],
|
|
81
|
+
published=d['publishedDate'], origin=d['folder'] or d.get('kind'),
|
|
82
|
+
metadata={k: d[k] for k in ('offerId', 'hiddenName', 'attachedOffer', 'kind', 'message') if k in d}) for d in self.parse(page)]
|
|
83
|
+
|
|
84
|
+
def download(self, attachment):
|
|
85
|
+
self.sleep(self.delay)
|
|
86
|
+
return self.get(attachment['url'], limit=MAX_BYTES)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class Marketplanet:
|
|
90
|
+
"""eZamawiający (Marketplanet): server-rendered document table on the notice page, with folders for the price
|
|
91
|
+
funding, the opening of offers and the results. Each file downloads by POST with a token valid in this session."""
|
|
92
|
+
label = 'eZamawiający (Marketplanet)'
|
|
93
|
+
NOTICE = re.compile(r'/pn/[^/]+/demand/(?:notice/public/\d+/details|\d+/notice/public/details)')
|
|
94
|
+
REJECTED = ('Strona nieodnaleziona', '<html')
|
|
95
|
+
|
|
96
|
+
def __init__(self, session, delay=2.0, sleep=time.sleep):
|
|
97
|
+
from tenderkit.platforms.marketplanet_html import collect_documents, resolve_notice_url
|
|
98
|
+
self.session, self.delay, self.sleep = session, delay, sleep
|
|
99
|
+
self.collect, self.resolve = collect_documents, resolve_notice_url
|
|
100
|
+
|
|
101
|
+
def transaction(self, notice):
|
|
102
|
+
"""(base, notice page, resolved); a buyer's home page is resolved by the notice's reference."""
|
|
103
|
+
url = (notice['platform_url'] or '').strip()
|
|
104
|
+
parts = re.match(r'(https?://[^/\s]*ezamawiajacy\.pl)(/[^?#\s]*)?', url)
|
|
105
|
+
if not parts:
|
|
106
|
+
raise FetchError('NOT_FOUND')
|
|
107
|
+
base = 'https://' + parts[1].split('://', 1)[1]
|
|
108
|
+
if self.NOTICE.search(parts[2] or ''):
|
|
109
|
+
return base, base + parts[2], False
|
|
110
|
+
if not notice['reference']:
|
|
111
|
+
raise FetchError('NO_REFERENCE')
|
|
112
|
+
found, _ = self.resolve(self.session, base, notice['reference'])
|
|
113
|
+
if not found:
|
|
114
|
+
raise FetchError('NOT_FOUND')
|
|
115
|
+
return base, found, True
|
|
116
|
+
|
|
117
|
+
def list(self, notice):
|
|
118
|
+
base, url, resolved = self.transaction(notice)
|
|
119
|
+
return self.documents_at(url, resolved, notice['reference'])
|
|
120
|
+
|
|
121
|
+
def documents_at(self, url, resolved, reference):
|
|
122
|
+
from urllib.parse import urlsplit
|
|
123
|
+
parts = urlsplit(url)
|
|
124
|
+
base = f'{parts.scheme}://{parts.netloc}'
|
|
125
|
+
if resolved:
|
|
126
|
+
# The search may return a single row with another reference; the page must name ours.
|
|
127
|
+
page = self.session.get(url, timeout=30)
|
|
128
|
+
if page.status_code >= 400:
|
|
129
|
+
raise FetchError(f'HTTP_{page.status_code}')
|
|
130
|
+
if key(reference) not in key(page.text):
|
|
131
|
+
raise FetchError('REFERENCE_MISMATCH')
|
|
132
|
+
return [dict(name=d['name'], ext=None, url=d['downloadUrl'], published=d['publishedDate'],
|
|
133
|
+
origin=d['folder'] or 'Postępowanie',
|
|
134
|
+
key=f"{url}#{d['folder'] or ''}/{d['name']}@{d['publishedDate'] or ''}")
|
|
135
|
+
for d in self.collect(self.session, base, url)]
|
|
136
|
+
|
|
137
|
+
def download(self, attachment):
|
|
138
|
+
self.sleep(self.delay)
|
|
139
|
+
response = self.session.post(attachment['url'], timeout=60, stream=True)
|
|
140
|
+
try:
|
|
141
|
+
if response.status_code >= 400:
|
|
142
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
143
|
+
content = bytearray()
|
|
144
|
+
for block in response.iter_content(1 << 16):
|
|
145
|
+
content += block
|
|
146
|
+
if len(content) > MAX_BYTES:
|
|
147
|
+
raise FetchError('TOO_LARGE')
|
|
148
|
+
finally:
|
|
149
|
+
if hasattr(response, 'close'):
|
|
150
|
+
response.close()
|
|
151
|
+
head = bytes(content[:2000]).decode('utf-8', errors='ignore')
|
|
152
|
+
if 'text/html' in response.headers.get('Content-Type', '') and any(m in head for m in self.REJECTED):
|
|
153
|
+
# An expired token answers with the site's HTML page instead of the file.
|
|
154
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
155
|
+
# Names often lack an extension; the attachment header carries the file name.
|
|
156
|
+
name = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)', response.headers.get('Content-Disposition', ''))
|
|
157
|
+
if name and Path(name[1]).suffix:
|
|
158
|
+
attachment['ext'] = Path(name[1]).suffix
|
|
159
|
+
return bytes(content)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def key(value):
|
|
163
|
+
return re.sub(r'[\W_]+', '', (value or '').casefold())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Core platform contract and compatibility wrapper for existing byte fetchers.
|
|
2
|
+
|
|
3
|
+
The resolved link carries the reference check across procedure() and documents();
|
|
4
|
+
one-time download tokens never become document identities.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
import hashlib
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
from urllib.parse import urlsplit
|
|
11
|
+
|
|
12
|
+
from tenderkit.model import DocumentRef, Notice, PlatformLink
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extension(attachment):
|
|
16
|
+
for candidate in (attachment.get('ext'), Path(attachment['url'].split('?')[0]).suffix, Path(attachment['name']).suffix):
|
|
17
|
+
ext = re.sub(r'[^a-z0-9]', '', (candidate or '').lower())
|
|
18
|
+
if ext:
|
|
19
|
+
return ext[:10]
|
|
20
|
+
return 'bin'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class ResolvedLink(PlatformLink):
|
|
25
|
+
reference: str | None = None
|
|
26
|
+
resolved: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class WorkerPlatform:
|
|
30
|
+
can_download = True
|
|
31
|
+
def __init__(self, backend):
|
|
32
|
+
self.backend = backend
|
|
33
|
+
self.name = backend.label
|
|
34
|
+
|
|
35
|
+
def matches(self, url: str) -> bool:
|
|
36
|
+
from tenderkit.platforms.session import public_platform_url
|
|
37
|
+
if not public_platform_url(url):
|
|
38
|
+
return False
|
|
39
|
+
try:
|
|
40
|
+
parsed = urlsplit(url)
|
|
41
|
+
host = (parsed.hostname or '').lower()
|
|
42
|
+
except ValueError:
|
|
43
|
+
return False
|
|
44
|
+
if parsed.scheme not in ('http', 'https'):
|
|
45
|
+
return False
|
|
46
|
+
if self.name == 'platformazakupowa.pl':
|
|
47
|
+
return host == 'platformazakupowa.pl'
|
|
48
|
+
return host == 'ezamawiajacy.pl' or host.endswith('.ezamawiajacy.pl')
|
|
49
|
+
|
|
50
|
+
def procedure(self, notice: Notice) -> PlatformLink | None:
|
|
51
|
+
if not self.matches(notice.platform_url or ''):
|
|
52
|
+
return None
|
|
53
|
+
transaction = self.backend.transaction(dict(platform_url=notice.platform_url, reference=notice.reference))
|
|
54
|
+
url, resolved = transaction[-2:]
|
|
55
|
+
return ResolvedLink(self.name, url, key=urlsplit(url).path,
|
|
56
|
+
reference=notice.reference, resolved=resolved)
|
|
57
|
+
|
|
58
|
+
def documents(self, link: PlatformLink) -> list[DocumentRef]:
|
|
59
|
+
if link.platform != self.name or not self.matches(link.url):
|
|
60
|
+
raise ValueError('Link belongs to another platform')
|
|
61
|
+
return [DocumentRef(**item) for item in self.backend.documents_at(
|
|
62
|
+
link.url, getattr(link, 'resolved', False), getattr(link, 'reference', None))]
|
|
63
|
+
|
|
64
|
+
def download(self, ref: DocumentRef, to: Path) -> Path:
|
|
65
|
+
from tenderkit.platforms.session import public_platform_url
|
|
66
|
+
from tenderkit.platforms.backends import FetchError
|
|
67
|
+
if not public_platform_url(ref.url):
|
|
68
|
+
raise FetchError('UNSAFE_URL')
|
|
69
|
+
attachment = asdict(ref)
|
|
70
|
+
content = self.backend.download(attachment)
|
|
71
|
+
head = content[:2000].lstrip().lower()
|
|
72
|
+
if Path(ref.name).suffix.lower() not in ('.html', '.htm') and (head.startswith(b'<html') or head.startswith(b'<!doctype html')):
|
|
73
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
74
|
+
# Never use a document-supplied name as a local path. The backend may
|
|
75
|
+
# learn the extension from Content-Disposition while downloading.
|
|
76
|
+
name = f'{hashlib.sha256(content).hexdigest()}.{extension(attachment)}'
|
|
77
|
+
destination = Path(to) / name
|
|
78
|
+
destination.write_bytes(content)
|
|
79
|
+
return destination
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Attachments linked by a public BK2021 notice."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from tenderkit.limits import MAX_BYTES
|
|
8
|
+
from tenderkit.model import DocumentRef, PlatformLink
|
|
9
|
+
from tenderkit.platforms.backends import FetchError
|
|
10
|
+
from tenderkit.platforms.base import extension
|
|
11
|
+
from tenderkit.platforms.session import bk_platform_url
|
|
12
|
+
from tenderkit.sources.bk import BASE, detail
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BkDocuments:
|
|
16
|
+
name = 'Baza Konkurencyjności'
|
|
17
|
+
|
|
18
|
+
def __init__(self, session):
|
|
19
|
+
self.session = session
|
|
20
|
+
|
|
21
|
+
def matches(self, url):
|
|
22
|
+
return bk_platform_url(url) and re.fullmatch(r'/ogloszenia/[1-9]\d*', urlsplit(url).path) is not None
|
|
23
|
+
|
|
24
|
+
def procedure(self, notice):
|
|
25
|
+
if not self.matches(notice.platform_url or ''):
|
|
26
|
+
return None
|
|
27
|
+
key = urlsplit(notice.platform_url).path.rsplit('/', 1)[1]
|
|
28
|
+
return PlatformLink(self.name, f'{BASE}/ogloszenia/{key}', key)
|
|
29
|
+
|
|
30
|
+
def documents(self, link):
|
|
31
|
+
if link.platform != self.name or not re.fullmatch(r'[1-9]\d*', link.key or ''):
|
|
32
|
+
raise FetchError('NOT_FOUND')
|
|
33
|
+
row = detail(self.session, int(link.key))
|
|
34
|
+
refs = []
|
|
35
|
+
for entry in row['attachments']:
|
|
36
|
+
file = entry.get('file') or {}
|
|
37
|
+
key = str(file.get('id'))
|
|
38
|
+
if not re.fullmatch(r'[1-9]\d*', key) or file.get('uri') != f'/api/files/{key}':
|
|
39
|
+
raise FetchError('UNSAFE_URL')
|
|
40
|
+
refs.append(DocumentRef(name=file['name'], key=key, url=f'{BASE}/api/files/{key}',
|
|
41
|
+
published=row['publication_date'], origin='BK announcement'))
|
|
42
|
+
return refs
|
|
43
|
+
|
|
44
|
+
def download(self, ref, to):
|
|
45
|
+
if not re.fullmatch(r'[1-9]\d*', ref.key) or ref.url != f'{BASE}/api/files/{ref.key}':
|
|
46
|
+
raise FetchError('UNSAFE_URL')
|
|
47
|
+
response = self.session.get(ref.url, stream=True, timeout=60,
|
|
48
|
+
headers={'Accept': 'application/octet-stream, */*'})
|
|
49
|
+
digest = hashlib.sha256()
|
|
50
|
+
temporary = Path(to) / f'bk-{ref.key}.tmp'
|
|
51
|
+
size = 0
|
|
52
|
+
try:
|
|
53
|
+
if response.status_code >= 400:
|
|
54
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
55
|
+
if response.url and response.url != ref.url:
|
|
56
|
+
raise FetchError('UNSAFE_URL')
|
|
57
|
+
media = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
|
|
58
|
+
if media in ('text/html', 'application/json'):
|
|
59
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
60
|
+
with temporary.open('wb') as stream:
|
|
61
|
+
for block in response.iter_content(1 << 16):
|
|
62
|
+
if not size and block.removeprefix(b'\xef\xbb\xbf').lstrip().lower().startswith(
|
|
63
|
+
(b'<!doctype html', b'<html', b'<head', b'<body')):
|
|
64
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
65
|
+
size += len(block)
|
|
66
|
+
if size > MAX_BYTES:
|
|
67
|
+
raise FetchError('TOO_LARGE')
|
|
68
|
+
digest.update(block)
|
|
69
|
+
stream.write(block)
|
|
70
|
+
if not size:
|
|
71
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
72
|
+
suffix = extension(dict(url=ref.url, name=ref.name))
|
|
73
|
+
destination = Path(to) / f'{digest.hexdigest()}.{suffix}'
|
|
74
|
+
temporary.replace(destination)
|
|
75
|
+
return destination
|
|
76
|
+
finally:
|
|
77
|
+
response.close()
|
|
78
|
+
temporary.unlink(missing_ok=True)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Public documents attached to native e-Zamówienia procedures."""
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import re
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
from tenderkit.limits import MAX_BYTES
|
|
8
|
+
from tenderkit.model import DocumentRef, PlatformLink
|
|
9
|
+
from tenderkit.platforms.backends import FetchError
|
|
10
|
+
from tenderkit.platforms.base import extension
|
|
11
|
+
from tenderkit.platforms.session import PlatformSession, native_platform_url
|
|
12
|
+
|
|
13
|
+
BASE = 'https://ezamowienia.gov.pl'
|
|
14
|
+
TENDER_ID = r'ocds-148610-[a-fA-F0-9-]{36}'
|
|
15
|
+
OBJECT_ID = r'[a-zA-Z0-9_-]{1,200}'
|
|
16
|
+
DOWNLOAD_PATH = '/mp-readmodels/api/Tender/DownloadDocument'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Ezamowienia:
|
|
20
|
+
name = 'e-Zamówienia (native)'
|
|
21
|
+
can_download = True
|
|
22
|
+
|
|
23
|
+
def __init__(self, session=None):
|
|
24
|
+
self.session = session or PlatformSession()
|
|
25
|
+
|
|
26
|
+
def matches(self, url):
|
|
27
|
+
try:
|
|
28
|
+
parts = urlsplit(url)
|
|
29
|
+
return parts.scheme in ('http', 'https') and parts.hostname == 'ezamowienia.gov.pl' and not parts.username
|
|
30
|
+
except ValueError:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def procedure(self, notice):
|
|
34
|
+
if not self.matches(notice.platform_url or ''):
|
|
35
|
+
return None
|
|
36
|
+
found = re.search(TENDER_ID, notice.platform_url or '')
|
|
37
|
+
identifier = found[0] if found else notice.procedure_id
|
|
38
|
+
if not identifier or not re.fullmatch(TENDER_ID, identifier):
|
|
39
|
+
raise FetchError('NO_REFERENCE')
|
|
40
|
+
return PlatformLink(self.name, f'{BASE}/mp-client/search/list/{identifier}', identifier)
|
|
41
|
+
|
|
42
|
+
def documents(self, link):
|
|
43
|
+
if link.platform != self.name or not re.fullmatch(TENDER_ID, link.key or ''):
|
|
44
|
+
raise FetchError('NOT_FOUND')
|
|
45
|
+
response = self.session.get(f'{BASE}/mp-readmodels/api/Search/GetTenderDocuments', params={'tenderId': link.key}, timeout=30)
|
|
46
|
+
response.raise_for_status()
|
|
47
|
+
rows = response.json()
|
|
48
|
+
if not isinstance(rows, list):
|
|
49
|
+
raise ValueError('Unexpected native document inventory')
|
|
50
|
+
return [DocumentRef(name=row['fileName'], key=row['objectId'], published=row['publishedDate'], origin='Postępowanie',
|
|
51
|
+
url=f"{BASE}{DOWNLOAD_PATH}/{link.key}/{row['objectId']}")
|
|
52
|
+
for row in rows if row.get('publishedDate') and re.fullmatch(OBJECT_ID, row.get('objectId') or '')]
|
|
53
|
+
|
|
54
|
+
def download(self, ref, to):
|
|
55
|
+
if not native_platform_url(ref.url):
|
|
56
|
+
raise FetchError('UNSAFE_URL')
|
|
57
|
+
url = urlsplit(ref.url)
|
|
58
|
+
path = re.fullmatch(f'{DOWNLOAD_PATH}/{TENDER_ID}/({OBJECT_ID})', url.path)
|
|
59
|
+
if not path or path[1] != ref.key or url.query or url.fragment or not ref.published:
|
|
60
|
+
raise FetchError('UNSAFE_URL')
|
|
61
|
+
|
|
62
|
+
response = self.session.get(ref.url, timeout=60, stream=True,
|
|
63
|
+
headers={'Accept': 'application/octet-stream, */*'})
|
|
64
|
+
content = bytearray()
|
|
65
|
+
try:
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
raise FetchError(f'HTTP_{response.status_code}')
|
|
68
|
+
if response.url and not native_platform_url(response.url):
|
|
69
|
+
raise FetchError('UNSAFE_URL')
|
|
70
|
+
if response.headers.get('Content-Type', '').split(';')[0].strip().lower() == 'text/html':
|
|
71
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
72
|
+
for block in response.iter_content(1 << 16):
|
|
73
|
+
if len(content) + len(block) > MAX_BYTES:
|
|
74
|
+
raise FetchError('TOO_LARGE')
|
|
75
|
+
content.extend(block)
|
|
76
|
+
finally:
|
|
77
|
+
response.close()
|
|
78
|
+
|
|
79
|
+
head = content[:2000].removeprefix(b'\xef\xbb\xbf').lstrip().lower()
|
|
80
|
+
if not content or head.startswith((b'<!doctype html', b'<html', b'<head', b'<body')):
|
|
81
|
+
raise FetchError('DOWNLOAD_REJECTED')
|
|
82
|
+
suffix = extension(dict(url=ref.url, name=ref.name, ext=ref.ext))
|
|
83
|
+
destination = Path(to) / f'{hashlib.sha256(content).hexdigest()}.{suffix}'
|
|
84
|
+
destination.write_bytes(content)
|
|
85
|
+
return destination
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Small HTML tree for platform tables; no scripts, styles or browser execution."""
|
|
2
|
+
from html.parser import HTMLParser
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Node:
|
|
6
|
+
def __init__(self, tag='', attrs=()):
|
|
7
|
+
self.tag, self.attrs, self.children = tag, dict(attrs), []
|
|
8
|
+
|
|
9
|
+
def find_all(self, tag=None, *, attr=None, value=None, direct=False):
|
|
10
|
+
found = []
|
|
11
|
+
for child in self.children:
|
|
12
|
+
if not isinstance(child, Node):
|
|
13
|
+
continue
|
|
14
|
+
matches = (tag is None or child.tag == tag) and (attr is None or (
|
|
15
|
+
value(child.attrs.get(attr, '')) if callable(value) else
|
|
16
|
+
value in child.attrs.get(attr, '').split() if attr == 'class' else child.attrs.get(attr) == value))
|
|
17
|
+
if matches:
|
|
18
|
+
found.append(child)
|
|
19
|
+
if not direct:
|
|
20
|
+
found.extend(child.find_all(tag, attr=attr, value=value))
|
|
21
|
+
return found
|
|
22
|
+
|
|
23
|
+
def find(self, tag=None, **kwargs):
|
|
24
|
+
return next(iter(self.find_all(tag, **kwargs)), None)
|
|
25
|
+
|
|
26
|
+
def text(self, separator='', strip=True):
|
|
27
|
+
def strings(node):
|
|
28
|
+
for child in node.children:
|
|
29
|
+
if isinstance(child, str):
|
|
30
|
+
yield child.strip() if strip else child
|
|
31
|
+
elif child.tag not in ('script', 'style'):
|
|
32
|
+
yield from strings(child)
|
|
33
|
+
return separator.join(s for s in strings(self) if s)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Tree(HTMLParser):
|
|
37
|
+
VOID = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'}
|
|
38
|
+
|
|
39
|
+
def __init__(self, html):
|
|
40
|
+
super().__init__(convert_charrefs=True)
|
|
41
|
+
self.root = Node()
|
|
42
|
+
self.stack = [self.root]
|
|
43
|
+
self.feed(html)
|
|
44
|
+
self.close()
|
|
45
|
+
|
|
46
|
+
def handle_starttag(self, tag, attrs):
|
|
47
|
+
# HTML tables often omit the closing tags of adjacent rows/cells.
|
|
48
|
+
if tag in ('tr', 'td', 'th') and self.stack[-1].tag == tag:
|
|
49
|
+
self.stack.pop()
|
|
50
|
+
node = Node(tag, attrs)
|
|
51
|
+
self.stack[-1].children.append(node)
|
|
52
|
+
if tag not in self.VOID:
|
|
53
|
+
self.stack.append(node)
|
|
54
|
+
|
|
55
|
+
def handle_startendtag(self, tag, attrs):
|
|
56
|
+
self.handle_starttag(tag, attrs)
|
|
57
|
+
if tag not in self.VOID:
|
|
58
|
+
self.handle_endtag(tag)
|
|
59
|
+
|
|
60
|
+
def handle_endtag(self, tag):
|
|
61
|
+
for index in range(len(self.stack) - 1, 0, -1):
|
|
62
|
+
if self.stack[index].tag == tag:
|
|
63
|
+
del self.stack[index:]
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
def handle_data(self, data):
|
|
67
|
+
self.stack[-1].children.append(data)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Marketplanet public document tables and profile resolution (no third-party imports)."""
|
|
2
|
+
import re
|
|
3
|
+
from urllib.parse import urljoin
|
|
4
|
+
|
|
5
|
+
ROW_SPLIT_RE = re.compile(r'(?=<tr class="fileDataRow)')
|
|
6
|
+
|
|
7
|
+
NAME_RE = re.compile(r'<td class="text long">\s*(?:<a[^>]*>)?\s*([^<\n]+?)\s*(?:</a>)?\s*</td>', re.DOTALL)
|
|
8
|
+
|
|
9
|
+
DATE_RE = re.compile(r'<td class="date-time">([^<]*)</td>')
|
|
10
|
+
|
|
11
|
+
SIZE_RE = re.compile(r'<td class="text medium">([^<]*)</td>')
|
|
12
|
+
|
|
13
|
+
DOWNLOAD_RE = re.compile(r"downloadPostUrl: '([^']+)'")
|
|
14
|
+
|
|
15
|
+
FOLDER_RE = re.compile(r"enterFolderUrl: '([^']+)'")
|
|
16
|
+
|
|
17
|
+
def parse_rows(html: str) -> list:
|
|
18
|
+
"""Split the notice/folder page into per-document <tr> blocks and pull
|
|
19
|
+
out name/date/size plus either a download token or a nested folder URL."""
|
|
20
|
+
rows = []
|
|
21
|
+
for block in ROW_SPLIT_RE.split(html)[1:]:
|
|
22
|
+
end = block.find("</tr>")
|
|
23
|
+
block = block[:end] if end >= 0 else block
|
|
24
|
+
|
|
25
|
+
name_m = NAME_RE.search(block)
|
|
26
|
+
if not name_m:
|
|
27
|
+
continue
|
|
28
|
+
name = name_m.group(1).strip()
|
|
29
|
+
if not name:
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
date_m = DATE_RE.search(block)
|
|
33
|
+
size_m = SIZE_RE.search(block)
|
|
34
|
+
download_m = DOWNLOAD_RE.search(block)
|
|
35
|
+
folder_m = FOLDER_RE.search(block)
|
|
36
|
+
|
|
37
|
+
rows.append({
|
|
38
|
+
"name": name,
|
|
39
|
+
"publishedDate": date_m.group(1).strip() if date_m else None,
|
|
40
|
+
"sizeText": size_m.group(1).strip() if size_m else None,
|
|
41
|
+
"downloadPath": download_m.group(1) if download_m else None,
|
|
42
|
+
"folderPath": folder_m.group(1) if folder_m else None,
|
|
43
|
+
})
|
|
44
|
+
return rows
|
|
45
|
+
|
|
46
|
+
STATUS_TABS = ["during", "current", "expired"]
|
|
47
|
+
|
|
48
|
+
DATA_ROW_RE = re.compile(r'<tr class="dataRow" id="(\d+)">\s*<td class="short">([^<]*)</td>')
|
|
49
|
+
|
|
50
|
+
ORG_SLUG_RE = re.compile(r'/pn/([^/"]+)/')
|
|
51
|
+
|
|
52
|
+
def resolve_notice_url(session, base_url: str, reference_number: str):
|
|
53
|
+
"""Resolve a bare org homepage URL + reference number to a specific
|
|
54
|
+
notice details URL, by reading the org's real (case-sensitive) slug off
|
|
55
|
+
its home page and then using its own reference-number search across all
|
|
56
|
+
three status tabs. Returns (notice_url, status_tab) or (None, reason)."""
|
|
57
|
+
home = session.get(urljoin(base_url, "/servlet/HomeServlet"), timeout=30)
|
|
58
|
+
home.raise_for_status()
|
|
59
|
+
slug_m = ORG_SLUG_RE.search(home.text)
|
|
60
|
+
if not slug_m:
|
|
61
|
+
return None, "no org slug found on home page"
|
|
62
|
+
slug = slug_m.group(1)
|
|
63
|
+
|
|
64
|
+
for status in STATUS_TABS:
|
|
65
|
+
list_url = urljoin(base_url, f"/pn/{slug}/demand/notice/publicpzp/{status}/list")
|
|
66
|
+
response = session.get(list_url, params={"demandNumber": reference_number, "isFiredBySearchButton": "true"}, timeout=30)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
if "Brak wynik" in response.text:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
rows = DATA_ROW_RE.findall(response.text)
|
|
72
|
+
exact_matches = [row_id for row_id, num in rows if num.strip() == reference_number]
|
|
73
|
+
if len(exact_matches) == 1:
|
|
74
|
+
return urljoin(base_url, f"/pn/{slug}/demand/notice/public/{exact_matches[0]}/details"), status
|
|
75
|
+
if len(rows) == 1:
|
|
76
|
+
return (
|
|
77
|
+
urljoin(base_url, f"/pn/{slug}/demand/notice/public/{rows[0][0]}/details"),
|
|
78
|
+
f"{status} (single row, reference text differs: {rows[0][1]!r})",
|
|
79
|
+
)
|
|
80
|
+
if len(rows) > 1:
|
|
81
|
+
return None, f"{status}: {len(rows)} ambiguous rows matched"
|
|
82
|
+
|
|
83
|
+
return None, "not found in any status tab (current/during/expired)"
|
|
84
|
+
|
|
85
|
+
def collect_documents(session, base_url: str, notice_url: str, max_folder_depth: int = 2) -> list:
|
|
86
|
+
"""Fetch the notice details page and recursively follow one level (or
|
|
87
|
+
two, for safety) of folder rows, returning a flat list of downloadable
|
|
88
|
+
documents. Folder-only "documents" with no nested files (rare) are
|
|
89
|
+
dropped silently.
|
|
90
|
+
|
|
91
|
+
A folder page re-renders the *entire* parent document table plus its own
|
|
92
|
+
extra row(s) appended at the end, rather than just its own contents - so
|
|
93
|
+
documents are deduplicated by name (globally unique in practice within a
|
|
94
|
+
single procedure) to avoid downloading the same main-table files once per
|
|
95
|
+
folder visited."""
|
|
96
|
+
documents = []
|
|
97
|
+
seen_names = set()
|
|
98
|
+
to_visit = [(notice_url, None, 0)]
|
|
99
|
+
seen_urls = set()
|
|
100
|
+
|
|
101
|
+
while to_visit:
|
|
102
|
+
url, folder_name, depth = to_visit.pop(0)
|
|
103
|
+
if url in seen_urls:
|
|
104
|
+
continue
|
|
105
|
+
seen_urls.add(url)
|
|
106
|
+
if len(seen_urls) > 100:
|
|
107
|
+
raise ValueError('Platform folder traversal limit reached')
|
|
108
|
+
|
|
109
|
+
response = session.get(url, timeout=30)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
rows = parse_rows(response.text)
|
|
112
|
+
|
|
113
|
+
for row in rows:
|
|
114
|
+
if row["downloadPath"]:
|
|
115
|
+
if row["name"] in seen_names:
|
|
116
|
+
continue
|
|
117
|
+
seen_names.add(row["name"])
|
|
118
|
+
documents.append({
|
|
119
|
+
"name": row["name"],
|
|
120
|
+
"folder": folder_name,
|
|
121
|
+
"publishedDate": row["publishedDate"],
|
|
122
|
+
"sizeText": row["sizeText"],
|
|
123
|
+
"downloadUrl": urljoin(base_url, row["downloadPath"]),
|
|
124
|
+
})
|
|
125
|
+
elif row["folderPath"] and depth < max_folder_depth:
|
|
126
|
+
to_visit.append((urljoin(base_url, row["folderPath"]), row["name"], depth + 1))
|
|
127
|
+
|
|
128
|
+
return documents
|