forked from Kineviz/fortune500
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy path02_parser.py
More file actions
315 lines (247 loc) · 11 KB
/
Copy path02_parser.py
File metadata and controls
315 lines (247 loc) · 11 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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
import os
import re
import base64
import binascii
import io
import argparse
from bs4 import BeautifulSoup, ProcessingInstruction
from markdownify import markdownify as md
from tqdm import tqdm
def uudecode_line(line):
"""
Decodes a single line of uuencoded data.
Tries strict binascii first, falls back to manual decoding for malformed/padded lines.
"""
if not line: return b""
# Try efficient binascii first
try:
return binascii.a2b_uu(line)
except (binascii.Error, ValueError):
pass
# Manual Fallback
# Length
n = ord(line[0]) - 32
if n <= 0: return b""
decoded = bytearray()
try:
# Body starts at index 1
# Process 4 chars -> 3 bytes
for i in range(1, len(line), 4):
chunk = line[i:i+4]
if len(chunk) < 4: break
vals = []
for c in chunk:
v = ord(c) - 32
vals.append(v & 0x3F)
c1, c2, c3, c4 = vals
b1 = (c1 << 2) | (c2 >> 4)
b2 = ((c2 & 0xF) << 4) | (c3 >> 2)
b3 = ((c3 & 0x3) << 6) | c4
decoded.append(b1)
decoded.append(b2)
decoded.append(b3)
return bytes(decoded[:n])
except Exception:
return b""
def uudecode_content(encoded_text):
"""
Decodes UUEncoded text content.
"""
try:
lines = encoded_text.strip().splitlines()
start_idx = -1
for i, line in enumerate(lines):
if line.startswith("begin "):
start_idx = i
break
if start_idx == -1:
return None
# Decode line by line until "end"
decoded = bytearray()
for line in lines[start_idx+1:]:
if line == "end":
break
if not line: continue
chunk = uudecode_line(line)
decoded.extend(chunk)
return bytes(decoded)
except Exception:
return None
def clean_html(html_content):
"""
Cleans HTML content using BeautifulSoup before conversion.
"""
soup = BeautifulSoup(html_content, 'html.parser')
# Remove Processing Instructions (<?xml ... ?>)
for element in soup.find_all(string=lambda text: isinstance(text, ProcessingInstruction)):
element.extract()
# Remove scripts and styles
for script in soup(["script", "style"]):
script.decompose()
# Handle Inline XBRL (iXBRL)
# 1. Un-nest the main content if it's wrapped in <XBRL> or <XML>
# Common in modern filings: <DOCUMENT><TEXT><XBRL> ...html... </XBRL></TEXT></DOCUMENT>
# We want the HTML inside, so we UNWRAP the parent container.
for tag_name in ["xbrl", "xml"]:
for tag in soup.find_all(tag_name):
tag.unwrap()
# 2. Remove metadata/hidden sections that contain raw data (the "garbage" text)
# ix:header -> usually metadata
# ix:hidden -> hidden facts
# FILENAME/DESCRIPTION/TYPE/SEQUENCE -> SGML metadata often leaked into TEXT
for tag_name in ["ix:header", "ix:hidden", "filename", "description", "type", "sequence", "title"]:
for tag in soup.find_all(tag_name):
tag.decompose()
return str(soup)
def parse_sgml_filing(filepath, output_dir):
"""
Parses a single SEC SGML filing (full-submission.txt).
Extracts documents (10-K, 10-Q, EX-*, GRAPHIC) and saves them to output_dir.
"""
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
content = f.read()
# Regex to find <DOCUMENT> blocks
# Flags: Dotall to match newlines
doc_pattern = re.compile(r'<DOCUMENT>(.*?)</DOCUMENT>', re.DOTALL)
matches = doc_pattern.findall(content)
if not matches:
return # No documents found
# Metadata extraction helper
def extract_tag(block, tag):
m = re.search(f"<{tag}>(.*)", block, re.IGNORECASE)
return m.group(1).strip() if m else None
# Extract Header Metadata (CIK, Accession)
cik = None
accession = None
# Matches usually for header tags
# CENTRAL INDEX KEY: \s* (\d+)
# ACCESSION NUMBER: \s* ([\d-]+)
cik_match = re.search(r'CENTRAL INDEX KEY:\s*(\d+)', content)
if cik_match:
cik = str(int(cik_match.group(1))) # Strip leading zeros
acc_match = re.search(r'ACCESSION NUMBER:\s*([\d-]+)', content)
if acc_match:
accession = acc_match.group(1).replace('-', '')
for block in matches:
doc_type = extract_tag(block, "TYPE")
filename = extract_tag(block, "FILENAME")
# Extract Content between <TEXT> ... </TEXT>
text_match = re.search(r'<TEXT>(.*?)</TEXT>', block, re.DOTALL)
if not text_match:
continue
doc_content = text_match.group(1)
if not filename:
# Fallback filename if missing
filename = f"doc_{doc_type}.txt"
# Sanitize filename
safe_filename = os.path.basename(filename)
# Determine strict output path
# If passed output_dir is "data/markdown/...", then use that.
out_path = os.path.join(output_dir, safe_filename)
# User Request: Extract ONLY full-submission.md (Main 10-K/10-Q), Images, and Spreadsheets.
# Filter based on type and extension.
is_main_doc = doc_type in ["10-K", "10-Q"]
# Extensions interested in
# Images: .jpg, .gif, .png
# Spreadsheets: .xlsx, .xls, .csv
ext = os.path.splitext(filename)[1].lower() if filename else ""
is_image = doc_type == "GRAPHIC" or ext in ['.jpg', '.gif', '.png', '.jpeg']
is_spreadsheet = ext in ['.xlsx', '.xls', '.csv']
if not (is_main_doc or is_image or is_spreadsheet):
continue
# Handle Binary Types
# GRAPHIC = Images (uuencoded)
# EXCEL = Excel files (uuencoded)
# ZIP = Zip files (uuencoded)
# PDF = PDF files (uuencoded)
binary_types = ["GRAPHIC", "EXCEL", "ZIP", "PDF"]
if doc_type in binary_types or filename.lower().endswith(('.zip', '.xlsx', '.xls', '.pdf', '.jpg', '.gif')):
# Attempt to decode UUEncoded content
decoded_bytes = uudecode_content(doc_content)
if decoded_bytes:
# Save binary content
with open(out_path, 'wb') as f_out:
f_out.write(decoded_bytes)
else:
pass
# Handle Text/HTML Documents (10-K, 10-Q, Exhibits)
else:
# Check if content looks like HTML
is_html = "<html" in doc_content.lower() or "<body" in doc_content.lower()
if is_html:
# Convert to Markdown
cleaned_html = clean_html(doc_content)
# Markdown conversion
markdown_text = md(cleaned_html, heading_style="ATX")
# Rename .htm/.html to .md
base, _ = os.path.splitext(safe_filename)
md_filename = f"{base}.md"
md_out_path = os.path.join(output_dir, md_filename)
# Special case for primary document logic:
if doc_type in ["10-K", "10-Q"]:
md_out_path = os.path.join(output_dir, "full-submission.md")
# Prepend Link if available
if cik and accession and filename:
# https://www.sec.gov/ix?doc=/Archives/edgar/data/320193/000032019320000096/aapl-20200926.htm
link = f"https://www.sec.gov/ix?doc=/Archives/edgar/data/{cik}/{accession}/{filename}"
markdown_text = f"[SEC Filing]({link})\n\n" + markdown_text
with open(md_out_path, 'w', encoding='utf-8') as f_out:
f_out.write(markdown_text)
else:
# Plain text, save as is (or .txt)
with open(out_path, 'w', encoding='utf-8') as f_out:
f_out.write(doc_content)
import concurrent.futures
import multiprocessing
def process_filing(args):
"""
Wrapper for parse_sgml_filing to be used with ProcessPoolExecutor.
args: tuple(filing_path, input_base, output_base)
"""
filing_path, input_base, output_base = args
rel_path = os.path.relpath(os.path.dirname(filing_path), input_base)
target_dir = os.path.join(output_base, rel_path)
# Check if already processed
if os.path.exists(os.path.join(target_dir, "full-submission.md")):
return filing_path # treat as done
os.makedirs(target_dir, exist_ok=True)
parse_sgml_filing(filing_path, target_dir)
return filing_path
def main():
parser = argparse.ArgumentParser(description="Parse SEC SGML filings to Markdown/Images")
parser.add_argument("--input_base", default="data/sgml", help="Input directory containing SGML filings")
parser.add_argument("--output_base", default="data/markdown", help="Output directory for parsed files")
parser.add_argument("--workers", type=int, default=multiprocessing.cpu_count(), help="Number of worker processes")
parser.add_argument("--ticker", help="Specific ticker to process")
args = parser.parse_args()
input_base = os.path.abspath(args.input_base)
output_base = os.path.abspath(args.output_base)
filings = []
# 1. Scan for files
print(f"Scanning {input_base} for full-submission.txt...")
for root, dirs, files in os.walk(input_base):
for file in files:
if file == "full-submission.txt":
if args.ticker:
parts = root.split(os.sep)
if args.ticker.upper() not in [p.upper() for p in parts]:
continue
filings.append(os.path.join(root, file))
print(f"Found {len(filings)} filings to parse.")
# 2. Process in parallel
# Prepare arguments for each task
tasks = [(f, input_base, output_base) for f in filings]
print(f"Starting parsing with {args.workers} workers...")
try:
with concurrent.futures.ProcessPoolExecutor(max_workers=args.workers) as executor:
futures = [executor.submit(process_filing, task) for task in tasks]
for _ in tqdm(concurrent.futures.as_completed(futures), total=len(futures), desc="Parsing"):
pass
except PermissionError as e:
# Some restricted environments disallow semaphores used by ProcessPool.
print(f"Process pool unavailable ({e}). Falling back to single-process parsing.")
for task in tqdm(tasks, total=len(tasks), desc="Parsing (fallback)"):
process_filing(task)
print("Parsing complete.")
if __name__ == "__main__":
main()