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
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
@@ -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>
@@ -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; }