tenderkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tenderkit/__init__.py +9 -0
- tenderkit/__main__.py +5 -0
- tenderkit/cli.py +248 -0
- tenderkit/documents.py +260 -0
- tenderkit/errors.py +52 -0
- tenderkit/http.py +94 -0
- tenderkit/interfaces.py +42 -0
- tenderkit/limits.py +7 -0
- tenderkit/mcp.py +221 -0
- tenderkit/model.py +78 -0
- tenderkit/pdf_ocr.py +78 -0
- tenderkit/platforms/__init__.py +33 -0
- tenderkit/platforms/backends.py +163 -0
- tenderkit/platforms/base.py +79 -0
- tenderkit/platforms/bk.py +78 -0
- tenderkit/platforms/ezamowienia.py +85 -0
- tenderkit/platforms/html.py +67 -0
- tenderkit/platforms/marketplanet_html.py +128 -0
- tenderkit/platforms/public_documents.py +206 -0
- tenderkit/platforms/pz_html.py +127 -0
- tenderkit/platforms/session.py +114 -0
- tenderkit/profile.py +216 -0
- tenderkit/profiles/__init__.py +13 -0
- tenderkit/profiles/leki.toml +5 -0
- tenderkit/profiles/oprogramowanie.toml +4 -0
- tenderkit/profiles/wyroby-medyczne.toml +4 -0
- tenderkit/queue.py +126 -0
- tenderkit/read_document.py +309 -0
- tenderkit/serialization.py +63 -0
- tenderkit/service.py +164 -0
- tenderkit/sources/__init__.py +1 -0
- tenderkit/sources/bk.py +145 -0
- tenderkit/sources/bzp.py +142 -0
- tenderkit/sources/mapping.py +264 -0
- tenderkit/sources/ted.py +116 -0
- tenderkit/sources/ted_xml.py +97 -0
- tenderkit/store/__init__.py +1 -0
- tenderkit/store/documents.py +168 -0
- tenderkit/store/state.py +202 -0
- tenderkit/ui.py +243 -0
- tenderkit/watch.py +247 -0
- tenderkit/web/index.html +59 -0
- tenderkit/web/style.css +221 -0
- tenderkit-0.1.0.dist-info/METADATA +219 -0
- tenderkit-0.1.0.dist-info/RECORD +49 -0
- tenderkit-0.1.0.dist-info/WHEEL +5 -0
- tenderkit-0.1.0.dist-info/entry_points.txt +2 -0
- tenderkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- tenderkit-0.1.0.dist-info/top_level.txt +1 -0
tenderkit/watch.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""Refresh a profile and report newly matching or revised notices."""
|
|
2
|
+
from dataclasses import replace
|
|
3
|
+
from datetime import date, datetime, timedelta, timezone
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
8
|
+
|
|
9
|
+
from tenderkit.queue import worker_lock
|
|
10
|
+
from tenderkit.serialization import dumps
|
|
11
|
+
from tenderkit.service import MAX_WINDOW_DAYS, matched_notice_record, refresh, today
|
|
12
|
+
from tenderkit.sources.bzp import AMENDMENT_TYPE
|
|
13
|
+
from tenderkit.sources.mapping import first
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_BZP_NUMBER = re.compile(r'(\d{4}/BZP \d+)/(\d+)')
|
|
17
|
+
_TED_ID = r'[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}'
|
|
18
|
+
_TED_PUBLICATION = re.compile(r'(\d{1,10})-(\d{4})')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _bzp_identity(number):
|
|
22
|
+
match = _BZP_NUMBER.fullmatch(number)
|
|
23
|
+
return (match[1], int(match[2])) if match else (number, 0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ted_publication(number):
|
|
27
|
+
match = _TED_PUBLICATION.fullmatch(number)
|
|
28
|
+
return f'{match[2]}/{int(match[1]):010d}' if match else number
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ted_identity(conn, profile, notice):
|
|
32
|
+
publication = _ted_publication(notice.number)
|
|
33
|
+
notice_id = str(first(notice.raw.get('notice-identifier')) or '').lower()
|
|
34
|
+
notice_id = notice_id if re.fullmatch(_TED_ID, notice_id) else None
|
|
35
|
+
reference = str(first(notice.raw.get('change-notice-version-identifier')) or '').lower()
|
|
36
|
+
previous_id = re.fullmatch(rf'({_TED_ID})-\d{{1,2}}', reference)
|
|
37
|
+
previous = previous_id[1] if previous_id else None
|
|
38
|
+
if _TED_PUBLICATION.fullmatch(reference):
|
|
39
|
+
previous = _ted_publication(reference)
|
|
40
|
+
aliases = tuple(dict.fromkeys(key for key in (previous, notice_id, publication) if key))
|
|
41
|
+
for alias in aliases:
|
|
42
|
+
row = conn.execute('select identity from watch_ted_aliases where profile=? and alias=?',
|
|
43
|
+
(profile, alias)).fetchone()
|
|
44
|
+
if row:
|
|
45
|
+
identity = row['identity']
|
|
46
|
+
break
|
|
47
|
+
else:
|
|
48
|
+
identity = previous or publication
|
|
49
|
+
conn.executemany('insert or ignore into watch_ted_aliases(profile,alias,identity) values(?,?,?)',
|
|
50
|
+
[(profile, alias, identity) for alias in aliases])
|
|
51
|
+
version = str(first(notice.raw.get('notice-version')) or '0').zfill(2)
|
|
52
|
+
return identity, f'{notice.published}/{publication}/{version}'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _latest_notices(conn, state, profile, source, start, end):
|
|
56
|
+
notices = list(state.notices(start, end, (source,)))
|
|
57
|
+
originals = {notice.number: notice for notice in notices if notice.notice_type != AMENDMENT_TYPE}
|
|
58
|
+
latest = {}
|
|
59
|
+
publication = _ted_publication if source == 'TED' else str
|
|
60
|
+
for notice in sorted(notices, key=lambda item: (item.published, publication(item.number))):
|
|
61
|
+
amendment = None
|
|
62
|
+
if source == 'BZP':
|
|
63
|
+
if notice.notice_type == AMENDMENT_TYPE:
|
|
64
|
+
amendment = notice
|
|
65
|
+
number = amendment.raw.get('amends')
|
|
66
|
+
if not number:
|
|
67
|
+
continue
|
|
68
|
+
identity, version = _bzp_identity(number)
|
|
69
|
+
notice = originals.get(number)
|
|
70
|
+
if notice is None:
|
|
71
|
+
seen = conn.execute('select 1 from watch_seen where profile=? and source=? and identity=?',
|
|
72
|
+
(profile, source, identity)).fetchone()
|
|
73
|
+
notice = state.get(source, number) if seen else None
|
|
74
|
+
if notice is None:
|
|
75
|
+
continue
|
|
76
|
+
else:
|
|
77
|
+
identity, version = _bzp_identity(notice.number)
|
|
78
|
+
published_notice = amendment or notice
|
|
79
|
+
revision = f'{version:08d}/{published_notice.published}/{published_notice.number}'
|
|
80
|
+
elif source == 'TED':
|
|
81
|
+
identity, revision = _ted_identity(conn, profile, notice)
|
|
82
|
+
elif source == 'BK':
|
|
83
|
+
identity, revision = notice.number, f"{notice.raw['version']:020d}"
|
|
84
|
+
else:
|
|
85
|
+
identity, revision = notice.number, notice.number
|
|
86
|
+
prior = latest.get(identity)
|
|
87
|
+
if prior is None or revision > prior[0]:
|
|
88
|
+
changed_deadline = amendment.deadline if amendment else None
|
|
89
|
+
if amendment and changed_deadline is None and prior and prior[2]:
|
|
90
|
+
changed_deadline = prior[3]
|
|
91
|
+
latest[identity] = (revision, notice, amendment, changed_deadline)
|
|
92
|
+
return latest
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _record_events(conn, run_id, profile, source, latest, on):
|
|
96
|
+
for identity, (revision, notice, amendment, changed_deadline) in latest.items():
|
|
97
|
+
key = (profile.name, source, identity)
|
|
98
|
+
previous = conn.execute('''select revision, deadline from watch_seen
|
|
99
|
+
where profile=? and source=? and identity=?''', key).fetchone()
|
|
100
|
+
if previous and revision < previous['revision']:
|
|
101
|
+
continue
|
|
102
|
+
if amendment:
|
|
103
|
+
if changed_deadline is None and previous:
|
|
104
|
+
changed_deadline = datetime.fromisoformat(previous['deadline']) if previous['deadline'] else None
|
|
105
|
+
notice = replace(notice, deadline=changed_deadline or notice.deadline)
|
|
106
|
+
if previous is None and not profile.matches(notice, today=on):
|
|
107
|
+
continue
|
|
108
|
+
deadline = notice.deadline
|
|
109
|
+
if deadline and deadline.tzinfo:
|
|
110
|
+
deadline = deadline.astimezone(timezone.utc)
|
|
111
|
+
deadline = deadline.isoformat() if deadline else None
|
|
112
|
+
changes = []
|
|
113
|
+
if previous:
|
|
114
|
+
if revision > previous['revision']:
|
|
115
|
+
changes.append('version')
|
|
116
|
+
if deadline != previous['deadline']:
|
|
117
|
+
changes.append('deadline')
|
|
118
|
+
if not changes:
|
|
119
|
+
continue
|
|
120
|
+
conn.execute('''insert into watch_seen(profile,source,identity,revision,deadline) values(?,?,?,?,?)
|
|
121
|
+
on conflict(profile,source,identity) do update set revision=excluded.revision, deadline=excluded.deadline''',
|
|
122
|
+
(*key, revision, deadline))
|
|
123
|
+
event = dict(profile=profile.name, event='updated' if previous else 'new',
|
|
124
|
+
changes=changes, notice=matched_notice_record(notice, profile))
|
|
125
|
+
if amendment:
|
|
126
|
+
event['amendment'] = dict(number=amendment.number, url=amendment.url)
|
|
127
|
+
conn.execute('insert into watch_events(run_id,profile,payload) values(?,?,?)',
|
|
128
|
+
(run_id, profile.name, dumps(event)))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _source_window(state, profile, source, start, catch_up):
|
|
132
|
+
if not catch_up:
|
|
133
|
+
return start
|
|
134
|
+
with state.connection() as conn:
|
|
135
|
+
row = conn.execute('select resume_from from watch_cursors where profile=? and source=?',
|
|
136
|
+
(profile, source)).fetchone()
|
|
137
|
+
start = min(start, date.fromisoformat(row['resume_from'])) if row else start
|
|
138
|
+
# Keep the whole attempted window recoverable until its events are staged.
|
|
139
|
+
conn.execute('''insert into watch_cursors(profile,source,resume_from) values(?,?,?)
|
|
140
|
+
on conflict(profile,source) do update set resume_from=excluded.resume_from''',
|
|
141
|
+
(profile, source, start.isoformat()))
|
|
142
|
+
return start
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _refresh_source(state, profile, source, adapter, start, end):
|
|
146
|
+
first = start
|
|
147
|
+
count = 0
|
|
148
|
+
interval = 30 if source == 'BZP' else MAX_WINDOW_DAYS
|
|
149
|
+
while True:
|
|
150
|
+
last = first + timedelta(days=min((end - first).days, interval - 1))
|
|
151
|
+
summary, = refresh(state, profile, first, last, {source: adapter}, include_amendments=True)
|
|
152
|
+
if summary['status'] == 'FAILED':
|
|
153
|
+
return {**summary, 'from': start.isoformat(), 'to': end.isoformat()}
|
|
154
|
+
count += summary['notices']
|
|
155
|
+
if last == end:
|
|
156
|
+
break
|
|
157
|
+
first = last + timedelta(days=1)
|
|
158
|
+
return {'source': source, 'status': 'COMPLETED', 'notices': count, 'from': start.isoformat(), 'to': end.isoformat()}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _event(row):
|
|
162
|
+
return dict(event_id=row['id'], **json.loads(row['payload']))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def latest_updates(state, profile, *, limit=100, offset=0):
|
|
166
|
+
if type(limit) is not int or not 1 <= limit <= 500 or type(offset) is not int or not 0 <= offset < 2**63:
|
|
167
|
+
raise ValueError('Invalid result limit or offset')
|
|
168
|
+
with state.connection() as conn:
|
|
169
|
+
conn.execute('begin')
|
|
170
|
+
run = conn.execute('''select id, finished_at, sources from watch_runs
|
|
171
|
+
where profile=? order by id desc limit 1''', (profile,)).fetchone()
|
|
172
|
+
if run is None:
|
|
173
|
+
return dict(profile=profile, run_id=None, finished_at=None, sources=[], events=[], next_offset=None)
|
|
174
|
+
rows = conn.execute('''select id,payload from watch_events where run_id=?
|
|
175
|
+
order by id limit ? offset ?''', (run['id'], limit + 1, offset)).fetchall()
|
|
176
|
+
return dict(profile=profile, run_id=run['id'], finished_at=run['finished_at'], sources=json.loads(run['sources']),
|
|
177
|
+
events=[_event(row) for row in rows[:limit]], next_offset=offset + limit if len(rows) > limit else None)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
181
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _webhook_sender(url):
|
|
186
|
+
parsed = urlsplit(url)
|
|
187
|
+
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username is not None or parsed.fragment:
|
|
188
|
+
raise ValueError('Webhook must be an HTTP(S) URL without credentials or a fragment')
|
|
189
|
+
opener = build_opener(_NoRedirect())
|
|
190
|
+
|
|
191
|
+
def send(events):
|
|
192
|
+
request = Request(url, data=dumps({'events': events}).encode('utf-8'),
|
|
193
|
+
headers={'Content-Type': 'application/json'}, method='POST')
|
|
194
|
+
try:
|
|
195
|
+
with opener.open(request, timeout=10) as response:
|
|
196
|
+
if not 200 <= response.status < 300:
|
|
197
|
+
raise OSError('Unexpected webhook response')
|
|
198
|
+
except (OSError, ValueError) as exc:
|
|
199
|
+
raise RuntimeError(f'Webhook delivery failed ({type(exc).__name__}); events retained for retry') from None
|
|
200
|
+
|
|
201
|
+
return send
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _deliver(state, run_id, output, send):
|
|
205
|
+
while True:
|
|
206
|
+
with state.connection() as conn:
|
|
207
|
+
rows = conn.execute('''select id,payload from watch_events where run_id=? and delivered=0
|
|
208
|
+
order by id limit 100''', (run_id,)).fetchall()
|
|
209
|
+
if not rows:
|
|
210
|
+
return
|
|
211
|
+
events = [_event(row) for row in rows]
|
|
212
|
+
if send:
|
|
213
|
+
send(events)
|
|
214
|
+
for event in events:
|
|
215
|
+
print(dumps(event), file=output)
|
|
216
|
+
output.flush()
|
|
217
|
+
with state.connection() as conn:
|
|
218
|
+
conn.executemany('update watch_events set delivered=1 where id=?', [(row['id'],) for row in rows])
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def run_watch(state, profile, start, end, adapters, output, *, catch_up=True, webhook=None):
|
|
222
|
+
"""Refresh each source independently, stage events atomically, and deliver pending events."""
|
|
223
|
+
send = _webhook_sender(webhook) if webhook is not None else None
|
|
224
|
+
with worker_lock(state.path.resolve(), suffix='.watch.lock') as locked:
|
|
225
|
+
if not locked:
|
|
226
|
+
raise RuntimeError('Another watch run is already active for this database')
|
|
227
|
+
summaries = []
|
|
228
|
+
successful = []
|
|
229
|
+
for source, adapter in adapters.items():
|
|
230
|
+
first = _source_window(state, profile.name, source, start, catch_up)
|
|
231
|
+
summary = _refresh_source(state, profile, source, adapter, first, end)
|
|
232
|
+
summaries.append(summary)
|
|
233
|
+
if summary['status'] == 'COMPLETED':
|
|
234
|
+
successful.append((source, first))
|
|
235
|
+
with state.connection() as conn:
|
|
236
|
+
run_id = conn.execute('insert into watch_runs(profile,finished_at,sources) values(?,?,?)',
|
|
237
|
+
(profile.name, datetime.now(timezone.utc).isoformat(), dumps(summaries))).lastrowid
|
|
238
|
+
conn.execute('update watch_events set run_id=? where profile=? and delivered=0', (run_id, profile.name))
|
|
239
|
+
on = today()
|
|
240
|
+
for source, first in successful:
|
|
241
|
+
latest = _latest_notices(conn, state, profile.name, source, first, end)
|
|
242
|
+
_record_events(conn, run_id, profile, source, latest, on)
|
|
243
|
+
if catch_up:
|
|
244
|
+
conn.execute('update watch_cursors set resume_from=? where profile=? and source=?',
|
|
245
|
+
(end.isoformat(), profile.name, source))
|
|
246
|
+
_deliver(state, run_id, output, send)
|
|
247
|
+
return summaries
|
tenderkit/web/index.html
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="pl">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>tenderkit — Przegląd ogłoszeń</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main class="window">
|
|
11
|
+
<header class="header">
|
|
12
|
+
<span class="mark" aria-hidden="true">↗</span>
|
|
13
|
+
<strong>tenderkit</strong>
|
|
14
|
+
<span class="local">Lokalna baza</span>
|
|
15
|
+
</header>
|
|
16
|
+
<div class="content">
|
|
17
|
+
<h1>Twoje profile wyszukiwania</h1>
|
|
18
|
+
<nav class="profiles" aria-label="Profile wyszukiwania">$profile_tabs</nav>
|
|
19
|
+
<div class="criteria">$criteria</div>
|
|
20
|
+
<form class="filters" action="/" method="get" role="search">
|
|
21
|
+
<input type="hidden" name="profile" value="$profile">
|
|
22
|
+
<input type="hidden" name="source" value="$source">
|
|
23
|
+
<div class="search">
|
|
24
|
+
<input type="search" name="q" value="$query" maxlength="200"
|
|
25
|
+
aria-label="Szukaj po tytule lub zamawiającym"
|
|
26
|
+
placeholder="Szukaj po tytule lub zamawiającym…">
|
|
27
|
+
</div>
|
|
28
|
+
<div class="filter-fields">
|
|
29
|
+
<label>Publikacja od
|
|
30
|
+
<input type="date" name="from" value="$published_from">
|
|
31
|
+
</label>
|
|
32
|
+
<label>Publikacja do
|
|
33
|
+
<input type="date" name="to" value="$published_to">
|
|
34
|
+
</label>
|
|
35
|
+
<label>Prefiks CPV
|
|
36
|
+
<input name="cpv" value="$cpv" inputmode="numeric" maxlength="8" pattern="[0-9]{0,8}" placeholder="np. 72">
|
|
37
|
+
</label>
|
|
38
|
+
<label>Termin ofert
|
|
39
|
+
<select name="deadline">$deadline_options</select>
|
|
40
|
+
</label>
|
|
41
|
+
<label>Sortuj według
|
|
42
|
+
<select name="sort">$sort_options</select>
|
|
43
|
+
</label>
|
|
44
|
+
</div>
|
|
45
|
+
<div class="filter-actions">
|
|
46
|
+
<button type="submit">Zastosuj</button>
|
|
47
|
+
<a href="$reset_link">Wyczyść filtry</a>
|
|
48
|
+
</div>
|
|
49
|
+
</form>
|
|
50
|
+
<div class="results-heading">
|
|
51
|
+
<span>$count</span>
|
|
52
|
+
<nav class="sources" aria-label="Źródło ogłoszenia">$source_tabs</nav>
|
|
53
|
+
</div>
|
|
54
|
+
$rows
|
|
55
|
+
<nav class="pagination" aria-label="Strony wyników">$pagination</nav>
|
|
56
|
+
</div>
|
|
57
|
+
</main>
|
|
58
|
+
</body>
|
|
59
|
+
</html>
|
tenderkit/web/style.css
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--ink: #192e50;
|
|
4
|
+
--muted: #526783;
|
|
5
|
+
--blue: #234cf4;
|
|
6
|
+
--line: #dce5f3;
|
|
7
|
+
--tint: #edf2fa;
|
|
8
|
+
--navy: #193663;
|
|
9
|
+
font: 15px/1.5 "Segoe UI", system-ui, sans-serif;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
* { box-sizing: border-box; }
|
|
13
|
+
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
padding: clamp(12px, 3vw, 36px);
|
|
17
|
+
background: var(--navy);
|
|
18
|
+
color: var(--ink);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.window {
|
|
22
|
+
max-width: 960px;
|
|
23
|
+
margin-inline: auto;
|
|
24
|
+
overflow: hidden;
|
|
25
|
+
border: 1px solid var(--line);
|
|
26
|
+
border-radius: 16px;
|
|
27
|
+
background: #fff;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.header {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: 12px;
|
|
34
|
+
padding: 12px clamp(14px, 3vw, 28px);
|
|
35
|
+
border-bottom: 1px solid var(--line);
|
|
36
|
+
background: #f6f8fc;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.mark {
|
|
40
|
+
display: grid;
|
|
41
|
+
place-items: center;
|
|
42
|
+
width: 21px;
|
|
43
|
+
height: 21px;
|
|
44
|
+
border: 2px solid currentColor;
|
|
45
|
+
border-radius: 3px;
|
|
46
|
+
color: var(--blue);
|
|
47
|
+
font-weight: 750;
|
|
48
|
+
line-height: 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.local {
|
|
52
|
+
margin-left: auto;
|
|
53
|
+
padding: 2px 8px;
|
|
54
|
+
border: 1px solid var(--line);
|
|
55
|
+
border-radius: 4px;
|
|
56
|
+
color: var(--muted);
|
|
57
|
+
font-size: .78rem;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.content { padding: 18px clamp(14px, 3vw, 28px); }
|
|
61
|
+
h1 { margin: 0 0 12px; font-size: 1.35rem; line-height: 1.25; }
|
|
62
|
+
a { color: inherit; text-decoration: none; }
|
|
63
|
+
button, input, select { font: inherit; }
|
|
64
|
+
:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
|
|
65
|
+
|
|
66
|
+
.profiles {
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-wrap: wrap;
|
|
69
|
+
gap: 4px;
|
|
70
|
+
padding: 5px;
|
|
71
|
+
border-radius: 9px;
|
|
72
|
+
background: var(--tint);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.profiles a {
|
|
76
|
+
flex: 1 1 auto;
|
|
77
|
+
padding: 7px 10px;
|
|
78
|
+
border: 1px solid transparent;
|
|
79
|
+
border-radius: 6px;
|
|
80
|
+
text-align: center;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.profiles [aria-current="page"] {
|
|
85
|
+
border-color: #cddbf6;
|
|
86
|
+
background: #fff;
|
|
87
|
+
color: var(--blue);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.criteria {
|
|
91
|
+
display: flex;
|
|
92
|
+
flex-wrap: wrap;
|
|
93
|
+
gap: 6px;
|
|
94
|
+
margin-block: 12px;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.criteria:empty, .pagination:empty { display: none; }
|
|
98
|
+
|
|
99
|
+
.criteria span, .badge {
|
|
100
|
+
padding: 2px 8px;
|
|
101
|
+
border: 1px solid #d7e1f7;
|
|
102
|
+
border-radius: 4px;
|
|
103
|
+
background: #eef3ff;
|
|
104
|
+
color: #34588d;
|
|
105
|
+
font-size: .77rem;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.search {
|
|
109
|
+
display: flex;
|
|
110
|
+
gap: 8px;
|
|
111
|
+
margin-top: 12px;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.search input, .filter-fields input, .filter-fields select {
|
|
115
|
+
flex: 1;
|
|
116
|
+
min-width: 0;
|
|
117
|
+
padding: 8px 11px;
|
|
118
|
+
border: 1px solid #cddbf0;
|
|
119
|
+
border-radius: 7px;
|
|
120
|
+
background: #fff;
|
|
121
|
+
color: var(--ink);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.filter-fields {
|
|
125
|
+
display: grid;
|
|
126
|
+
grid-template-columns: repeat(auto-fit, minmax(min(100%, 145px), 1fr));
|
|
127
|
+
gap: 8px 10px;
|
|
128
|
+
margin-top: 10px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.filter-fields label {
|
|
132
|
+
display: grid;
|
|
133
|
+
gap: 4px;
|
|
134
|
+
min-width: 0;
|
|
135
|
+
color: var(--muted);
|
|
136
|
+
font-size: .84rem;
|
|
137
|
+
font-weight: 600;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.filter-fields input, .filter-fields select {
|
|
141
|
+
width: 100%;
|
|
142
|
+
min-width: 0;
|
|
143
|
+
font-weight: 400;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.filter-actions {
|
|
147
|
+
display: flex;
|
|
148
|
+
flex-wrap: wrap;
|
|
149
|
+
align-items: center;
|
|
150
|
+
gap: 12px;
|
|
151
|
+
margin-top: 10px;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.filter-actions a { color: #34588d; font-size: .85rem; text-decoration: underline; }
|
|
155
|
+
|
|
156
|
+
.filter-actions button {
|
|
157
|
+
padding: 8px 13px;
|
|
158
|
+
border: 1px solid #cddbf6;
|
|
159
|
+
border-radius: 7px;
|
|
160
|
+
background: #edf2ff;
|
|
161
|
+
color: #254699;
|
|
162
|
+
cursor: pointer;
|
|
163
|
+
font-weight: 600;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.results-heading {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
justify-content: space-between;
|
|
170
|
+
flex-wrap: wrap;
|
|
171
|
+
gap: 6px 16px;
|
|
172
|
+
margin-top: 10px;
|
|
173
|
+
padding-bottom: 8px;
|
|
174
|
+
color: var(--muted);
|
|
175
|
+
font-size: .85rem;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.sources, .pagination { display: flex; flex-wrap: wrap; gap: 4px; }
|
|
179
|
+
.sources a, .pagination a { padding: 5px 8px; border-radius: 4px; }
|
|
180
|
+
.sources [aria-current="page"], .sources a:hover, .pagination a:hover {
|
|
181
|
+
background: var(--tint);
|
|
182
|
+
color: #284e8a;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.notices { padding: 0; margin: 0; list-style: none; }
|
|
186
|
+
|
|
187
|
+
.notice {
|
|
188
|
+
display: grid;
|
|
189
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 16px;
|
|
192
|
+
padding: 12px 0;
|
|
193
|
+
border-top: 1px solid var(--line);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.notice h2 {
|
|
197
|
+
margin: 4px 0 2px;
|
|
198
|
+
font-size: 1rem;
|
|
199
|
+
line-height: 1.4;
|
|
200
|
+
overflow-wrap: anywhere;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.notice p, .metadata, .empty { color: var(--muted); }
|
|
204
|
+
.notice p { margin: 0; font-size: .86rem; overflow-wrap: anywhere; }
|
|
205
|
+
.metadata { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 9px; font-size: .76rem; }
|
|
206
|
+
.badge { padding: 0 5px; font-weight: 650; }
|
|
207
|
+
|
|
208
|
+
.open {
|
|
209
|
+
grid-column: 2;
|
|
210
|
+
display: grid;
|
|
211
|
+
place-items: center;
|
|
212
|
+
width: 34px;
|
|
213
|
+
height: 36px;
|
|
214
|
+
border: 1px solid var(--line);
|
|
215
|
+
border-radius: 5px;
|
|
216
|
+
color: #34588d;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.open:hover, .profiles a:hover { background: #f2f6ff; }
|
|
220
|
+
.pagination { justify-content: flex-end; border-top: 1px solid var(--line); padding-top: 8px; }
|
|
221
|
+
.empty { border-top: 1px solid var(--line); margin: 0; padding: 24px 0; }
|