echr-extractor 0.0.1.dev1__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.
@@ -0,0 +1,82 @@
1
+ import threading
2
+
3
+ import requests
4
+ from bs4 import BeautifulSoup
5
+
6
+ base_url = "https://hudoc.echr.coe.int/app/conversion/docx/html/body?library=ECHR&id="
7
+
8
+
9
+ def get_full_text_from_html(html_text):
10
+ # This method turns the html code from the summary page into text
11
+ # It has different cases depending on the first character of the CELEX ID
12
+ # Should only be used for summaries extraction
13
+ soup = BeautifulSoup(html_text, "html.parser")
14
+ for script in soup(["script", "style"]):
15
+ script.extract() # rip it out
16
+ text = soup.get_text()
17
+ # break into lines and remove leading and trailing space on each
18
+ lines = (line.strip() for line in text.splitlines())
19
+ # break multi-headlines into a line each
20
+ chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
21
+ # drop blank lines
22
+ text = "\n".join(chunk for chunk in chunks if chunk)
23
+ text = text.replace(",", "_")
24
+ return text
25
+
26
+
27
+ def download_full_text_main(df, threads):
28
+ item_ids = df["itemid"]
29
+ eclis = df["ecli"]
30
+ length = item_ids.size
31
+ if length > threads:
32
+ at_once_threads = int(length / threads)
33
+ else:
34
+ at_once_threads = length
35
+ all_dict = list()
36
+ threads = []
37
+ for i in range(0, length, at_once_threads):
38
+ curr_ids = item_ids[i : (i + at_once_threads)]
39
+ curr_ecli = eclis[i : (i + at_once_threads)]
40
+ t = threading.Thread(
41
+ target=download_full_text_separate, args=(curr_ids, curr_ecli, all_dict)
42
+ )
43
+ threads.append(t)
44
+ for t in threads:
45
+ t.start()
46
+ for t in threads:
47
+ t.join()
48
+
49
+ json_file = list()
50
+ for item in all_dict:
51
+ if len(item) > 0:
52
+ json_file.extend(item)
53
+ return json_file
54
+
55
+
56
+ def download_full_text_separate(item_ids, eclis, dict_list):
57
+ full_list = []
58
+ eclis = eclis.reset_index(drop=True)
59
+ item_ids = item_ids.reset_index(drop=True)
60
+
61
+ def download_html(item_ids, eclis):
62
+ retry_ids = []
63
+ retry_eclis = []
64
+ for i in range(len(item_ids)):
65
+ item_id = item_ids[i]
66
+ ecli = eclis[i]
67
+ try:
68
+ r = requests.get(base_url + item_id, timeout=1)
69
+ json_dict = {
70
+ "item_id": item_id,
71
+ "ecli": ecli,
72
+ "full_text": get_full_text_from_html(r.text),
73
+ }
74
+ full_list.append(json_dict)
75
+ except Exception:
76
+ retry_ids.append(item_id)
77
+ retry_eclis.append(ecli)
78
+ return retry_ids, retry_eclis
79
+
80
+ retry_ids, retry_eclis = download_html(item_ids, eclis)
81
+ download_html(retry_ids, retry_eclis)
82
+ dict_list.append(full_list)
@@ -0,0 +1,575 @@
1
+ import gc
2
+ import json
3
+ import logging
4
+ import time
5
+ import urllib.parse
6
+ from datetime import datetime, timedelta
7
+
8
+ import pandas as pd
9
+ import requests
10
+ from tqdm import tqdm
11
+
12
+
13
+ def get_r(url, timeout, retry, verbose, max_attempts=20):
14
+ """
15
+ Enhanced get data from a URL with improved error handling and retry logic.
16
+
17
+ :param str url: The data source URL.
18
+ :param float timeout: The amount of time to wait for a response each attempt.
19
+ :param int retry: The number of times to retry upon failure.
20
+ :param bool verbose: Whether or not to print extra information.
21
+ :param int max_attempts: Maximum number of attempts before giving up.
22
+ :return: requests.Response object or None if all attempts failed.
23
+ """
24
+ count = 0
25
+ last_exception = None
26
+
27
+ while count < max_attempts:
28
+ try:
29
+ r = requests.get(url, timeout=timeout)
30
+ r.raise_for_status() # Raise an exception for bad status codes
31
+ return r
32
+ except (
33
+ requests.exceptions.ReadTimeout,
34
+ requests.exceptions.ConnectTimeout,
35
+ requests.exceptions.ConnectionError,
36
+ requests.exceptions.HTTPError,
37
+ ) as e:
38
+ last_exception = e
39
+ count += 1
40
+ if verbose:
41
+ logging.warning(
42
+ f"Request failed (attempt {count}/{max_attempts}): {type(e).__name__}: {str(e)}"
43
+ )
44
+
45
+ if count <= retry:
46
+ # Exponential backoff
47
+ wait_time = min(2**count, 30) # Cap at 30 seconds
48
+ if verbose:
49
+ logging.info(f"Retrying in {wait_time} seconds...")
50
+ time.sleep(wait_time)
51
+ else:
52
+ if verbose:
53
+ logging.error(
54
+ f"Unable to connect to {url} after {count} attempts. Last error: {last_exception}"
55
+ )
56
+ return None
57
+
58
+ if verbose:
59
+ logging.error(
60
+ f"Max attempts ({max_attempts}) exceeded for {url}. Last error: {last_exception}"
61
+ )
62
+ return None
63
+
64
+
65
+ def get_date_ranges(start_date, end_date, days_per_batch=365):
66
+ """
67
+ Split a date range into smaller batches to prevent timeouts and memory issues.
68
+
69
+ :param str start_date: Start date in YYYY-MM-DD format
70
+ :param str end_date: End date in YYYY-MM-DD format
71
+ :param int days_per_batch: Number of days per batch
72
+ :return: List of (start_date, end_date) tuples
73
+ """
74
+ if not start_date or not end_date:
75
+ return [(start_date, end_date)]
76
+
77
+ start_dt = datetime.strptime(start_date, "%Y-%m-%d")
78
+ end_dt = datetime.strptime(end_date, "%Y-%m-%d")
79
+
80
+ date_ranges = []
81
+ current_start = start_dt
82
+
83
+ while current_start < end_dt:
84
+ current_end = min(current_start + timedelta(days=days_per_batch - 1), end_dt)
85
+ date_ranges.append(
86
+ (current_start.strftime("%Y-%m-%d"), current_end.strftime("%Y-%m-%d"))
87
+ )
88
+ current_start = current_end + timedelta(days=1)
89
+
90
+ return date_ranges
91
+
92
+
93
+ def basic_function(term, values):
94
+ values = ['"' + i + '"' for i in values]
95
+ main_body = list()
96
+ cut_term = term.replace('"', "")
97
+ for v in values:
98
+ main_body.append(f"({cut_term}={v}) OR ({cut_term}:{v})")
99
+ query = f"({' OR '.join(main_body)})"
100
+ return query
101
+
102
+
103
+ def link_to_query(link):
104
+ # Fixing brackets
105
+ link = link.replace("%7B", "{")
106
+ link = link.replace("%7D", "}")
107
+ link = link.replace("%5B", "[")
108
+ link = link.replace("%5D", "]")
109
+ link = link.replace("%22", '"')
110
+ link = link.replace("%27", "'")
111
+
112
+ # fixing fulltext shenanigans - happen because of people using "
113
+ # in the queries.
114
+
115
+ full_text_input = ""
116
+ fulltext_end = -1
117
+ fulltext_start = link.find("fulltext")
118
+ if fulltext_start != -1: # Fixed: check for -1 instead of truthy value
119
+ start = link[fulltext_start:].find("[") + fulltext_start + 1
120
+ fulltext_end = link[fulltext_start:].find("]") + fulltext_start
121
+ fragment_to_fix = link[start:fulltext_end]
122
+ full_text_input = (
123
+ "("
124
+ + "".join(fragment_to_fix.rsplit('"', 1)).replace('"', "", 1)
125
+ + ")".replace("\\", "")
126
+ )
127
+ full_text_input = full_text_input.replace("\\", "")
128
+ # removing first and last " elements and saving the output to
129
+ # put manually later
130
+ if fulltext_end != -1: # Fixed: check for -1 instead of truthy value
131
+
132
+ if link[fulltext_end + 1] == ",":
133
+ to_replace = link[fulltext_start - 1 : fulltext_end + 2]
134
+ else:
135
+ to_replace = link[fulltext_start - 1 : fulltext_end + 1]
136
+ link = link.replace(to_replace, "")
137
+
138
+ extra_cases_map = {
139
+ "bodyprocedure": (
140
+ '("PROCEDURE" ONEAR(n=1000) terms OR "PROCÉDURE" ONEAR(n=1000) terms)'
141
+ ),
142
+ "bodyfacts": (
143
+ '("THE FACTS" ONEAR(n=1000) terms OR "EN FAIT" ONEAR(n=1000) terms)'
144
+ ),
145
+ "bodycomplaints": (
146
+ '("COMPLAINTS" ONEAR(n=1000) terms OR "GRIEFS" ONEAR(n=1000) terms)'
147
+ ),
148
+ "bodylaw": (
149
+ '("THE LAW" ONEAR(n=1000) terms OR "EN DROIT" ONEAR(n=1000) terms)'
150
+ ),
151
+ "bodyreasons": (
152
+ '("FOR THESE REASONS" ONEAR(n=1000) terms OR '
153
+ '"PAR CES MOTIFS" ONEAR(n=1000) terms)'
154
+ ),
155
+ "bodyseparateopinions": (
156
+ '(("SEPARATE OPINION" OR "SEPARATE OPINIONS") ONEAR(n=5000) terms OR '
157
+ '"OPINION SÉPARÉE" ONEAR(n=5000) terms)'
158
+ ),
159
+ "bodyappendix": (
160
+ '("APPENDIX" ONEAR(n=1000) terms OR "ANNEXE" ONEAR(n=1000) terms)'
161
+ ),
162
+ }
163
+
164
+ def full_text_function(term, values):
165
+ return f"({','.join(values)})"
166
+
167
+ def date_function(term, values):
168
+ values = ['"' + i + '"' for i in values]
169
+ query = "(kpdate>=first_term AND kpdate<=second_term)"
170
+ first = values[0]
171
+ second = values[1]
172
+ if first == '""':
173
+ first = '"1900-01-01"'
174
+ if second == '""':
175
+ second = str(datetime.today().date())
176
+ query = query.replace("first_term", first)
177
+ query = query.replace("second_term", second)
178
+ return query
179
+
180
+ def advanced_function(term, values):
181
+ body = extra_cases_map.get(term)
182
+ query = body.replace("terms", ",".join(values))
183
+ return query
184
+
185
+ query_map = {
186
+ "docname": basic_function,
187
+ "appno": basic_function,
188
+ "scl": basic_function,
189
+ "rulesofcourt": basic_function,
190
+ "applicability": basic_function,
191
+ "ecli": basic_function,
192
+ "conclusion": basic_function,
193
+ "resolutionnumber": basic_function,
194
+ "separateopinions": basic_function,
195
+ "externalsources": basic_function,
196
+ "kpthesaurus": basic_function,
197
+ "advopidentifier": basic_function,
198
+ "documentcollectionid2": basic_function,
199
+ "itemid": basic_function, # Added support for itemid
200
+ "fulltext": full_text_function,
201
+ "kpdate": date_function,
202
+ "bodyprocedure": advanced_function,
203
+ "bodyfacts": advanced_function,
204
+ "bodycomplaints": advanced_function,
205
+ "bodylaw": advanced_function,
206
+ "bodyreasons": advanced_function,
207
+ "bodyseparateopinions": advanced_function,
208
+ "bodyappendix": advanced_function,
209
+ "languageisocode": basic_function,
210
+ }
211
+
212
+ start = link.index("{")
213
+ end = link.rindex("}")
214
+ json_str = link[start : end + 1].replace("'", '"')
215
+
216
+ # URL decode the JSON string before parsing
217
+ decoded_json_str = urllib.parse.unquote(json_str)
218
+
219
+ try:
220
+ link_dictionary = json.loads(decoded_json_str)
221
+ except json.JSONDecodeError:
222
+ # Fallback parsing for malformed JSON
223
+ link_dictionary = {}
224
+ pairs = decoded_json_str.strip("{}").split(",")
225
+ for pair in pairs:
226
+ key, value = pair.split(":", 1)
227
+ key = key.strip().strip('"')
228
+ value = value.strip().strip("[]").split(",")
229
+ link_dictionary[key] = [v.strip().strip('"') for v in value]
230
+
231
+ base_query = (
232
+ "https://hudoc.echr.coe.int/app/query/results?query=contentsitename:ECHR"
233
+ " AND (NOT (doctype=PR OR doctype=HFCOMOLD OR doctype=HECOMOLD)) AND "
234
+ "inPutter&select={select}&sort=itemid%20Ascending&start={start}"
235
+ "&length={length}"
236
+ )
237
+ query_elements = list()
238
+ if full_text_input:
239
+ query_elements.append(full_text_input)
240
+ date_addition = ""
241
+ for key in list(link_dictionary.keys()):
242
+ if key == "kpdate":
243
+ vals = link_dictionary.get(key)
244
+ funct = query_map.get(key)
245
+ date_addition = funct(key, vals)
246
+ elif key == "sort":
247
+ continue
248
+ else:
249
+ vals = link_dictionary.get(key)
250
+ funct = query_map.get(key)
251
+ if funct is not None:
252
+ query_elements.append(funct(key, vals))
253
+ else:
254
+ # Handle unknown keys by using basic_function as fallback
255
+ query_elements.append(basic_function(key, vals))
256
+ if date_addition:
257
+ query_elements.append(date_addition)
258
+ query_total = " AND ".join(query_elements)
259
+ final_query = base_query.replace("inPutter", query_total)
260
+
261
+ return final_query
262
+
263
+
264
+ def determine_meta_url(link, query_payload, start_date, end_date):
265
+ if query_payload:
266
+ # URL encode the query_payload to avoid issues with special characters
267
+ encoded_payload = urllib.parse.quote(query_payload, safe="")
268
+ META_URL = (
269
+ "http://hudoc.echr.coe.int/app/query/results"
270
+ f"?query={encoded_payload}"
271
+ "&select={select}"
272
+ + "&sort=itemid Ascending"
273
+ + "&start={start}&length={length}"
274
+ )
275
+ elif link:
276
+ META_URL = link_to_query(link)
277
+ else:
278
+ META_URL = (
279
+ "http://hudoc.echr.coe.int/app/query/results"
280
+ "?query=(contentsitename=ECHR) AND "
281
+ '(documentcollectionid2:"JUDGMENTS" OR '
282
+ 'documentcollectionid2:"COMMUNICATEDCASES" OR '
283
+ 'documentcollectionid2:"DECISIONS" OR '
284
+ 'documentcollectionid2:"CLIN") AND '
285
+ "lang_inputter"
286
+ "&select={select}"
287
+ + "&sort=itemid Ascending"
288
+ + "&start={start}&length={length}"
289
+ )
290
+ if start_date and end_date:
291
+ addition = f'(kpdate>="{start_date}" AND kpdate<="{end_date}")'
292
+ elif start_date:
293
+ end_date = datetime.today().date()
294
+ addition = f'(kpdate>="{start_date}" AND kpdate<="{end_date}")'
295
+ elif end_date:
296
+ start_date = "1900-01-01"
297
+ addition = f'(kpdate>="{start_date}" AND kpdate<="{end_date}")'
298
+ else:
299
+ addition = ""
300
+
301
+ if addition:
302
+ addition = " AND " + addition
303
+ META_URL = META_URL.replace("&select", addition + "&select")
304
+ return META_URL
305
+
306
+
307
+ def get_echr_metadata(
308
+ start_id,
309
+ end_id,
310
+ verbose,
311
+ fields,
312
+ start_date,
313
+ end_date,
314
+ link,
315
+ language,
316
+ query_payload,
317
+ # New configuration parameters with defaults for backward compatibility
318
+ batch_size=500,
319
+ timeout=60,
320
+ retry_attempts=3,
321
+ max_attempts=20,
322
+ days_per_batch=365,
323
+ progress_bar=True,
324
+ memory_efficient=True,
325
+ ):
326
+ """
327
+ Enhanced ECHR metadata extraction with improved batching, error handling, and memory management.
328
+
329
+ :param int start_id: The index to start the search from.
330
+ :param int end_id: The index to end search at, where the default fetches all results.
331
+ :param bool verbose: Whether or not to print extra information.
332
+ :param list fields: List of fields to extract.
333
+ :param str start_date: The point from which to save cases (YYYY-MM-DD format).
334
+ :param str end_date: The point before which to save cases (YYYY-MM-DD format).
335
+ :param str link: Custom HUDOC link.
336
+ :param list language: List of language codes.
337
+ :param str query_payload: Custom query payload.
338
+ :param int batch_size: Number of records to fetch per batch (max 500).
339
+ :param float timeout: Request timeout in seconds.
340
+ :param int retry_attempts: Number of retry attempts for failed requests.
341
+ :param int max_attempts: Maximum total attempts before giving up.
342
+ :param int days_per_batch: Number of days per date batch for large date ranges.
343
+ :param bool progress_bar: Whether to show progress bar.
344
+ :param bool memory_efficient: Whether to use memory-efficient processing.
345
+ :return: pandas.DataFrame or False if no data retrieved.
346
+ """
347
+ # Set default fields if not provided
348
+ if not fields:
349
+ fields = [
350
+ "itemid",
351
+ "applicability",
352
+ "appno",
353
+ "article",
354
+ "conclusion",
355
+ "docname",
356
+ "doctype",
357
+ "doctypebranch",
358
+ "ecli",
359
+ "importance",
360
+ "judgementdate",
361
+ "languageisocode",
362
+ "originatingbody",
363
+ "violation",
364
+ "nonviolation",
365
+ "extractedappno",
366
+ "scl",
367
+ "publishedby",
368
+ "representedby",
369
+ "respondent",
370
+ "separateopinion",
371
+ "sharepointid",
372
+ "externalsources",
373
+ "issue",
374
+ "referencedate",
375
+ "rulesofcourt",
376
+ "DocId",
377
+ "WorkId",
378
+ "Rank",
379
+ "Author",
380
+ "Size",
381
+ "Path",
382
+ "Description",
383
+ "Write",
384
+ "CollapsingStatus",
385
+ "HighlightedSummary",
386
+ "HighlightedProperties",
387
+ "contentclass",
388
+ "PictureThumbnailURL",
389
+ "ServerRedirectedURL",
390
+ "ServerRedirectedEmbedURL",
391
+ "ServerRedirectedPreviewURL",
392
+ "FileExtension",
393
+ "ContentTypeId",
394
+ "ParentLink",
395
+ "ViewsLifeTime",
396
+ "ViewsRecent",
397
+ "SectionNames",
398
+ "SectionIndexes",
399
+ "SiteLogo",
400
+ "SiteDescription",
401
+ "deeplinks",
402
+ "SiteName",
403
+ "IsDocument",
404
+ "LastModifiedTime",
405
+ "FileType",
406
+ "IsContainer",
407
+ "WebTemplate",
408
+ "SecondaryFileExtension",
409
+ "docaclmeta",
410
+ "OriginalPath",
411
+ "EditorOWSUSER",
412
+ "DisplayAuthor",
413
+ "ResultTypeIdList",
414
+ "PartitionId",
415
+ "UrlZone",
416
+ "AAMEnabledManagedProperties",
417
+ "ResultTypeId",
418
+ "rendertemplateid",
419
+ ]
420
+
421
+ # Determine if we need date batching
422
+ use_date_batching = start_date and end_date and not link and not query_payload
423
+
424
+ if use_date_batching:
425
+ date_ranges = get_date_ranges(start_date, end_date, days_per_batch)
426
+ if verbose:
427
+ logging.info(
428
+ f"Date range split into {len(date_ranges)} batches of {days_per_batch} days each"
429
+ )
430
+ else:
431
+ date_ranges = [(start_date, end_date)]
432
+
433
+ all_data = []
434
+ total_processed = 0
435
+ total_failed = 0
436
+
437
+ for batch_idx, (batch_start_date, batch_end_date) in enumerate(date_ranges):
438
+ if verbose:
439
+ logging.info(
440
+ f"Processing date batch {batch_idx + 1}/{len(date_ranges)}: {batch_start_date} to {batch_end_date}"
441
+ )
442
+
443
+ # Determine meta URL for this batch
444
+ META_URL = determine_meta_url(
445
+ link, query_payload, batch_start_date, batch_end_date
446
+ )
447
+
448
+ # URL encoding
449
+ META_URL = META_URL.replace(" ", "%20")
450
+ META_URL = META_URL.replace('"', "%22")
451
+ META_URL = META_URL.replace("%5C", "")
452
+
453
+ # Language handling
454
+ language_input = basic_function("languageisocode", language)
455
+ if not link:
456
+ META_URL = META_URL.replace("lang_inputter", language_input)
457
+
458
+ META_URL = META_URL.replace("{select}", ",".join(fields))
459
+
460
+ # Get total result count for this batch
461
+ url = META_URL.format(start=0, length=1)
462
+ if verbose:
463
+ logging.info(f"Checking result count: {url}")
464
+
465
+ r = get_r(url, timeout, retry_attempts, verbose, max_attempts)
466
+ if r is None:
467
+ logging.error(f"Failed to get result count for batch {batch_idx + 1}")
468
+ total_failed += 1
469
+ continue
470
+
471
+ try:
472
+ resultcount = r.json()["resultcount"]
473
+ if verbose:
474
+ logging.info(f"Available results for this batch: {resultcount}")
475
+ except (KeyError, ValueError) as e:
476
+ logging.error(f"Failed to parse result count: {e}")
477
+ total_failed += 1
478
+ continue
479
+
480
+ if resultcount == 0:
481
+ if verbose:
482
+ logging.info(f"No results found for batch {batch_idx + 1}")
483
+ continue
484
+
485
+ # Determine actual end_id for this batch
486
+ batch_end_id = min(end_id, resultcount) if end_id else resultcount
487
+ batch_start_id = start_id if batch_idx == 0 else 0
488
+
489
+ if verbose:
490
+ msg = f"Fetching {batch_end_id - batch_start_id} results from index {batch_start_id} to {batch_end_id}"
491
+ if batch_start_date and batch_end_date:
492
+ msg += f" for date range {batch_start_date} to {batch_end_date}"
493
+ logging.info(msg)
494
+
495
+ # Process this batch
496
+ batch_data = []
497
+ batch_processed = 0
498
+ batch_failed = 0
499
+
500
+ # Create progress bar for this batch
501
+ if progress_bar and (batch_end_id - batch_start_id) > batch_size:
502
+ pbar = tqdm(
503
+ total=batch_end_id - batch_start_id,
504
+ desc=f"Batch {batch_idx + 1}/{len(date_ranges)}",
505
+ unit="records",
506
+ leave=False,
507
+ )
508
+
509
+ # Process in batches of batch_size
510
+ for i in range(batch_start_id, batch_end_id, batch_size):
511
+ current_batch_size = min(batch_size, batch_end_id - i)
512
+
513
+ if verbose and not progress_bar:
514
+ logging.info(f"Fetching records {i} to {i + current_batch_size}")
515
+
516
+ url = META_URL.format(start=i, length=current_batch_size)
517
+ r = get_r(url, timeout, retry_attempts, verbose, max_attempts)
518
+
519
+ if r is not None:
520
+ try:
521
+ temp_dict = r.json()["results"]
522
+ for result in temp_dict:
523
+ batch_data.append(result["columns"])
524
+ batch_processed += len(temp_dict)
525
+
526
+ if progress_bar and (batch_end_id - batch_start_id) > batch_size:
527
+ pbar.update(len(temp_dict))
528
+
529
+ except (KeyError, ValueError) as e:
530
+ logging.error(f"Failed to parse results: {e}")
531
+ batch_failed += 1
532
+ else:
533
+ batch_failed += 1
534
+ if progress_bar and (batch_end_id - batch_start_id) > batch_size:
535
+ pbar.update(current_batch_size)
536
+
537
+ # Memory management: process data in chunks if memory_efficient is True
538
+ if memory_efficient and len(batch_data) > 10000:
539
+ all_data.extend(batch_data)
540
+ batch_data = []
541
+ gc.collect() # Force garbage collection
542
+
543
+ # Close progress bar for this batch
544
+ if progress_bar and (batch_end_id - batch_start_id) > batch_size:
545
+ pbar.close()
546
+
547
+ # Add remaining batch data
548
+ all_data.extend(batch_data)
549
+ total_processed += batch_processed
550
+ total_failed += batch_failed
551
+
552
+ if verbose:
553
+ logging.info(
554
+ f"Batch {batch_idx + 1} completed: {batch_processed} processed, {batch_failed} failed"
555
+ )
556
+
557
+ # Final summary
558
+ if verbose:
559
+ logging.info(
560
+ f"Total processing complete: {total_processed} records processed, {total_failed} batches failed"
561
+ )
562
+
563
+ if len(all_data) == 0:
564
+ logging.warning("No data retrieved from any batch")
565
+ return False
566
+
567
+ # Create DataFrame
568
+ df = pd.DataFrame.from_records(all_data)
569
+
570
+ if verbose:
571
+ logging.info(
572
+ f"Created DataFrame with {len(df)} records and {len(df.columns)} columns"
573
+ )
574
+
575
+ return df