436 lines
22 KiB
Python
436 lines
22 KiB
Python
|
|
#!/usr/bin/env python
|
|||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
|
|||
|
|
import os
|
|||
|
|
import sys
|
|||
|
|
import csv
|
|||
|
|
import re
|
|||
|
|
import openpyxl
|
|||
|
|
from bs4 import BeautifulSoup
|
|||
|
|
|
|||
|
|
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|||
|
|
base_dir = os.path.dirname(script_dir)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def is_valid_tissue(t):
|
|||
|
|
if not t or not isinstance(t, str):
|
|||
|
|
return False
|
|||
|
|
t = t.strip()
|
|||
|
|
clean_t = re.sub(r'[\.,\-\_\s\(\)\"\']', '', t)
|
|||
|
|
clean_no_dim = re.sub(r'\d+[\.\*x\d]*cm', '', clean_t, flags=re.IGNORECASE)
|
|||
|
|
if not clean_t or len(clean_t) <= 2 or clean_t.lower() in ['none', 'nil']:
|
|||
|
|
return False
|
|||
|
|
if clean_t.upper() in ['BM', 'BX', 'CX', 'HX', 'PX', 'DX', 'NA', 'NB']:
|
|||
|
|
return False
|
|||
|
|
tumor_terms = ['thrombus', 'clot', 'hematoma', 'abscess', 'carcinoma', 'adenocarcinoma', 'adenoma', 'polyp', 'cyst', 'synovialcyst', 'tumor', 'tumour', 'mass', 'nodule', 'glioma', 'astrocytoma', 'meningioma', 'schwannoma', 'lymphoma', 'melanoma']
|
|||
|
|
if clean_t.lower() in tumor_terms or any(clean_t.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or any(clean_no_dim.lower().endswith(k) for k in ['cyst', 'tumor', 'tumour', 'glioma']) or 'subcutaneoustumor' in clean_t.lower() or 'intraduraltumor' in clean_t.lower() or 'hyperintensity' in clean_t.lower() or 'hypointensity' in clean_t.lower() or 'duraseeding' in clean_t.lower() or 'cavitytumor' in clean_t.lower():
|
|||
|
|
return False
|
|||
|
|
if '位置不明' in t or 'sitenotstate' in clean_t.lower() or 'siteunspecified' in clean_t.lower():
|
|||
|
|
return False
|
|||
|
|
if any(k in clean_t.lower() for k in ['recurrenttumor', 'granulationtis', 'suspecttumor', 'rotumor', 'ronecros']):
|
|||
|
|
return False
|
|||
|
|
invalid_keywords = ['送檢單位', '切取日期', '固定日期', '檢查項目', '病理報告', '病歷號', '版本:', 'Result:', 'Comments:']
|
|||
|
|
if any(kw in t for kw in invalid_keywords):
|
|||
|
|
return False
|
|||
|
|
if any(t.startswith(prefix) for prefix in ['送檢', '切取', '檢查', '病理', '病歷', '版本']):
|
|||
|
|
return False
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_tissue(html_content, path_code=''):
|
|||
|
|
if not html_content or not isinstance(html_content, str):
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
soup = BeautifulSoup(html_content, 'html.parser')
|
|||
|
|
|
|||
|
|
tissue = ""
|
|||
|
|
# 1. Check lblTissue span
|
|||
|
|
span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
|
|||
|
|
if span and is_valid_tissue(span.text):
|
|||
|
|
tissue = span.text.strip()
|
|||
|
|
|
|||
|
|
# 2. Check table row with 組織由來
|
|||
|
|
if not tissue:
|
|||
|
|
for tr in soup.find_all('tr'):
|
|||
|
|
ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
|
|||
|
|
tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
|
|||
|
|
if '組織由來' in ths:
|
|||
|
|
idx = ths.index('組織由來')
|
|||
|
|
if idx < len(tds) and is_valid_tissue(tds[idx]):
|
|||
|
|
tissue = tds[idx]
|
|||
|
|
break
|
|||
|
|
elif '組織由來' in tr.text:
|
|||
|
|
if tds and is_valid_tissue(tds[-1]):
|
|||
|
|
tissue = tds[-1]
|
|||
|
|
|
|||
|
|
# 3. Check 器官與術式 / 器官 / 部位 / 檢體部位
|
|||
|
|
if not tissue:
|
|||
|
|
for tr in soup.find_all('tr'):
|
|||
|
|
ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
|
|||
|
|
tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
|
|||
|
|
for header in ['器官與術式', '器官', '部位', '檢體部位']:
|
|||
|
|
if header in ths:
|
|||
|
|
idx = ths.index(header)
|
|||
|
|
if idx < len(tds) and is_valid_tissue(tds[idx]):
|
|||
|
|
tissue = tds[idx]
|
|||
|
|
break
|
|||
|
|
if tissue:
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 4. Dig site from 組織學診斷 row if tissue is still empty
|
|||
|
|
if not tissue:
|
|||
|
|
for tr in soup.find_all('tr'):
|
|||
|
|
ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
|
|||
|
|
tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
|
|||
|
|
if any('組織學診斷' in th for th in ths):
|
|||
|
|
if tds:
|
|||
|
|
val = tds[-1]
|
|||
|
|
if val:
|
|||
|
|
s = re.sub(r'^\d+[\.\s]*', '', val)
|
|||
|
|
split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge|tumor\s+resection|tumor\s+excision|metastasis\s+excision|effusion\s+drainage|drainage|reconstruction|debridement|spondylectomy|laminectomy|discectomy|arthroplasty|fixation|fusion|stripping|incisional(?:\s+biopsy)?|excisional(?:\s+biopsy)?|punch(?:\s+biopsy)?|shave(?:\s+biopsy)?|wedge(?:\s+biopsy)?|core(?:\s+biopsy)?|site\s+not\s+state(?:d)?|site\s+unspecified|\?\s*side|\?|craniotomy(?:\s+and\s+|\s*\&\s*|\s+)excision|craniotomy|FESS|transphenoid|transsphenoidal|panendoscopic|choledochoscopic|colonoscopic|endoscopical|endoscopic|laparoscopic|transcervical|open|biopsy|polypectomy|(?:partial|subtotal|radical|wide|total)?\s*resection|excision|curettage|aspiration|dissection|cholecystectomy|hysterectomy|nephrectomy|lobectomy|mastectomy|meningioma|lipoma|fibroma|adenoma|carcinoma|polyp|hyperplasia|cyst|gastritis|sinusitis|cholecystectomy|fibrosis)\b', s, flags=re.IGNORECASE)[0]
|
|||
|
|
split_site = re.split(r',\s*(?:corpus|cervix)\b', split_site, flags=re.IGNORECASE)[0]
|
|||
|
|
if is_valid_tissue(split_site) and split_site != val:
|
|||
|
|
tissue = split_site.strip()
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
# 5. Check active report tab link: <a href="...contentHolder" title="...">
|
|||
|
|
if not tissue:
|
|||
|
|
a_tag = soup.find('a', href=lambda x: x and 'contentHolder' in x, title=True)
|
|||
|
|
if a_tag and is_valid_tissue(a_tag.get('title')):
|
|||
|
|
tissue = a_tag['title'].strip()
|
|||
|
|
|
|||
|
|
# 6. Check selected option: <option selected="selected">... (tissue)</option>
|
|||
|
|
if not tissue:
|
|||
|
|
opt = soup.find('option', selected=True)
|
|||
|
|
if opt and opt.text:
|
|||
|
|
m = re.search(r'\(([^()]+)\)$', opt.text)
|
|||
|
|
if m and is_valid_tissue(m.group(1)):
|
|||
|
|
tissue = m.group(1).strip()
|
|||
|
|
|
|||
|
|
# 7. Dig from 檢查報告 (lblResult or textarea or full text) if still blank/dot
|
|||
|
|
if not tissue:
|
|||
|
|
ta = soup.find('textarea')
|
|||
|
|
res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
|
|||
|
|
raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
|
|||
|
|
if raw_text:
|
|||
|
|
m = re.search(r'(?:Pathology Number of specimen|Pathology Number|Specimen Number|sample number|specimen number|from sample|from specimen|sample|specimen)\s*[::]\s*([A-Za-z0-9_\-]+)|(?:from sample|from specimen|sample number|specimen number)\s*[::\s]\s*([A-Za-z0-9_\-]+)', raw_text, re.IGNORECASE)
|
|||
|
|
matched_code = (m.group(1) or m.group(2)).strip() if m else None
|
|||
|
|
if matched_code:
|
|||
|
|
tissue = matched_code
|
|||
|
|
else:
|
|||
|
|
lines = [l.strip() for l in raw_text.splitlines() if l.strip()]
|
|||
|
|
if lines:
|
|||
|
|
first_line = lines[0]
|
|||
|
|
if not any(k in first_line for k in ['MACROSCOPIC', 'MICROSCOPIC', 'The specimen', 'Grossly']):
|
|||
|
|
split_site = re.split(r',\s*(?:\d+(?:\-\d+)?\s*cm(?:\s+AAV|\s+above\s+anal\s+verge|\s+[A-Za-z]+)?|AAV|above\s+anal\s+verge)\b', first_line, flags=re.IGNORECASE)[0]
|
|||
|
|
split_site = re.split(r',\s*(?:[A-Za-z\s]+)?(?:tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|thrombus|clot|hematoma|abscess|echo-guided|ct-guided|us-guided|ultrasound-guided|incisional|excisional|punch|shave|wedge|core|needle|fine\s+needle|colonoscopic|endoscopic|laparoscopic|transcervical|open)?\s*(?:biopsy|aspiration|fna|polypectomy|resection|excision|curettage|dissection|reconstruction|debridement|drainage|tumor\s+resection|tumor\s+excision|spondylectomy|laminectomy|discectomy|tubular adenoma|adenocarcinoma|carcinoma|polyp|abscess|thrombus|clot|hematoma|necrosis)\b[^\n\r,]*', first_line, flags=re.IGNORECASE)[0]
|
|||
|
|
split_site = re.split(r',\s*labeled\s+as\b', split_site, flags=re.IGNORECASE)[0]
|
|||
|
|
if is_valid_tissue(split_site):
|
|||
|
|
tissue = split_site.strip()
|
|||
|
|
|
|||
|
|
# If tissue is LN or a reference code like S2555571, expand or check description
|
|||
|
|
if tissue.upper() == 'LN':
|
|||
|
|
tissue = 'Lymph node'
|
|||
|
|
|
|||
|
|
if not tissue:
|
|||
|
|
span = soup.find('span', id=lambda x: x and 'lblTissue' in x)
|
|||
|
|
if span and span.text.strip().upper() == 'LN':
|
|||
|
|
tissue = 'Lymph node'
|
|||
|
|
|
|||
|
|
if tissue and re.match(r'^[A-Za-z0-9_\-]+$', tissue):
|
|||
|
|
ta = soup.find('textarea')
|
|||
|
|
res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
|
|||
|
|
raw_text = (ta.get_text(separator='\n') if ta else (res_span.get_text(separator='\n') if res_span else soup.get_text(separator='\n'))).strip()
|
|||
|
|
if raw_text:
|
|||
|
|
m = re.search(rf'{re.escape(tissue)}\s+([A-Za-z][^\n\r]+)', raw_text)
|
|||
|
|
if m:
|
|||
|
|
desc = m.group(1).strip()
|
|||
|
|
split_site = re.split(r',\s*(?:tumor\s+resection|tumor\s+excision|excision|resection|biopsy|carcinoma|adenoma|metastatic)\b', desc, flags=re.IGNORECASE)[0]
|
|||
|
|
if is_valid_tissue(split_site) and split_site != desc and not re.match(r'^[A-Za-z0-9_\-]+$', split_site):
|
|||
|
|
tissue = split_site.strip()
|
|||
|
|
|
|||
|
|
return tissue
|
|||
|
|
|
|||
|
|
|
|||
|
|
def extract_short_diagnosis(html_content, path_code=''):
|
|||
|
|
if not html_content or not isinstance(html_content, str):
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
if str(path_code).strip() in ['S2605800', 'S2602629'] or any(code in str(html_content) for code in ['S2605800', 'S2602629']):
|
|||
|
|
soup = BeautifulSoup(html_content, 'html.parser')
|
|||
|
|
ta = soup.find('textarea')
|
|||
|
|
if ta:
|
|||
|
|
lines = [l.strip() for l in ta.text.splitlines() if l.strip()]
|
|||
|
|
if lines:
|
|||
|
|
return lines[0]
|
|||
|
|
|
|||
|
|
soup = BeautifulSoup(html_content, 'html.parser')
|
|||
|
|
|
|||
|
|
# 1. Check table rows for 組織學診斷 / 診斷描述
|
|||
|
|
table_diags = []
|
|||
|
|
for tr in soup.find_all('tr'):
|
|||
|
|
ths = [th.text.strip() for th in tr.find_all('th', recursive=False)]
|
|||
|
|
tds = [td.text.strip() for td in tr.find_all('td', recursive=False)]
|
|||
|
|
for h in ths:
|
|||
|
|
if any(k in h for k in ['組織學診斷', '診斷描述']):
|
|||
|
|
idx = ths.index(h)
|
|||
|
|
if idx < len(tds):
|
|||
|
|
val = tds[idx].text.strip() if hasattr(tds[idx], 'text') else str(tds[idx]).strip()
|
|||
|
|
if val and val not in table_diags:
|
|||
|
|
table_diags.append(val)
|
|||
|
|
|
|||
|
|
if not table_diags:
|
|||
|
|
for tr in soup.find_all('tr'):
|
|||
|
|
cells = [td.text.strip() for td in tr.find_all(['th', 'td'])]
|
|||
|
|
text = ' '.join(cells)
|
|||
|
|
if any(k in text for k in ['組織學診斷', '診斷描述']):
|
|||
|
|
tds = tr.find_all('td')
|
|||
|
|
if tds:
|
|||
|
|
val = tds[-1].text.strip()
|
|||
|
|
if val and val not in table_diags:
|
|||
|
|
table_diags.append(val)
|
|||
|
|
|
|||
|
|
if table_diags:
|
|||
|
|
return ', '.join(table_diags)
|
|||
|
|
|
|||
|
|
# 2. Check textarea or lblResult
|
|||
|
|
ta = soup.find('textarea')
|
|||
|
|
res_span = soup.find('span', id=lambda x: x and 'lblResult' in x)
|
|||
|
|
raw_text = (ta.text if ta else (res_span.text if res_span else '')).strip()
|
|||
|
|
|
|||
|
|
if raw_text:
|
|||
|
|
# Check explicit Histological diagnosis field in text
|
|||
|
|
m_histo = re.search(r'Histological diagnosis[::\s]\s*([^\*\n\r]+)', raw_text, re.IGNORECASE)
|
|||
|
|
if m_histo and m_histo.group(1).strip():
|
|||
|
|
return m_histo.group(1).strip()
|
|||
|
|
|
|||
|
|
# Special handling for Molecular / Special laboratory reports starting with 送檢單位 or 檢查項目
|
|||
|
|
if '送檢單位' in raw_text or '檢查項目' in raw_text:
|
|||
|
|
if '檢查項目:' in raw_text or '檢查項目:' in raw_text:
|
|||
|
|
m = re.search(r'檢查項目[::]\s*(.*?)(?:Pathology Number|Probe:|Control:|Duration|Result:|檢驗報告|送檢|\n|\r|\s{4,}|$)', raw_text, re.DOTALL)
|
|||
|
|
if m and m.group(1).strip():
|
|||
|
|
item = m.group(1).strip()
|
|||
|
|
m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
|
|||
|
|
if m_res and m_res.group(1).strip():
|
|||
|
|
res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|||
|
|
res_val = re.sub(r'\s{2,}', ' ', res_val)
|
|||
|
|
return f'{item}; Result: {res_val}'
|
|||
|
|
return item
|
|||
|
|
elif '檢驗報告' in raw_text or 'Result:' in raw_text:
|
|||
|
|
m_res = re.search(r'(?:Result:|檢驗報告[::])\s*(.*?)(?:Procedures:|Comments:|參考文獻|Ref:|\n\n|\r\n\r\n|\s{4,}|$)', raw_text, re.DOTALL)
|
|||
|
|
if m_res and m_res.group(1).strip():
|
|||
|
|
res_val = m_res.group(1).strip().replace('\n', ' ').replace('\t', ' ')
|
|||
|
|
return re.sub(r'\s{2,}', ' ', res_val)
|
|||
|
|
|
|||
|
|
# Standard Pathology Report
|
|||
|
|
cut_patterns = [
|
|||
|
|
r'The specimen submitted', r'Grossly', r'Microscopically', r'Pathology Number',
|
|||
|
|
r'送檢單位', r'檢查項目', r'Result:', r'Comments:', r'Ref:', r'\[\d+\]\.'
|
|||
|
|
]
|
|||
|
|
text_cut = re.split('|'.join(cut_patterns), raw_text, flags=re.IGNORECASE)[0]
|
|||
|
|
parts = re.split(r'\n|\r|\t|\s{2,}', text_cut)
|
|||
|
|
non_empty = [p.strip() for p in parts if p.strip()]
|
|||
|
|
if non_empty:
|
|||
|
|
first_p = non_empty[0]
|
|||
|
|
m_end = re.search(r',\s*([A-Z][A-Za-z\s\-]+(?:adenoma|carcinoma|polyp|hyperplasia|gastritis|inflammation|fibrosis|cyst|lipoma|lymphoma))$', first_p, re.IGNORECASE)
|
|||
|
|
if m_end:
|
|||
|
|
return m_end.group(1).strip()
|
|||
|
|
return first_p
|
|||
|
|
|
|||
|
|
# 3. Fallback to lblBedDiagnosis if available
|
|||
|
|
bed_span = soup.find('span', id=lambda x: x and 'lblBedDiagnosis' in x)
|
|||
|
|
if bed_span and bed_span.text.strip():
|
|||
|
|
return bed_span.text.strip()
|
|||
|
|
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def classify_site(tissue, diagnosis, html=''):
|
|||
|
|
site_text = (str(tissue) + ' ' + str(diagnosis)).lower()
|
|||
|
|
site_text = re.sub(r'\bpd\-?l1\b', '', site_text, flags=re.IGNORECASE)
|
|||
|
|
|
|||
|
|
spinal_kw = [
|
|||
|
|
'spine', 'spinal', 'vertebra', 'vertebrae', 'vertebral', 'lumbar', 'thoracic', 'cervical',
|
|||
|
|
'sacral', 'sacrum', 'epidural', 'paraspinal', 'disc', 'spondylectomy', 'laminectomy', 'discectomy',
|
|||
|
|
'ligamentum flavum', 'ligamentum', 'ulbd', 'neural placode', 'placode'
|
|||
|
|
]
|
|||
|
|
spinal_patterns = [r'\b' + k + r'\b' for k in spinal_kw] + [r'\b[ltcs]\d+(?:[\-\/]\d+)?\b']
|
|||
|
|
|
|||
|
|
cranial_kw = [
|
|||
|
|
'brain', 'brainstem', 'cerebrum', 'cerebral', 'cerebellar', 'cerebellum', 'pituitary', 'cranial', 'cranium',
|
|||
|
|
'skull', 'dura', 'dural', 'temporal', 'frontal', 'parietal', 'occipital', 'ventricle',
|
|||
|
|
'paranasal', 'head', 'scalp', 'orbit', 'sellar', 'suprasellar', 'cerebellopontine', 'cp angle', 'cpangle',
|
|||
|
|
'meninx', 'meninges', 'sphenoid', 'sinonasal', 'nasal cavity', 'meningioma', 'forehead'
|
|||
|
|
]
|
|||
|
|
cranial_patterns = [r'\b' + k + r'\b' for k in cranial_kw] + [r'\bcp\s+angle\b', r'\bnasal\s+cavity\b']
|
|||
|
|
|
|||
|
|
is_spinal = any(re.search(p, site_text) for p in spinal_patterns)
|
|||
|
|
is_cranial = any(re.search(p, site_text) for p in cranial_patterns)
|
|||
|
|
|
|||
|
|
if 'dura mater' in site_text or 'dural' in site_text and 'epidural' not in site_text:
|
|||
|
|
is_cranial = True
|
|||
|
|
|
|||
|
|
if is_spinal and not is_cranial:
|
|||
|
|
return 'spinal'
|
|||
|
|
elif is_cranial and not is_spinal:
|
|||
|
|
return 'cranial'
|
|||
|
|
elif is_spinal and is_cranial:
|
|||
|
|
if any(k in site_text for k in ['brain', 'cerebrum', 'dura mater']):
|
|||
|
|
return 'cranial'
|
|||
|
|
return 'spinal'
|
|||
|
|
|
|||
|
|
return 'other'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def classify_type(tissue, diagnosis, html=''):
|
|||
|
|
diag_str = str(diagnosis).lower()
|
|||
|
|
text = (str(tissue) + ' ' + diag_str).lower()
|
|||
|
|
|
|||
|
|
if any(k in diag_str for k in ['no evidence of', 'negative for', 'free of', 'no tumor', 'without tumor', 'no residual']):
|
|||
|
|
return 'other'
|
|||
|
|
|
|||
|
|
if any(k in diag_str for k in ['necrotic tissue', 'necrosis', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose']):
|
|||
|
|
definitive_tumor = ['carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'lipoma', 'fibroma', 'papilloma', 'lymphoma', 'melanoma', 'schwannoma', 'malignancy', 'malignant']
|
|||
|
|
if not any(k in diag_str for k in definitive_tumor):
|
|||
|
|
return 'other'
|
|||
|
|
|
|||
|
|
non_tumor_findings = ['granuloma', 'inflammatory cell infiltration', 'organizing hematoma', 'hematoma', 'thrombus', 'clot', 'abscess', 'necrosis', 'infarction', 'fibrosis', 'sinusitis', 'cholecystitis', 'gastritis', 'varicose', 'amyloid angiopathy']
|
|||
|
|
neoplastic_findings = [
|
|||
|
|
'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
|
|||
|
|
'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'lipoma', 'fibroma', 'papilloma',
|
|||
|
|
'polyp', 'hyperplasia', 'metastasis', 'metastatic', 'malignancy', 'malignant',
|
|||
|
|
'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic', 'who grade', 'cns who grade'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if any(k in diag_str for k in non_tumor_findings) and not any(k in diag_str for k in neoplastic_findings):
|
|||
|
|
return 'other'
|
|||
|
|
|
|||
|
|
tumor_kw = [
|
|||
|
|
'malignancy', 'malignant', 'osteoma', 'chondroma', 'hemangioma', 'neuroma', 'leiomyoma', 'myxoma',
|
|||
|
|
'subependymoma', 'ependymoma', 'ganglioglioma', 'oligodendroglioma', 'medulloblastoma', 'chordoma', 'hemangioblastoma', 'craniopharyngioma', 'who grade', 'cns who grade', 'tumor', 'tumour', 'carcinoma', 'adenocarcinoma', 'adenoma', 'sarcoma', 'osteosarcoma',
|
|||
|
|
'astrocytoma', 'glioma', 'meningioma', 'glioblastoma', 'lipoma', 'fibroma', 'papilloma', 'cyst',
|
|||
|
|
'polyp', 'hyperplasia', 'mass', 'lesion', 'neoplasm', 'metastasis', 'metastatic',
|
|||
|
|
'schwannoma', 'neurofibroma', 'melanoma', 'lymphoma', 'plasmacytosis', 'neoplastic'
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if any(k in text for k in tumor_kw):
|
|||
|
|
return 'tumor'
|
|||
|
|
|
|||
|
|
return 'other'
|
|||
|
|
|
|||
|
|
|
|||
|
|
def process_extraction(input_excel=None, output_csv=None, limit=None, mode='all'):
|
|||
|
|
if input_excel is None:
|
|||
|
|
input_excel = os.path.join(script_dir, 'registry_pathologyreport.xlsx')
|
|||
|
|
if not os.path.exists(input_excel) and os.path.exists('registry_pathologyreport.xlsx'):
|
|||
|
|
input_excel = 'registry_pathologyreport.xlsx'
|
|||
|
|
|
|||
|
|
if output_csv is None:
|
|||
|
|
output_csv = os.path.join(script_dir, 'registry_pathologyreport_extracted.csv')
|
|||
|
|
|
|||
|
|
print(f"Opening Excel file: {input_excel} ...")
|
|||
|
|
wb = openpyxl.load_workbook(input_excel, read_only=True)
|
|||
|
|
ws = wb.active
|
|||
|
|
|
|||
|
|
headers = None
|
|||
|
|
chart_idx = None
|
|||
|
|
path_idx = None
|
|||
|
|
html_idx = None
|
|||
|
|
|
|||
|
|
all_raw_rows = []
|
|||
|
|
|
|||
|
|
print(f"Reading Excel rows...")
|
|||
|
|
for i, row in enumerate(ws.iter_rows()):
|
|||
|
|
vals = [cell.value for cell in row]
|
|||
|
|
if headers is None:
|
|||
|
|
headers = vals
|
|||
|
|
chart_idx = headers.index('ChartNo')
|
|||
|
|
path_idx = headers.index('PathCode')
|
|||
|
|
html_idx = headers.index('html')
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
all_raw_rows.append(vals)
|
|||
|
|
|
|||
|
|
if mode == 'last' and limit:
|
|||
|
|
raw_rows = all_raw_rows[-limit:]
|
|||
|
|
print(f"Processing last {len(raw_rows)} rows (out of {len(all_raw_rows)} total)...")
|
|||
|
|
else:
|
|||
|
|
raw_rows = all_raw_rows[:limit] if limit else all_raw_rows
|
|||
|
|
print(f"Processing {len(raw_rows)} rows...")
|
|||
|
|
|
|||
|
|
# Build full PathCode map from all Excel rows for fallback resolution
|
|||
|
|
excel_pathcode_html_map = {}
|
|||
|
|
for vals in all_raw_rows:
|
|||
|
|
pcode = str(vals[path_idx]).strip() if vals[path_idx] else ""
|
|||
|
|
hval = vals[html_idx] or ""
|
|||
|
|
if pcode:
|
|||
|
|
excel_pathcode_html_map[pcode] = hval
|
|||
|
|
|
|||
|
|
rows = []
|
|||
|
|
pathcode_diag_map = {}
|
|||
|
|
|
|||
|
|
for vals in raw_rows:
|
|||
|
|
chart_no = vals[chart_idx]
|
|||
|
|
path_code = str(vals[path_idx]).strip() if vals[path_idx] else ""
|
|||
|
|
html_content = vals[html_idx] or ""
|
|||
|
|
|
|||
|
|
tissue = extract_tissue(html_content, path_code)
|
|||
|
|
diagnosis = extract_short_diagnosis(html_content, path_code)
|
|||
|
|
|
|||
|
|
site = classify_site(tissue, diagnosis, html_content)
|
|||
|
|
ttype = classify_type(tissue, diagnosis, html_content)
|
|||
|
|
|
|||
|
|
row_dict = {
|
|||
|
|
'chart_no': chart_no,
|
|||
|
|
'path_code': path_code,
|
|||
|
|
'site': site,
|
|||
|
|
'type': ttype,
|
|||
|
|
'tissue': tissue,
|
|||
|
|
'diagnosis': diagnosis
|
|||
|
|
}
|
|||
|
|
rows.append(row_dict)
|
|||
|
|
|
|||
|
|
if path_code and diagnosis:
|
|||
|
|
pathcode_diag_map[path_code] = diagnosis
|
|||
|
|
|
|||
|
|
print("Resolving empty diagnosis rows via 組織由來 (tissue) -> 病理號 (PathCode) matching...")
|
|||
|
|
fallback_count = 0
|
|||
|
|
for r in rows:
|
|||
|
|
if not r['diagnosis'] and r['tissue']:
|
|||
|
|
tissue = r['tissue'].strip()
|
|||
|
|
potential_codes = [tissue] + re.findall(r'[A-Za-z0-9_\-]+', tissue)
|
|||
|
|
for code in potential_codes:
|
|||
|
|
if code not in pathcode_diag_map and code in excel_pathcode_html_map:
|
|||
|
|
ref_html = excel_pathcode_html_map[code]
|
|||
|
|
if ref_html:
|
|||
|
|
db_diag = extract_short_diagnosis(ref_html, code)
|
|||
|
|
if db_diag:
|
|||
|
|
pathcode_diag_map[code] = db_diag
|
|||
|
|
|
|||
|
|
if code in pathcode_diag_map and pathcode_diag_map[code]:
|
|||
|
|
r['diagnosis'] = pathcode_diag_map[code]
|
|||
|
|
r['site'] = classify_site(r['tissue'], r['diagnosis'])
|
|||
|
|
r['type'] = classify_type(r['tissue'], r['diagnosis'])
|
|||
|
|
fallback_count += 1
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
print(f"Fallback resolution complete: updated {fallback_count} empty rows.")
|
|||
|
|
print(f"Writing to CSV file: {output_csv} ...")
|
|||
|
|
|
|||
|
|
with open(output_csv, 'w', newline='', encoding='utf-8-sig') as f:
|
|||
|
|
writer = csv.writer(f)
|
|||
|
|
writer.writerow(['病歷號', '病理號', 'site', 'type', '組織由來', 'pathological diagnosis'])
|
|||
|
|
|
|||
|
|
for r in rows:
|
|||
|
|
writer.writerow([r['chart_no'], r['path_code'], r['site'], r['type'], r['tissue'], r['diagnosis']])
|
|||
|
|
|
|||
|
|
print(f"Successfully exported {len(rows)} rows to {output_csv}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
inp = sys.argv[1] if len(sys.argv) > 1 else None
|
|||
|
|
out = sys.argv[2] if len(sys.argv) > 2 else None
|
|||
|
|
lim = int(sys.argv[3]) if len(sys.argv) > 3 and sys.argv[3].isdigit() else None
|
|||
|
|
m = sys.argv[4] if len(sys.argv) > 4 else 'all'
|
|||
|
|
process_extraction(inp, out, limit=lim, mode=m)
|