-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathparse_minori_all.py
More file actions
executable file
·276 lines (234 loc) · 12.1 KB
/
Copy pathparse_minori_all.py
File metadata and controls
executable file
·276 lines (234 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
#!/usr/bin/env python3
"""Parse minori (minor citizenship) ordinances into the Minori11 table.
Each PDF is an ordinance approving citizenship for minors and lists the
dossiers involved, e.g. "(9791/M/2022)", "(87829/A/2019)" or older "(11436/2018)".
We extract the ordinance number + date and, per dossier, how many minors it
covers in that ordinance (siblings repeat the same id).
Run get_minori_no_ssl.py first to download; this only parses.
"""
import os
import re
import time
import sqlite3
import logging
import fitz # pip install PyMuPDF
from datetime import datetime
import sys
sys.dont_write_bytecode = True
from incremental import db_path, id_pattern, is_reference, document_body, setup_logger, setup_issue_logger, cprint
# Reuse the robust date logic already implemented for ordinances (PyMuPDF ->
# PDFMiner fallback + filename date parser) instead of duplicating it here.
from parse_ordins_all import parse_date_from_filename, extract_date
# Constants
CRED = '\033[91m'
COK = '\033[92m'
CWARN = '\033[93m'
CVIOLET = '\033[95m'
CEND = '\033[0m'
start_time = time.time()
pdf_dir = './minori/'
database_path = db_path()
logger = setup_logger('minori_logger', 'parse-minori-' + datetime.now().strftime("%Y-%m-%d") + '.log', mode='w')
# Skip log: every match that is NOT stored as a dossier (e.g. NNNN/P/YYYY
# ordinance numbers, out-of-range years), with raw text, pattern and the
# document:page it came from — mirrors parse_stadiu's "[SKIP] ... | PATTERN:".
SkipLogger = setup_issue_logger('minori_skip_logger', 'parse-minori-issues-' + datetime.now().strftime("%Y-%m-%d") + '.log')
connection = sqlite3.connect(database_path)
db = connection.cursor()
# Make sure the table exists even when running against an already-built DB
# (create_tables.sql only runs on a fresh init_db).
db.executescript('''
CREATE TABLE IF NOT EXISTS Minori11(
id TEXT NOT NULL,
number INTEGER,
year INTEGER,
segment TEXT DEFAULT NULL,
ordin TEXT NOT NULL,
ordin_date DATE DEFAULT NULL,
cminori INTEGER DEFAULT 1,
PRIMARY KEY (id, ordin)
);
CREATE INDEX IF NOT EXISTS idx_minori11_numyear ON Minori11(number, year);
CREATE INDEX IF NOT EXISTS idx_minori11_ordin ON Minori11(ordin);
''')
connection.commit()
# number / optional alpha segment (M, A, RD...) / year. Years from the 1990s on
# are valid (dossiers are NOT limited to recent years), so no year cutoff is
# applied; non-dossier numbers are excluded by context instead (see _skip_reason).
ID_RE = re.compile(r'(\d{2,7})\s*/\s*(?:([A-Za-z]{1,6})\s*/\s*)?((?:19|20)\d{2})')
# Run-wide counters, reported by print_summary() for both _all and _new.
stats = {'files': 0, 'unique': 0, 'records': 0, 'skipped': 0,
'skip_P': 0, 'skip_ref': 0, 'boiler': 0, 'empty': 0, 'errors': 0}
def _skip_reason(seg, pre_context):
"""Why a matched number is NOT a dossier, or None if it is one.
`677/2001` after "Legii nr." is the data-protection law, not a dossier; it is
effectively a `/P/`-style institutional number with the segment dropped, so we
classify (restore the meaning of) it by its surrounding text (shared
is_reference) rather than by a crude year cutoff.
"""
if is_reference(pre_context):
return 'law/regulation-reference'
if seg == 'P':
return 'ordinance-number(/P/)'
return None
def _despace(s):
return re.sub(r'\s+', ' ', s)
def print_summary():
"""Final summary, printed to console and the parse/skip logs."""
print(f"{'-'*60}")
print(f"Files: {COK}{stats['files']}{CEND} Dossiers(uniq): {COK}{stats['unique']}{CEND} Records: {stats['records']}")
print(f"Skipped matches: {CWARN}{stats['skipped']}{CEND} "
f"(/P/ ordin: {stats['skip_P']}, law/reg ref: {stats['skip_ref']}) "
f"Boilerplate lines cut: {stats['boiler']} "
f"Empty files: {CWARN}{stats['empty']}{CEND} Errors: {CRED}{stats['errors']}{CEND}")
print(f"{'Parsing PDF time: '}{COK}{time.time() - start_time:.2f}{CEND} seconds")
summary = (f"files={stats['files']} unique={stats['unique']} records={stats['records']} "
f"skipped={stats['skipped']} skip_P={stats['skip_P']} skip_ref={stats['skip_ref']} "
f"boiler={stats['boiler']} empty={stats['empty']} errors={stats['errors']}")
logger.info("SUMMARY " + summary)
# Write summary to the always-on issue log only when there is something to
# inspect (so a perfectly clean run leaves no file).
if stats['skipped'] or stats['empty'] or stats['errors']:
SkipLogger.info("SUMMARY " + summary)
def extract_ordin_number(text, filename):
head = _despace(text[:800])
for pat in (r'\bnr\.?\s*(\d{1,5})\s*/\s*P\b',
r'O\s*R\s*D\s*I\s*N[^0-9]{0,40}?(\d{1,5})\s*/?\s*P\b',
r'\b(\d{1,5})\s*/\s*P\b'):
m = re.search(pat, head, re.I)
if m:
return m.group(1)
# fallback: from filename
n = re.sub(r'^\d{4}-\d{2}-', '', filename)
n = re.sub(r'\.pdf$', '', n, flags=re.I)
n = re.sub(r'\b\d{1,2}[._-]\d{1,2}[._-]\d{2,4}\b', ' ', n) # drop dates
m = re.search(r'(?:ordin|ordon|op|ord)[^0-9]{0,12}(\d{1,5})', n, re.I)
if m:
return m.group(1)
m = re.search(r'(\d{1,5})\s*[-_. ]?P\b', n, re.I)
return m.group(1) if m else None
def process_pdf(file_path, db, logger):
# Mirrors parse_ordins_all.parse_pdf: declare early, big try, log F/DF/DP/DR,
# except logs the error, finally guarantees a printed status line.
printed = False
unique_records = 0
total_records = 0
file_skipped = 0
stats['files'] += 1
try:
raw_filename = os.path.basename(file_path)
date_file = parse_date_from_filename(raw_filename) # from filename
ordinance_date = extract_date(file_path) # PyMuPDF -> PDFMiner
with fitz.open(file_path) as doc:
pages = [page.get_text() for page in doc]
first_page_text = pages[0] if pages else ""
# Ordinance number: filename fallback, then header override (minori
# headers are spaced, e.g. "O R D I N nr. 1822/P").
ordin_num = extract_ordin_number(first_page_text, raw_filename)
# Date fallbacks: PDF -> filename -> YYYY-MM- archive prefix (day=01).
if not ordinance_date and date_file:
ordinance_date = date_file
if not ordinance_date:
m = re.match(r'(\d{4})-(\d{2})-', raw_filename)
if m:
try:
ordinance_date = datetime(int(m.group(1)), int(m.group(2)), 1)
except ValueError:
pass
ordinance_year = (ordinance_date.year if ordinance_date
else date_file.year if date_file else None)
ordin = (f"{ordin_num}/P/{ordinance_year}" if ordin_num and ordinance_year
else f"{ordin_num}/P" if ordin_num else None)
df_str = date_file.strftime('%Y-%m-%d') if date_file else 'None'
dp_str = ordinance_date.strftime('%Y-%m-%d') if ordinance_date else 'None'
logger.info(f"Parsing file: {file_path} | F:{ordin_num} DF:{df_str} DP:{dp_str} DR:{dp_str}")
cprint(f"{'Parsing: ' + CWARN + file_path + CEND:.<170}", end="")
if not ordin:
logger.error(f"No ordin number in {file_path}")
SkipLogger.info(f"[SKIP FILE] {file_path} | REASON: no-ordin-number")
cprint(f"{CRED}no ordin number{CEND}")
printed = True
stats['errors'] += 1
return 0, 0
# Cut the header/preamble (everything up to ANEXA/LISTA) and the
# repeating header/footer band; the dossier list is the meat between.
body = document_body(pages, start_markers=('anex', 'lista'))
all_lines = sum(1 for p in pages for ln in p.split('\n') if ln.strip())
stats['boiler'] += all_lines - sum(len(lines) for _, lines in body)
counts = {} # id -> count of minors
meta = {} # id -> (number, year, segment)
for pnum, lines in body:
for line in lines:
for m in ID_RE.finditer(line):
num, seg, year = m.group(1), m.group(2), m.group(3)
raw = re.sub(r'\s+', '', m.group(0))
pat = id_pattern(raw)
seg_u = seg.upper() if seg else None
pre = line[max(0, m.start() - 45):m.start()]
# Safety net only; boilerplate (law refs) is already cut.
reason = _skip_reason(seg_u, pre)
if reason:
# The document's OWN ordinance number (matches the one
# in the title/filename) is expected — drop it silently,
# not as an anomaly in the skip log.
if reason.startswith('ordinance') and num == ordin_num:
continue
file_skipped += 1
stats['skip_P' if reason.startswith('ordinance') else 'skip_ref'] += 1
SkipLogger.info(f"[SKIP] {file_path}:{pnum} | RAW: '{raw}' | PATTERN: {pat} | REASON: {reason}")
continue
y = int(year)
dosar_id = f"{num}/{seg_u}/{year}" if seg_u else f"{num}/{year}"
total_records += 1
if dosar_id not in counts:
counts[dosar_id] = 0
meta[dosar_id] = (int(num), y, seg_u)
unique_records += 1
counts[dosar_id] += 1
ordin_date_db = ordinance_date.date() if ordinance_date else None
for dosar_id, cnt in counts.items():
number, year, seg_u = meta[dosar_id]
upsert_minori(db, dosar_id, number, year, seg_u, ordin, ordin_date_db, cnt, logger)
color = COK if unique_records > 0 else CRED
cprint(f"{'found ' + color + str(unique_records).zfill(4) + CWARN + ' / ' + COK + str(total_records).zfill(4) + CEND + ' minors, ' + CWARN + str(file_skipped).zfill(3) + CEND + ' skipped (ordin ' + str(ordin) + ')'}")
printed = True
logger.info(f"Processed {unique_records}/{total_records} minors, {file_skipped} skipped from {file_path} | F:{ordin_num} DF:{df_str} DP:{dp_str}\n")
if unique_records == 0:
stats['empty'] += 1
SkipLogger.info(f"[EMPTY] {file_path} | ordin {ordin} | no dossiers parsed")
stats['unique'] += unique_records
stats['records'] += total_records
stats['skipped'] += file_skipped
connection.commit()
except Exception as e:
logger.error(f"Error processing file {file_path}: {e}")
SkipLogger.info(f"[ERROR] {file_path} | {e}")
stats['errors'] += 1
finally:
if not printed:
cprint(f"{CRED}found {str(unique_records).zfill(4)} minors (error){CEND}")
return unique_records, total_records
def upsert_minori(db, dosar_id, number, year, segment, ordin, ordin_date, cminori, logger):
try:
db.execute('''
INSERT INTO Minori11 (id, number, year, segment, ordin, ordin_date, cminori)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id, ordin) DO UPDATE SET
ordin_date = excluded.ordin_date,
cminori = excluded.cminori;
''', (dosar_id, number, year, segment, ordin, ordin_date, cminori))
logger.info(f"Upserted minori {dosar_id} ordin {ordin} cminori={cminori}")
except Exception as e:
logger.error(f"Error upserting minori {dosar_id} / {ordin}: {e}")
def main():
for file in sorted(os.listdir(pdf_dir)):
if not file.lower().endswith('.pdf'):
continue
file_path = os.path.join(pdf_dir, file)
logger.info(f"Processing file: {file}")
process_pdf(file_path, db, logger)
connection.commit()
connection.close()
print_summary()
if __name__ == '__main__':
main()