codex-agent-framework 0.1.1__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,1240 @@
1
+ """Text extraction utilities for various sources.
2
+
3
+ Supports extraction from:
4
+ - Files (PDF, DOCX, ODT, HTML, text files, CSV, JSON, XML, YAML, TOML, INI, XLSX, ODS)
5
+ - Archives (ZIP, TAR, TAR.GZ, TAR.BZ2)
6
+ - URLs (web pages, documents)
7
+ - Python objects (classes, functions, modules, instances)
8
+ - File system (directory listings)
9
+ - Email files (EML)
10
+ """
11
+
12
+ from typing import Any, Optional, Dict, List
13
+ import os
14
+ import json
15
+ import inspect
16
+ import re
17
+ from io import BytesIO, StringIO, TextIOWrapper
18
+ import csv
19
+ import xml.etree.ElementTree as ET
20
+ import configparser
21
+ import zipfile
22
+ import tarfile
23
+ import email
24
+ from email import policy
25
+ from pathlib import Path
26
+
27
+ # For gitignore handling
28
+ try:
29
+ import pathspec
30
+ HAS_PATHSPEC = True
31
+ except ImportError:
32
+ HAS_PATHSPEC = False
33
+
34
+ # Try to import optional stdlib modules
35
+ try:
36
+ import tomllib # Python 3.11+
37
+ except ImportError:
38
+ try:
39
+ import tomli as tomllib # Fallback for older Python
40
+ except ImportError:
41
+ tomllib = None
42
+
43
+ import requests
44
+ from bs4 import BeautifulSoup
45
+ import PyPDF2
46
+ import docx
47
+ from odf import opendocument, text, teletype
48
+ import yaml
49
+
50
+ # Optional imports for extended functionality
51
+ try:
52
+ import openpyxl
53
+ HAS_OPENPYXL = True
54
+ except ImportError:
55
+ HAS_OPENPYXL = False
56
+
57
+ try:
58
+ import trafilatura
59
+ HAS_TRAFILATURA = True
60
+ except ImportError:
61
+ HAS_TRAFILATURA = False
62
+
63
+ from ..get_webdriver import get_webdriver
64
+
65
+
66
+ # ============================================================================
67
+ # Text Processing Utilities
68
+ # ============================================================================
69
+
70
+ def strip_newlines(string: str) -> str:
71
+ """Remove leading/trailing newlines and collapse multiple newlines.
72
+
73
+ Args:
74
+ string: Input text to clean
75
+
76
+ Returns:
77
+ Cleaned text with normalized newlines
78
+ """
79
+ string = string.strip('\n')
80
+ return re.sub(r'\n{2,}', '\n', string)
81
+
82
+
83
+ # ============================================================================
84
+ # Document Processing
85
+ # ============================================================================
86
+
87
+ def _extract_pdf_text(file_obj) -> str:
88
+ """Extract text from PDF file object.
89
+
90
+ Args:
91
+ file_obj: File-like object containing PDF data
92
+
93
+ Returns:
94
+ Extracted text from all pages
95
+ """
96
+ reader = PyPDF2.PdfReader(file_obj)
97
+ return "".join(page.extract_text() for page in reader.pages)
98
+
99
+
100
+ def _extract_docx_text(file_obj) -> str:
101
+ """Extract text from DOCX file object.
102
+
103
+ Args:
104
+ file_obj: File-like object or path to DOCX file
105
+
106
+ Returns:
107
+ Extracted text from all paragraphs
108
+ """
109
+ doc = docx.Document(file_obj)
110
+ return "\n".join(para.text for para in doc.paragraphs)
111
+
112
+
113
+ def _extract_odt_text(file_obj) -> str:
114
+ """Extract text from ODT file object.
115
+
116
+ Args:
117
+ file_obj: File-like object or path to ODT file
118
+
119
+ Returns:
120
+ Extracted text from all paragraphs
121
+ """
122
+ doc = opendocument.load(file_obj)
123
+ paragraphs = doc.getElementsByType(text.P)
124
+ return '\n'.join(teletype.extractText(para) for para in paragraphs)
125
+
126
+
127
+ def _extract_html_text(file_obj) -> str:
128
+ """Extract text from HTML file object.
129
+
130
+ Args:
131
+ file_obj: File-like object containing HTML
132
+
133
+ Returns:
134
+ Extracted text content
135
+ """
136
+ soup = BeautifulSoup(file_obj, 'html.parser')
137
+ return soup.get_text()
138
+
139
+
140
+ def _extract_csv_text(file_obj) -> str:
141
+ """Extract text from CSV file object.
142
+
143
+ Args:
144
+ file_obj: File-like object containing CSV data
145
+
146
+ Returns:
147
+ Formatted CSV content as text
148
+ """
149
+ content = file_obj.read()
150
+ if isinstance(content, bytes):
151
+ content = content.decode('utf-8')
152
+
153
+ reader = csv.reader(StringIO(content))
154
+ lines = []
155
+ for row in reader:
156
+ lines.append(' | '.join(row))
157
+ return '\n'.join(lines)
158
+
159
+
160
+ def _extract_json_text(file_obj) -> str:
161
+ """Extract text from JSON file object.
162
+
163
+ Args:
164
+ file_obj: File-like object containing JSON data
165
+
166
+ Returns:
167
+ Pretty-printed JSON content
168
+ """
169
+ content = file_obj.read()
170
+ if isinstance(content, bytes):
171
+ content = content.decode('utf-8')
172
+
173
+ data = json.loads(content)
174
+ return json.dumps(data, indent=2, ensure_ascii=False)
175
+
176
+
177
+ def _extract_xml_text(file_obj) -> str:
178
+ """Extract text from XML file object.
179
+
180
+ Args:
181
+ file_obj: File-like object containing XML data
182
+
183
+ Returns:
184
+ XML content as formatted text
185
+ """
186
+ content = file_obj.read()
187
+ if isinstance(content, bytes):
188
+ content = content.decode('utf-8')
189
+
190
+ try:
191
+ tree = ET.ElementTree(ET.fromstring(content))
192
+ root = tree.getroot()
193
+
194
+ def extract_element(elem, level=0):
195
+ """Recursively extract text from XML elements."""
196
+ indent = ' ' * level
197
+ text_parts = []
198
+
199
+ # Add element tag
200
+ text_parts.append(f"{indent}<{elem.tag}>")
201
+
202
+ # Add element text if present
203
+ if elem.text and elem.text.strip():
204
+ text_parts.append(f"{indent} {elem.text.strip()}")
205
+
206
+ # Recurse into children
207
+ for child in elem:
208
+ text_parts.append(extract_element(child, level + 1))
209
+
210
+ return '\n'.join(text_parts)
211
+
212
+ return extract_element(root)
213
+ except ET.ParseError:
214
+ # If parsing fails, return raw content
215
+ return content
216
+
217
+
218
+ def _extract_yaml_text(file_obj) -> str:
219
+ """Extract text from YAML file object.
220
+
221
+ Args:
222
+ file_obj: File-like object containing YAML data
223
+
224
+ Returns:
225
+ Pretty-printed YAML content
226
+ """
227
+ content = file_obj.read()
228
+ if isinstance(content, bytes):
229
+ content = content.decode('utf-8')
230
+
231
+ data = yaml.safe_load(content)
232
+ return yaml.dump(data, default_flow_style=False, allow_unicode=True)
233
+
234
+
235
+ def _extract_toml_text(file_obj) -> str:
236
+ """Extract text from TOML file object.
237
+
238
+ Args:
239
+ file_obj: File-like object containing TOML data
240
+
241
+ Returns:
242
+ TOML content as text (raw if tomllib not available)
243
+ """
244
+ content = file_obj.read()
245
+ if isinstance(content, bytes):
246
+ content = content.decode('utf-8')
247
+
248
+ if tomllib is None:
249
+ # If tomllib not available, return raw content
250
+ return content
251
+
252
+ data = tomllib.loads(content)
253
+ # Since there's no standard toml dump in stdlib, return pretty JSON representation
254
+ return json.dumps(data, indent=2, ensure_ascii=False)
255
+
256
+
257
+ def _extract_ini_text(file_obj) -> str:
258
+ """Extract text from INI/CFG file object.
259
+
260
+ Args:
261
+ file_obj: File-like object containing INI data
262
+
263
+ Returns:
264
+ Formatted INI content as text
265
+ """
266
+ content = file_obj.read()
267
+ if isinstance(content, bytes):
268
+ content = content.decode('utf-8')
269
+
270
+ config = configparser.ConfigParser()
271
+ config.read_string(content)
272
+
273
+ lines = []
274
+ for section in config.sections():
275
+ lines.append(f"[{section}]")
276
+ for key, value in config.items(section):
277
+ lines.append(f"{key} = {value}")
278
+ lines.append("")
279
+
280
+ return '\n'.join(lines)
281
+
282
+
283
+ def _extract_xlsx_text(file_obj) -> str:
284
+ """Extract text from XLSX file object.
285
+
286
+ Args:
287
+ file_obj: File-like object or path to XLSX file
288
+
289
+ Returns:
290
+ Formatted spreadsheet content as text
291
+ """
292
+ if not HAS_OPENPYXL:
293
+ return "Error: openpyxl not installed. Install with: pip install openpyxl"
294
+
295
+ wb = openpyxl.load_workbook(file_obj, data_only=True)
296
+ lines = []
297
+
298
+ for sheet_name in wb.sheetnames:
299
+ sheet = wb[sheet_name]
300
+ lines.append(f"=== Sheet: {sheet_name} ===\n")
301
+
302
+ for row in sheet.iter_rows(values_only=True):
303
+ row_text = ' | '.join(str(cell) if cell is not None else '' for cell in row)
304
+ if row_text.strip():
305
+ lines.append(row_text)
306
+
307
+ lines.append("")
308
+
309
+ return '\n'.join(lines)
310
+
311
+
312
+ def _extract_ods_text(file_obj) -> str:
313
+ """Extract text from ODS spreadsheet file object.
314
+
315
+ Args:
316
+ file_obj: File-like object or path to ODS file
317
+
318
+ Returns:
319
+ Formatted spreadsheet content as text
320
+ """
321
+ doc = opendocument.load(file_obj)
322
+ from odf.table import Table, TableRow, TableCell
323
+
324
+ lines = []
325
+ tables = doc.getElementsByType(Table)
326
+
327
+ for table in tables:
328
+ table_name = table.getAttribute('name')
329
+ if table_name:
330
+ lines.append(f"=== Sheet: {table_name} ===\n")
331
+
332
+ rows = table.getElementsByType(TableRow)
333
+ for row in rows:
334
+ cells = row.getElementsByType(TableCell)
335
+ row_data = []
336
+ for cell in cells:
337
+ cell_text = teletype.extractText(cell)
338
+ row_data.append(cell_text)
339
+
340
+ row_text = ' | '.join(row_data)
341
+ if row_text.strip():
342
+ lines.append(row_text)
343
+
344
+ lines.append("")
345
+
346
+ return '\n'.join(lines)
347
+
348
+
349
+ def _extract_eml_text(file_obj) -> str:
350
+ """Extract text from email file object.
351
+
352
+ Args:
353
+ file_obj: File-like object containing email data
354
+
355
+ Returns:
356
+ Formatted email content as text
357
+ """
358
+ content = file_obj.read()
359
+ if isinstance(content, bytes):
360
+ msg = email.message_from_bytes(content, policy=policy.default)
361
+ else:
362
+ msg = email.message_from_string(content, policy=policy.default)
363
+
364
+ lines = []
365
+
366
+ # Extract headers
367
+ lines.append("=== Email Headers ===")
368
+ lines.append(f"From: {msg.get('From', 'Unknown')}")
369
+ lines.append(f"To: {msg.get('To', 'Unknown')}")
370
+ lines.append(f"Subject: {msg.get('Subject', 'No Subject')}")
371
+ lines.append(f"Date: {msg.get('Date', 'Unknown')}")
372
+ lines.append("")
373
+
374
+ # Extract body
375
+ lines.append("=== Email Body ===")
376
+ if msg.is_multipart():
377
+ for part in msg.walk():
378
+ content_type = part.get_content_type()
379
+ if content_type == 'text/plain':
380
+ lines.append(part.get_content())
381
+ else:
382
+ lines.append(msg.get_content())
383
+
384
+ return '\n'.join(lines)
385
+
386
+
387
+ # ============================================================================
388
+ # Archive Handlers
389
+ # ============================================================================
390
+
391
+ def _extract_zip_text(file_obj) -> str:
392
+ """Extract file listing from ZIP archive.
393
+
394
+ Args:
395
+ file_obj: File-like object or path to ZIP file
396
+
397
+ Returns:
398
+ List of files in the archive with sizes
399
+ """
400
+ if isinstance(file_obj, str):
401
+ zf = zipfile.ZipFile(file_obj, 'r')
402
+ else:
403
+ zf = zipfile.ZipFile(file_obj)
404
+
405
+ lines = ["=== ZIP Archive Contents ===\n"]
406
+
407
+ for info in zf.infolist():
408
+ size_str = f"{info.file_size:,} bytes"
409
+ lines.append(f"{info.filename:<50} {size_str:>15}")
410
+
411
+ lines.append(f"\nTotal files: {len(zf.filelist)}")
412
+
413
+ return '\n'.join(lines)
414
+
415
+
416
+ def _extract_tar_text(file_obj, mode='r') -> str:
417
+ """Extract file listing from TAR archive.
418
+
419
+ Args:
420
+ file_obj: File-like object or path to TAR file
421
+ mode: Open mode ('r', 'r:gz', 'r:bz2', etc.)
422
+
423
+ Returns:
424
+ List of files in the archive with sizes
425
+ """
426
+ if isinstance(file_obj, str):
427
+ tf = tarfile.open(file_obj, mode)
428
+ else:
429
+ tf = tarfile.open(fileobj=file_obj, mode=mode)
430
+
431
+ lines = ["=== TAR Archive Contents ===\n"]
432
+
433
+ for member in tf.getmembers():
434
+ size_str = f"{member.size:,} bytes"
435
+ type_char = 'd' if member.isdir() else 'f'
436
+ lines.append(f"{type_char} {member.name:<50} {size_str:>15}")
437
+
438
+ lines.append(f"\nTotal entries: {len(tf.getmembers())}")
439
+
440
+ return '\n'.join(lines)
441
+
442
+
443
+ # ============================================================================
444
+ # File System Handlers
445
+ # ============================================================================
446
+
447
+ def _load_gitignore_patterns(directory: str) -> Optional['pathspec.PathSpec']:
448
+ """Load gitignore patterns from default and local .gitignore files.
449
+
450
+ Args:
451
+ directory: Directory to scan for local .gitignore
452
+
453
+ Returns:
454
+ PathSpec object with combined patterns, or None if pathspec not available
455
+ """
456
+ if not HAS_PATHSPEC:
457
+ return None
458
+
459
+ patterns = []
460
+
461
+ # Load default gitignore patterns bundled with get_text
462
+ default_gitignore = Path(__file__).parent / 'default_gitignore'
463
+ if default_gitignore.exists():
464
+ with open(default_gitignore, 'r', encoding='utf-8') as f:
465
+ patterns.extend(f.readlines())
466
+
467
+ # Load local .gitignore if present
468
+ local_gitignore = Path(directory) / '.gitignore'
469
+ if local_gitignore.exists():
470
+ with open(local_gitignore, 'r', encoding='utf-8') as f:
471
+ patterns.extend(f.readlines())
472
+
473
+ # Create PathSpec from patterns
474
+ if patterns:
475
+ return pathspec.PathSpec.from_lines('gitwildmatch', patterns)
476
+
477
+ return None
478
+
479
+
480
+ def handle_directory(path: str, max_depth: int = 3) -> str:
481
+ """Generate a tree-like representation of directory structure.
482
+
483
+ Automatically applies sensible exclusions from:
484
+ - Default gitignore patterns (bundled with get_text)
485
+ - Local .gitignore file (if present)
486
+
487
+ This filters out common junk directories like .git, __pycache__, node_modules, etc.
488
+ Recursion is limited to max_depth levels to avoid overwhelming output.
489
+
490
+ Args:
491
+ path: Path to directory
492
+ max_depth: Maximum recursion depth (default: 3)
493
+
494
+ Returns:
495
+ Tree structure as text, or error message
496
+ """
497
+ if not os.path.exists(path):
498
+ return f'The path {path} does not exist.'
499
+
500
+ if not os.path.isdir(path):
501
+ return f'The path {path} is not a valid directory.'
502
+
503
+ # Load gitignore patterns
504
+ spec = _load_gitignore_patterns(path)
505
+
506
+ # Track directories that weren't fully explored
507
+ unexplored_dirs = []
508
+
509
+ def should_ignore(item_path: str, base_path: str) -> bool:
510
+ """Check if path should be ignored based on gitignore patterns."""
511
+ if spec is None:
512
+ return False
513
+
514
+ # Get relative path from base
515
+ try:
516
+ rel_path = os.path.relpath(item_path, base_path)
517
+ # pathspec expects forward slashes
518
+ rel_path = rel_path.replace(os.sep, '/')
519
+ # Check if it's a directory
520
+ if os.path.isdir(item_path):
521
+ rel_path += '/'
522
+ return spec.match_file(rel_path)
523
+ except ValueError:
524
+ # Handles case where paths are on different drives on Windows
525
+ return False
526
+
527
+ def has_contents(folder: str) -> bool:
528
+ """Check if a directory has non-ignored contents."""
529
+ try:
530
+ contents = os.listdir(folder)
531
+ for item in contents:
532
+ item_path = os.path.join(folder, item)
533
+ if not should_ignore(item_path, path):
534
+ return True
535
+ return False
536
+ except PermissionError:
537
+ return False
538
+
539
+ def recurse_folder(folder: str, prefix: str = '', base_path: str = None, depth: int = 0) -> str:
540
+ """Recursively build directory tree with gitignore filtering and depth limit."""
541
+ if base_path is None:
542
+ base_path = folder
543
+
544
+ try:
545
+ contents = os.listdir(folder)
546
+ except PermissionError:
547
+ return prefix + '├── [Permission Denied]\n'
548
+
549
+ # Filter out ignored items
550
+ filtered_contents = []
551
+ for item in contents:
552
+ item_path = os.path.join(folder, item)
553
+ if not should_ignore(item_path, base_path):
554
+ filtered_contents.append(item)
555
+
556
+ output = ''
557
+ for i, item in enumerate(filtered_contents):
558
+ item_path = os.path.join(folder, item)
559
+ is_dir = os.path.isdir(item_path)
560
+
561
+ # Check if we've reached max depth for this directory
562
+ depth_limit_reached = is_dir and depth >= max_depth
563
+
564
+ # Check if directory has unexplored contents
565
+ has_more = is_dir and depth_limit_reached and has_contents(item_path)
566
+
567
+ # Format the item name with appropriate indicators
568
+ if has_more:
569
+ item_display = f"{item}/ [...explore further]"
570
+ unexplored_dirs.append(item_path)
571
+ elif is_dir and depth < max_depth:
572
+ # Check if empty after filtering
573
+ if not has_contents(item_path):
574
+ item_display = f"{item}/ [empty]"
575
+ else:
576
+ item_display = f"{item}/"
577
+ elif is_dir:
578
+ item_display = f"{item}/"
579
+ else:
580
+ item_display = item
581
+
582
+ output += prefix + '├── ' + item_display + '\n'
583
+
584
+ # Recurse into subdirectories if under depth limit
585
+ if is_dir and depth < max_depth:
586
+ new_prefix = prefix + (' ' if i == len(filtered_contents) - 1 else '│ ')
587
+ output += recurse_folder(item_path, new_prefix, base_path, depth + 1)
588
+
589
+ return output
590
+
591
+ result = path + '\n' + recurse_folder(path, depth=0)
592
+
593
+ # Add notes about filtering and depth limit
594
+ notes = []
595
+
596
+ if spec is not None:
597
+ notes.append("Common junk directories (.git, __pycache__, node_modules, etc.) are automatically filtered")
598
+ elif HAS_PATHSPEC:
599
+ notes.append("No gitignore patterns found, showing all files")
600
+ else:
601
+ notes.append("Warning: pathspec not installed. Install with 'pip install pathspec' for gitignore filtering")
602
+
603
+ notes.append(f"Recursion limited to {max_depth} levels deep")
604
+
605
+ if unexplored_dirs:
606
+ notes.append(f"Directories with unexplored contents ({len(unexplored_dirs)} total):")
607
+ for unexplored_dir in unexplored_dirs[:10]: # Show max 10 examples
608
+ notes.append(f" - {unexplored_dir}")
609
+ if len(unexplored_dirs) > 10:
610
+ notes.append(f" ... and {len(unexplored_dirs) - 10} more")
611
+ notes.append("Tip: Call get_text() on specific subdirectories to explore them further")
612
+
613
+ result += '\n\n[' + '\n '.join(notes) + ']'
614
+
615
+ return result
616
+
617
+
618
+ def handle_file(source: str) -> str:
619
+ """Extract text from a file based on its extension.
620
+
621
+ Supported formats:
622
+ - Documents: PDF, DOCX, ODT, HTML, TXT
623
+ - Data: CSV, JSON, XML, YAML, TOML, INI, CFG
624
+ - Spreadsheets: XLSX, ODS
625
+ - Archives: ZIP, TAR, TAR.GZ, TAR.BZ2, TGZ, TBZ2
626
+ - Email: EML
627
+
628
+ Args:
629
+ source: Path to file
630
+
631
+ Returns:
632
+ Extracted text content, or error message
633
+ """
634
+ ext = os.path.splitext(source)[1].lower()
635
+
636
+ try:
637
+ # Document formats
638
+ if ext == '.pdf':
639
+ with open(source, 'rb') as file:
640
+ return _extract_pdf_text(file)
641
+
642
+ elif ext == '.docx':
643
+ return _extract_docx_text(source)
644
+
645
+ elif ext == '.odt':
646
+ return _extract_odt_text(source)
647
+
648
+ elif ext in ('.html', '.htm'):
649
+ with open(source, 'r', encoding='utf-8') as f:
650
+ return _extract_html_text(f)
651
+
652
+ # Data formats
653
+ elif ext == '.csv':
654
+ with open(source, 'rb') as f:
655
+ return _extract_csv_text(f)
656
+
657
+ elif ext == '.json':
658
+ with open(source, 'rb') as f:
659
+ return _extract_json_text(f)
660
+
661
+ elif ext == '.xml':
662
+ with open(source, 'rb') as f:
663
+ return _extract_xml_text(f)
664
+
665
+ elif ext in ('.yaml', '.yml'):
666
+ with open(source, 'rb') as f:
667
+ return _extract_yaml_text(f)
668
+
669
+ elif ext == '.toml':
670
+ with open(source, 'rb') as f:
671
+ return _extract_toml_text(f)
672
+
673
+ elif ext in ('.ini', '.cfg', '.conf'):
674
+ with open(source, 'rb') as f:
675
+ return _extract_ini_text(f)
676
+
677
+ # Spreadsheet formats
678
+ elif ext == '.xlsx':
679
+ return _extract_xlsx_text(source)
680
+
681
+ elif ext == '.ods':
682
+ return _extract_ods_text(source)
683
+
684
+ # Archive formats
685
+ elif ext == '.zip':
686
+ return _extract_zip_text(source)
687
+
688
+ elif ext in ('.tar', '.tar.gz', '.tgz'):
689
+ mode = 'r:gz' if ext in ('.tar.gz', '.tgz') else 'r'
690
+ with open(source, 'rb') as f:
691
+ return _extract_tar_text(f, mode=mode)
692
+
693
+ elif ext in ('.tar.bz2', '.tbz2'):
694
+ with open(source, 'rb') as f:
695
+ return _extract_tar_text(f, mode='r:bz2')
696
+
697
+ # Email format
698
+ elif ext == '.eml':
699
+ with open(source, 'rb') as f:
700
+ return _extract_eml_text(f)
701
+
702
+ else:
703
+ # Plain text file
704
+ with open(source, 'r', encoding='utf-8') as f:
705
+ return f.read()
706
+
707
+ except FileNotFoundError:
708
+ return f"Unable to process file. File not found: {source}"
709
+ except PermissionError:
710
+ return f"Unable to process file. Permission denied: {source}"
711
+ except Exception as e:
712
+ return f"Error while attempting to read file: {e}"
713
+
714
+
715
+ # ============================================================================
716
+ # Web Handlers
717
+ # ============================================================================
718
+
719
+ def _extract_with_beautifulsoup(html_content: str) -> str:
720
+ """Extract text from HTML using BeautifulSoup with smart filtering.
721
+
722
+ Removes common non-content elements like scripts, styles, navigation, etc.
723
+
724
+ Args:
725
+ html_content: HTML content as string
726
+
727
+ Returns:
728
+ Extracted text content
729
+ """
730
+ soup = BeautifulSoup(html_content, 'html.parser')
731
+
732
+ # Remove non-content elements
733
+ for element in soup(['script', 'style', 'nav', 'header', 'footer',
734
+ 'aside', 'iframe', 'noscript']):
735
+ element.decompose()
736
+
737
+ # Try to find main content area first
738
+ main_content = soup.find('main') or soup.find('article') or soup.find('body')
739
+
740
+ if main_content:
741
+ text = main_content.get_text(separator='\n', strip=True)
742
+ else:
743
+ text = soup.get_text(separator='\n', strip=True)
744
+
745
+ return strip_newlines(text)
746
+
747
+
748
+ def _is_content_sufficient(text: str, min_length: int = 100) -> bool:
749
+ """Check if extracted content seems sufficient (not empty or too short).
750
+
751
+ Args:
752
+ text: Extracted text content
753
+ min_length: Minimum acceptable length in characters
754
+
755
+ Returns:
756
+ True if content seems sufficient, False otherwise
757
+ """
758
+ if not text or len(text.strip()) < min_length:
759
+ return False
760
+
761
+ # Check if content is not just boilerplate
762
+ words = text.split()
763
+ if len(words) < 20: # Less than 20 words is suspicious
764
+ return False
765
+
766
+ return True
767
+
768
+
769
+ def extract_webpage_content(url: str, force_selenium: bool = False) -> str:
770
+ """Extract text content from a web page using a hybrid approach.
771
+
772
+ Strategy:
773
+ 1. If force_selenium=False:
774
+ - Try trafilatura first (if available) - fast and smart
775
+ - If trafilatura unavailable or content insufficient, try BeautifulSoup
776
+ - If still insufficient, fallback to Selenium (handles JavaScript)
777
+ 2. If force_selenium=True:
778
+ - Use Selenium directly (for known dynamic pages)
779
+
780
+ Args:
781
+ url: URL to fetch
782
+ force_selenium: If True, skip lightweight methods and use Selenium directly
783
+
784
+ Returns:
785
+ Extracted text content from the page
786
+ """
787
+ # Force Selenium path
788
+ if force_selenium:
789
+ driver = None
790
+ try:
791
+ driver = get_webdriver()
792
+ driver.get(url)
793
+ page_source = driver.page_source
794
+ return _extract_with_beautifulsoup(page_source)
795
+ finally:
796
+ if driver:
797
+ driver.quit()
798
+
799
+ # Try lightweight methods first
800
+ try:
801
+ # Fetch page content
802
+ response = requests.get(url, timeout=30)
803
+ response.raise_for_status()
804
+ html_content = response.text
805
+
806
+ # Method 1: Try trafilatura if available (best for articles/blogs)
807
+ if HAS_TRAFILATURA:
808
+ extracted = trafilatura.extract(html_content, include_comments=False,
809
+ include_tables=True)
810
+ if extracted and _is_content_sufficient(extracted):
811
+ return strip_newlines(extracted)
812
+
813
+ # Method 2: Try BeautifulSoup with smart filtering
814
+ text = _extract_with_beautifulsoup(html_content)
815
+ if _is_content_sufficient(text):
816
+ return text
817
+
818
+ # Method 3: Content seems insufficient, might need JavaScript
819
+ # Fallback to Selenium
820
+ driver = None
821
+ try:
822
+ driver = get_webdriver()
823
+ driver.get(url)
824
+ page_source = driver.page_source
825
+ return _extract_with_beautifulsoup(page_source)
826
+ finally:
827
+ if driver:
828
+ driver.quit()
829
+
830
+ except requests.exceptions.RequestException:
831
+ # If requests fails, try Selenium as last resort
832
+ driver = None
833
+ try:
834
+ driver = get_webdriver()
835
+ driver.get(url)
836
+ page_source = driver.page_source
837
+ return _extract_with_beautifulsoup(page_source)
838
+ finally:
839
+ if driver:
840
+ driver.quit()
841
+
842
+
843
+ def handle_url(source: str) -> str:
844
+ """Extract text from a URL (document or web page).
845
+
846
+ Automatically handles various document types served over HTTP based on
847
+ content-type or URL extension, falling back to web scraping for HTML pages.
848
+
849
+ Supported formats:
850
+ - Documents: PDF, DOCX, ODT
851
+ - Data: CSV, JSON, XML, YAML
852
+ - Spreadsheets: XLSX, ODS
853
+ - Archives: ZIP
854
+ - Web pages: HTML (via Selenium)
855
+
856
+ Args:
857
+ source: URL to fetch
858
+
859
+ Returns:
860
+ Extracted text content, or error message
861
+ """
862
+ try:
863
+ response = requests.get(source, timeout=30)
864
+ response.raise_for_status()
865
+
866
+ content_type = response.headers.get('content-type', '').lower()
867
+
868
+ # Handle PDF documents
869
+ if 'application/pdf' in content_type or source.endswith('.pdf'):
870
+ with BytesIO(response.content) as file:
871
+ return _extract_pdf_text(file)
872
+
873
+ # Handle DOCX documents
874
+ elif 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' in content_type \
875
+ or source.endswith('.docx'):
876
+ with BytesIO(response.content) as file:
877
+ return _extract_docx_text(file)
878
+
879
+ # Handle ODT documents
880
+ elif 'application/vnd.oasis.opendocument.text' in content_type \
881
+ or source.endswith('.odt'):
882
+ with BytesIO(response.content) as file:
883
+ return _extract_odt_text(file)
884
+
885
+ # Handle XLSX spreadsheets
886
+ elif 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in content_type \
887
+ or source.endswith('.xlsx'):
888
+ with BytesIO(response.content) as file:
889
+ return _extract_xlsx_text(file)
890
+
891
+ # Handle ODS spreadsheets
892
+ elif 'application/vnd.oasis.opendocument.spreadsheet' in content_type \
893
+ or source.endswith('.ods'):
894
+ with BytesIO(response.content) as file:
895
+ return _extract_ods_text(file)
896
+
897
+ # Handle CSV
898
+ elif 'text/csv' in content_type or source.endswith('.csv'):
899
+ with BytesIO(response.content) as file:
900
+ return _extract_csv_text(file)
901
+
902
+ # Handle JSON
903
+ elif 'application/json' in content_type or source.endswith('.json'):
904
+ with BytesIO(response.content) as file:
905
+ return _extract_json_text(file)
906
+
907
+ # Handle XML
908
+ elif 'application/xml' in content_type or 'text/xml' in content_type \
909
+ or source.endswith('.xml'):
910
+ with BytesIO(response.content) as file:
911
+ return _extract_xml_text(file)
912
+
913
+ # Handle YAML
914
+ elif 'application/x-yaml' in content_type or source.endswith(('.yaml', '.yml')):
915
+ with BytesIO(response.content) as file:
916
+ return _extract_yaml_text(file)
917
+
918
+ # Handle ZIP archives
919
+ elif 'application/zip' in content_type or source.endswith('.zip'):
920
+ with BytesIO(response.content) as file:
921
+ return _extract_zip_text(file)
922
+
923
+ # Default to web scraping
924
+ else:
925
+ return extract_webpage_content(source)
926
+
927
+ except requests.exceptions.Timeout:
928
+ return f"Unable to process URL. Request timed out: {source}"
929
+ except requests.exceptions.RequestException as e:
930
+ return f"Unable to process URL. Connection error: {e}"
931
+
932
+
933
+ # ============================================================================
934
+ # Python Object Inspection
935
+ # ============================================================================
936
+
937
+ def handle_class(source: type) -> str:
938
+ """Generate detailed JSON representation of a class.
939
+
940
+ Args:
941
+ source: Class object to inspect
942
+
943
+ Returns:
944
+ JSON string with class metadata
945
+ """
946
+ class_info = {
947
+ "type": "class",
948
+ "name": source.__name__,
949
+ "doc": inspect.getdoc(source) or "No documentation available.",
950
+ "base_classes": [base.__name__ for base in inspect.getmro(source)[1:]],
951
+ "methods": {},
952
+ "class_methods": {},
953
+ "static_methods": {},
954
+ "properties": {},
955
+ "attributes": {},
956
+ "string_repr": repr(source)
957
+ }
958
+
959
+ # Process methods, class methods, static methods, and properties
960
+ for name, member in inspect.getmembers(source):
961
+ if inspect.isfunction(member):
962
+ class_info["methods"][name] = {
963
+ "doc": inspect.getdoc(member) or "No documentation available.",
964
+ "signature": str(inspect.signature(member))
965
+ }
966
+ elif isinstance(member, classmethod):
967
+ class_info["class_methods"][name] = {
968
+ "doc": inspect.getdoc(member) or "No documentation available.",
969
+ "signature": str(inspect.signature(member.__func__))
970
+ }
971
+ elif isinstance(member, staticmethod):
972
+ class_info["static_methods"][name] = {
973
+ "doc": inspect.getdoc(member) or "No documentation available."
974
+ }
975
+ elif isinstance(member, property):
976
+ class_info["properties"][name] = {
977
+ "doc": inspect.getdoc(member) or "No documentation available."
978
+ }
979
+
980
+ # Process attributes
981
+ for name in dir(source):
982
+ if not name.startswith('__') and not inspect.isroutine(getattr(source, name)):
983
+ try:
984
+ attribute = inspect.getattr_static(source, name)
985
+ serialized = json.dumps(attribute)
986
+ except:
987
+ serialized = repr(attribute)
988
+
989
+ class_info["attributes"][name] = {
990
+ "value": serialized,
991
+ "type": type(attribute).__name__
992
+ }
993
+
994
+ return json.dumps(class_info, indent=4)
995
+
996
+
997
+ def handle_function(source: Any) -> str:
998
+ """Generate detailed JSON representation of a function or callable.
999
+
1000
+ Args:
1001
+ source: Function, method, or callable object to inspect
1002
+
1003
+ Returns:
1004
+ JSON string with callable metadata
1005
+ """
1006
+ # Determine the specific type of callable
1007
+ if inspect.isfunction(source):
1008
+ callable_type = "function"
1009
+ elif inspect.ismethod(source):
1010
+ callable_type = "instance method"
1011
+ elif inspect.isbuiltin(source):
1012
+ callable_type = "built-in"
1013
+ elif inspect.ismethoddescriptor(source):
1014
+ callable_type = "method descriptor"
1015
+ else:
1016
+ callable_type = "other callable"
1017
+
1018
+ callable_info = {
1019
+ "type": callable_type,
1020
+ "name": getattr(source, '__name__', 'Unnamed'),
1021
+ "doc": inspect.getdoc(source) or "No documentation available.",
1022
+ "module": getattr(source, '__module__', None),
1023
+ "string_repr": repr(source),
1024
+ "signature": None,
1025
+ "source": None
1026
+ }
1027
+
1028
+ # Attempt to get the signature
1029
+ try:
1030
+ signature = inspect.signature(source)
1031
+ callable_info["signature"] = str(signature)
1032
+ except (TypeError, ValueError):
1033
+ callable_info["signature"] = "Not available"
1034
+
1035
+ # Attempt to get the source code if it's a regular function
1036
+ if callable_type == "function":
1037
+ try:
1038
+ callable_info["source"] = inspect.getsource(source)
1039
+ except Exception:
1040
+ callable_info["source"] = "Not available"
1041
+
1042
+ return json.dumps(callable_info, indent=4)
1043
+
1044
+
1045
+ def handle_module(source: Any) -> str:
1046
+ """Generate detailed JSON representation of a module.
1047
+
1048
+ Args:
1049
+ source: Module object to inspect
1050
+
1051
+ Returns:
1052
+ JSON string with module metadata
1053
+ """
1054
+ module_info = {
1055
+ "type": "module",
1056
+ "name": source.__name__,
1057
+ "doc": inspect.getdoc(source) or "No documentation available.",
1058
+ "functions": {},
1059
+ "classes": {},
1060
+ "variables": {},
1061
+ "submodules": {},
1062
+ "string_repr": repr(source)
1063
+ }
1064
+
1065
+ for name, member in inspect.getmembers(source):
1066
+ if inspect.isfunction(member) and not name.startswith('_'):
1067
+ module_info["functions"][name] = {
1068
+ "doc": inspect.getdoc(member) or "No documentation available.",
1069
+ "signature": str(inspect.signature(member))
1070
+ }
1071
+ elif inspect.isclass(member) and not name.startswith('_'):
1072
+ module_info["classes"][name] = {
1073
+ "doc": inspect.getdoc(member) or "No documentation available."
1074
+ }
1075
+ elif inspect.ismodule(member) and not name.startswith('_'):
1076
+ module_info["submodules"][name] = {
1077
+ "doc": inspect.getdoc(member) or "No documentation available."
1078
+ }
1079
+ elif not (inspect.isroutine(member) or inspect.isclass(member) or inspect.ismodule(member)) and not name.startswith('_'):
1080
+ try:
1081
+ value = json.dumps(member)
1082
+ except:
1083
+ value = repr(member)
1084
+
1085
+ module_info["variables"][name] = {
1086
+ "value": value,
1087
+ "type": type(member).__name__
1088
+ }
1089
+
1090
+ return json.dumps(module_info, indent=4)
1091
+
1092
+
1093
+ def handle_object(source: Any) -> str:
1094
+ """Generate detailed JSON representation of a generic object instance.
1095
+
1096
+ Args:
1097
+ source: Object instance to inspect
1098
+
1099
+ Returns:
1100
+ JSON string with instance metadata
1101
+ """
1102
+ # Try to serialize the object
1103
+ serialized = repr(source)
1104
+ if hasattr(source, 'dumps') and callable(getattr(source, 'dumps')):
1105
+ try:
1106
+ serialized = source.dumps()
1107
+ except:
1108
+ pass
1109
+ else:
1110
+ try:
1111
+ serialized = json.dumps(source)
1112
+ except:
1113
+ pass
1114
+
1115
+ instance_info = {
1116
+ "type": "class_instance",
1117
+ "class": source.__class__.__name__,
1118
+ "doc": inspect.getdoc(source) or "No documentation available.",
1119
+ "attributes": {},
1120
+ "methods": {},
1121
+ "properties": {},
1122
+ "callable": callable(source),
1123
+ "call_signature": None,
1124
+ "string_repr": serialized
1125
+ }
1126
+
1127
+ if instance_info["callable"]:
1128
+ try:
1129
+ instance_info["call_signature"] = str(inspect.signature(source.__call__))
1130
+ except (TypeError, ValueError):
1131
+ instance_info["call_signature"] = "Not available"
1132
+
1133
+ # Use inspect.getmembers to safely retrieve object members
1134
+ members = inspect.getmembers(source, lambda a: not inspect.isroutine(a))
1135
+ methods = inspect.getmembers(source, inspect.isroutine)
1136
+ properties = [m for m in members if isinstance(m[1], property)]
1137
+
1138
+ # Filter attributes, methods, and properties
1139
+ for name, member in members:
1140
+ if not name.startswith('__'):
1141
+ try:
1142
+ value = json.dumps(member)
1143
+ except:
1144
+ value = repr(member)
1145
+
1146
+ instance_info["attributes"][name] = {
1147
+ "value": value,
1148
+ "type": type(member).__name__,
1149
+ "string_repr": repr(member)
1150
+ }
1151
+
1152
+ for name, method in methods:
1153
+ if not name.startswith('__'):
1154
+ try:
1155
+ signature = str(inspect.signature(method))
1156
+ except (TypeError, ValueError):
1157
+ signature = "Not available"
1158
+
1159
+ instance_info["methods"][name] = {
1160
+ "doc": inspect.getdoc(method) or "No documentation available.",
1161
+ "signature": signature
1162
+ }
1163
+
1164
+ for name, prop in properties:
1165
+ instance_info["properties"][name] = {
1166
+ "doc": inspect.getdoc(prop) or "No documentation available."
1167
+ }
1168
+
1169
+ return json.dumps(instance_info, indent=4)
1170
+
1171
+
1172
+ # ============================================================================
1173
+ # Main Entry Point
1174
+ # ============================================================================
1175
+
1176
+ def get_text(source: Any) -> str:
1177
+ """Extract text from various sources.
1178
+
1179
+ This is the main entry point that dispatches to appropriate handlers
1180
+ based on the type of source.
1181
+
1182
+ Supported sources:
1183
+ - Python objects (classes, functions, modules, instances)
1184
+ - URLs (web pages, documents)
1185
+ - File paths (PDF, DOCX, ODT, HTML, text files)
1186
+ - Directory paths (generates tree structure)
1187
+ - Primitive types (lists, dicts, etc.)
1188
+ - Plain strings (returned as-is)
1189
+
1190
+ Args:
1191
+ source: The source to extract text from
1192
+
1193
+ Returns:
1194
+ Extracted text, cleaned of excessive newlines
1195
+ """
1196
+ # Handle non-string objects (Python objects)
1197
+ if not isinstance(source, str):
1198
+ # Classes
1199
+ if inspect.isclass(source):
1200
+ text = handle_class(source)
1201
+
1202
+ # Callables (functions, methods, builtins)
1203
+ elif inspect.isfunction(source) or inspect.ismethod(source) or \
1204
+ inspect.isbuiltin(source) or inspect.ismethoddescriptor(source):
1205
+ text = handle_function(source)
1206
+
1207
+ # Modules
1208
+ elif inspect.ismodule(source):
1209
+ text = handle_module(source)
1210
+
1211
+ # Built-in types
1212
+ elif isinstance(source, (list, dict, tuple, set, int, float, bool)):
1213
+ try:
1214
+ text = json.dumps(source, indent=2, ensure_ascii=False)
1215
+ except:
1216
+ text = repr(source)
1217
+
1218
+ # Generic objects
1219
+ elif isinstance(source, object):
1220
+ text = handle_object(source)
1221
+
1222
+ # Fallback
1223
+ else:
1224
+ text = repr(source)
1225
+
1226
+ # Handle strings (URLs, file paths, or plain text)
1227
+ elif source.startswith('http://') or source.startswith('https://'):
1228
+ text = handle_url(source)
1229
+
1230
+ elif os.path.isfile(source):
1231
+ text = handle_file(source)
1232
+
1233
+ elif os.path.isdir(source):
1234
+ text = handle_directory(source)
1235
+
1236
+ else:
1237
+ # Plain string
1238
+ text = source
1239
+
1240
+ return strip_newlines(text)