#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Extracción read-only de datos del sistema legacy SKEEN. Uso autorizado por el responsable del proyecto con credenciales proporcionadas. Extrae pacientes (con expediente clínico completo), citas, ventas, monederos, servicios, etc. """ import requests import csv import json import re import sys import time from pathlib import Path from datetime import datetime, timedelta from concurrent.futures import ThreadPoolExecutor, as_completed from bs4 import BeautifulSoup BASE = 'https://sistema.skeenmx.app' USERNAME = 'JoseSkeen' PASSWORD = 'SKr#902_88' OUTDIR = Path('/root/migracion/extraccion') OUTDIR.mkdir(parents=True, exist_ok=True) CACHE = OUTDIR / 'detalle_pacientes.json' session = requests.Session() def log(msg): print(f'[{datetime.now().strftime("%H:%M:%S")}] {msg}') def login(): log('Iniciando sesión...') r = session.get(f'{BASE}/login', timeout=30) r.raise_for_status() soup = BeautifulSoup(r.text, 'html.parser') token = soup.find('input', {'name': '_token'}) token = token['value'] if token else '' data = { '_token': token, 'username': USERNAME, 'password': PASSWORD, } r = session.post(f'{BASE}/login', data=data, timeout=30, allow_redirects=True) r.raise_for_status() if '/login' in r.url or 'estas credenciales' in r.text.lower(): raise Exception('Login fallido. Verifica credenciales.') log('Login exitoso') return True def dt_params(start=0, length=100, search=''): return { 'draw': 1, 'columns[0][data]': 'id', 'columns[0][name]': '', 'columns[0][searchable]': 'true', 'columns[0][orderable]': 'true', 'columns[0][search][value]': '', 'columns[0][search][regex]': 'false', 'start': start, 'length': length, 'search[value]': search, 'search[regex]': 'false', 'order[0][column]': '0', 'order[0][dir]': 'asc', '_': int(time.time() * 1000), } def fetch_json(url, params=None): headers = {'X-Requested-With': 'XMLHttpRequest'} r = session.get(f'{BASE}{url}', params=params or {}, headers=headers, timeout=120) r.raise_for_status() return r.json() def paginate(url, params_builder, label, page_size=500, max_pages=None): log(f'Extrayendo {label} desde {url}...') all_rows = [] start = 0 page = 0 while True: params = params_builder(start, page_size) try: data = fetch_json(url, params) except Exception as e: log(f' ERROR página {page}: {e}') break rows = data.get('data', []) if not rows: break all_rows.extend(rows) total = data.get('recordsTotal') or data.get('recordsFiltered') or 0 log(f' página {page + 1}: {len(rows)} filas (total acumulado: {len(all_rows)}/{total})') if len(rows) < page_size: break start += page_size page += 1 if max_pages and page >= max_pages: break time.sleep(0.3) log(f'{label}: {len(all_rows)} registros extraídos') return all_rows def save_csv(name, rows, fieldnames): path = OUTDIR / f'{name}.csv' with open(path, 'w', newline='', encoding='utf-8') as f: writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction='ignore') writer.writeheader() writer.writerows(rows) log(f'Guardado {path}: {len(rows)} registros') MESES_ES = { 'enero': '01', 'febrero': '02', 'marzo': '03', 'abril': '04', 'mayo': '05', 'junio': '06', 'julio': '07', 'agosto': '08', 'septiembre': '09', 'octubre': '10', 'noviembre': '11', 'diciembre': '12', } def parse_spanish_date(text): text = (text or '').strip().lower() if not text: return '' m = re.search(r'(\d{1,2})\s+de\s+([a-záéíóúñ]+)\s+(?:del|de)\s+(\d{4})', text) if not m: return '' dia, mes, anio = m.groups() mes_num = MESES_ES.get(mes) if not mes_num: return '' return f'{anio}-{mes_num}-{int(dia):02d}' def normalize_phone(phone): phone = re.sub(r'\D', '', (phone or '')) if phone and not phone.startswith('52') and len(phone) == 10: phone = '52' + phone return phone def parse_patient_detail(html, fallback_name=''): """Extrae datos personales e historia clínica completa del HTML del expediente.""" soup = BeautifulSoup(html, 'html.parser') text = soup.get_text(separator='\n') lines = [l.strip() for l in text.split('\n') if l.strip()] def find_line_after(keywords): for i, line in enumerate(lines): low = line.lower() if any(k in low for k in keywords): if i + 1 < len(lines): return lines[i + 1] return '' def find_pair(keywords): for i, line in enumerate(lines): low = line.lower() if any(k in low for k in keywords): # Si la respuesta está en la misma línea separada por ':' o espacio if ':' in line: return line.split(':', 1)[1].strip() if i + 1 < len(lines): return lines[i + 1] return '' def yes_no(value): v = (value or '').lower() if v.startswith('s') or v == 'yes': return True return False # Extraer email con regex del texto completo emails = re.findall(r'[\w.\-]+@[\w.\-]+', text) email = emails[0] if emails else '' if email.lower() in ('no@no.com', 'no@no'): email = '' data = { 'celular': '', 'whatsapp': '', 'email': email, 'fecha_nacimiento': '', 'sexo': '', 'lugar_nacimiento': '', 'empleo': '', 'estado_civil': '', 'contacto_emergencia': '', 'telefono_emergencia': '', 'telefono_casa': '', 'direccion': '', 'recomendado_por': '', 'comentarios': '', 'hijos': 0, 'embarazada': False, 'lactancia': False, 'anticonceptivos': False, 'ejercicio': False, 'alimentacion': False, 'peso_normal': False, 'enfermedad_seria': False, 'alcohol': False, 'problemas_rinon': False, 'problemas_espalda': False, 'problemas_cardiacos': False, 'problemas_respiratorios': False, 'alta_presion': False, 'baja_presion': False, 'diabetes': False, 'hipertiroidismo': False, 'hipotiroidismo': False, 'colitis': False, 'estrenimiento': False, 'problemas_higado': False, 'enfermedades_cronicas': False, 'cirugias': False, 'varices': False, 'migrana': False, 'desmaya_agujas': False, 'alergias': '', 'medicamentos': '', 'tratamientos_previos': '', } # Recorrer líneas buscando pares pregunta/respuesta i = 0 while i < len(lines): line = lines[i] low = line.lower() nxt = lines[i + 1] if i + 1 < len(lines) else '' # Datos personales if 'celular' in low and len(low) < 20: data['celular'] = re.sub(r'\D', '', nxt) elif 'whatsapp' in low and len(low) < 20: data['whatsapp'] = re.sub(r'\D', '', nxt) elif 'correo electrónico' in low or 'email' in low: data['email'] = nxt if '@' in nxt else data['email'] elif 'fecha de nacimiento' in low: data['fecha_nacimiento'] = parse_spanish_date(nxt or line) elif 'lugar de nacimiento' in low: data['lugar_nacimiento'] = nxt elif low == 'sexo': data['sexo'] = 'female' if 'femenino' in nxt.lower() else ('male' if 'masculino' in nxt.lower() else '') elif 'empleo' in low: data['empleo'] = nxt elif 'estado civil' in low: data['estado_civil'] = nxt elif 'en caso de emergencia llamar a' in low: parts = nxt.split() if parts: data['contacto_emergencia'] = parts[0] data['telefono_emergencia'] = normalize_phone(' '.join(parts[1:])) elif 'teléfono casa' in low or 'telefono casa' in low: data['telefono_casa'] = re.sub(r'\D', '', nxt) elif low == 'dirección' or low == 'direccion': data['direccion'] = nxt elif 'recomendado por' in low: data['recomendado_por'] = nxt elif 'comentarios adicionales' in low: data['comentarios'] = nxt # Historia clínica elif 'tiene hijos' in low: val = nxt.lower() if val.isdigit(): data['hijos'] = int(val) elif val.startswith('s'): data['hijos'] = 1 elif 'embarazada' in low: data['embarazada'] = yes_no(nxt) elif 'lactancia' in low: data['lactancia'] = yes_no(nxt) elif 'anticonceptivo' in low: data['anticonceptivos'] = yes_no(nxt) elif 'hace ejercicio' in low: data['ejercicio'] = yes_no(nxt) elif 'come en forma adecuada' in low: data['alimentacion'] = yes_no(nxt) elif 'peso corporal normal' in low: data['peso_normal'] = yes_no(nxt) elif 'enfermedad seria' in low or 'incapacidad física' in low: data['enfermedad_seria'] = yes_no(nxt) elif 'bebidas alcohólicas' in low: data['alcohol'] = yes_no(nxt) elif 'riñones' in low or 'rinon' in low: data['problemas_rinon'] = yes_no(nxt) elif 'espalda' in low: data['problemas_espalda'] = yes_no(nxt) elif 'cardiacos' in low or 'cardíacos' in low: data['problemas_cardiacos'] = yes_no(nxt) elif 'respiratorios' in low: data['problemas_respiratorios'] = yes_no(nxt) elif 'alta presión' in low or 'alta presion' in low: data['alta_presion'] = yes_no(nxt) elif 'baja presión' in low or 'baja presion' in low: data['baja_presion'] = yes_no(nxt) elif 'diabetes' in low: data['diabetes'] = yes_no(nxt) elif 'hipertiroidismo' in low: data['hipertiroidismo'] = yes_no(nxt) elif 'hipotiroidismo' in low: data['hipotiroidismo'] = yes_no(nxt) elif 'colitis' in low: data['colitis'] = yes_no(nxt) elif 'estreñimiento' in low: data['estrenimiento'] = yes_no(nxt) elif 'hígado' in low or 'higado' in low or 'vesícula' in low: data['problemas_higado'] = yes_no(nxt) elif 'enfermedades crónicas' in low or 'enfermedades cronicas' in low: data['enfermedades_cronicas'] = yes_no(nxt) elif 'realizado cirugías' in low or 'realizado cirugias' in low: data['cirugias'] = yes_no(nxt) elif 'varicosas' in low or 'varices' in low: data['varices'] = yes_no(nxt) elif 'migraña' in low or 'migrana' in low: data['migrana'] = yes_no(nxt) elif 'desmaya' in low and 'agujas' in low: data['desmaya_agujas'] = yes_no(nxt) elif low == 'alergias': data['alergias'] = nxt if nxt.lower() != 'no' else '' elif 'medicamentos actualmente' in low: data['medicamentos'] = nxt if nxt.lower() != 'no' else '' elif 'tratamientos médico' in low or 'tratamientos medico' in low or 'tratamientos cosméticos previos' in low: data['tratamientos_previos'] = nxt if nxt.lower() != 'no' else '' i += 1 return data def load_detail_cache(): if CACHE.exists(): try: with open(CACHE, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: log(f'No se pudo cargar caché: {e}') return {} def save_detail_cache(cache): with open(CACHE, 'w', encoding='utf-8') as f: json.dump(cache, f, ensure_ascii=False, indent=2) def fetch_one_detail(legacy_id, fallback_name): try: resp = session.get(f'{BASE}/expediente/ver/{legacy_id}', timeout=30) resp.raise_for_status() return str(legacy_id), parse_patient_detail(resp.text, fallback_name) except Exception as e: return str(legacy_id), {'_error': str(e)} def extract_paciente_details(rows, cache, max_workers=10): to_fetch = [] for r in rows: legacy_id = r.get('id') if not legacy_id: continue sid = str(legacy_id) if sid in cache and cache[sid].get('celular'): continue to_fetch.append((legacy_id, r.get('nombre', ''))) log(f'Extrayendo detalles de {len(to_fetch)} pacientes con {max_workers} workers...') total = len(to_fetch) completed = 0 with ThreadPoolExecutor(max_workers=max_workers) as ex: future_to_id = {ex.submit(fetch_one_detail, lid, name): lid for lid, name in to_fetch} for future in as_completed(future_to_id): lid, detail = future.result() if '_error' not in detail: cache[lid] = detail completed += 1 if completed % 500 == 0: log(f' {completed}/{total} detalles procesados') save_detail_cache(cache) time.sleep(0.01) save_detail_cache(cache) log('Detalles de pacientes completados') def extract_expedientes(): rows = paginate('/expedientes/json', lambda s, l: dt_params(s, l), 'pacientes') cache = load_detail_cache() extract_paciente_details(rows, cache) out = [] for r in rows: nombre = (r.get('nombre') or '').strip() if not nombre or nombre.upper() == '. . NO CITAR': continue detail = cache.get(str(r.get('id', '')), {}) phone = detail.get('celular') or detail.get('whatsapp') or (r.get('telefono') or '').strip() phone = normalize_phone(phone) email = detail.get('email', '') if email.lower() in ('no@no.com', 'no@no'): email = '' out.append({ 'legacy_id': r.get('id', ''), 'folio': r.get('folio', ''), 'nombre': nombre, 'apellido_paterno': '', 'apellido_materno': '', 'telefono': phone, 'email': email, 'fecha_nacimiento': detail.get('fecha_nacimiento', ''), 'sexo': detail.get('sexo', ''), 'lugar_nacimiento': detail.get('lugar_nacimiento', ''), 'empleo': detail.get('empleo', ''), 'estado_civil': detail.get('estado_civil', ''), 'contacto_emergencia': detail.get('contacto_emergencia', ''), 'telefono_emergencia': detail.get('telefono_emergencia', ''), 'telefono_casa': detail.get('telefono_casa', ''), 'direccion': detail.get('direccion', ''), 'recomendado_por': detail.get('recomendado_por', ''), 'comentarios': detail.get('comentarios', ''), 'medico_principal': r.get('medico_principal') or '', 'etiquetas': ','.join(str(c) for c in (r.get('condiciones') or [])) if r.get('condiciones') else '', 'vip': '1' if r.get('vip') else '0', 'fuente': 'legacy', # Historia clínica 'hijos': detail.get('hijos', 0), 'embarazada': '1' if detail.get('embarazada') else '0', 'lactancia': '1' if detail.get('lactancia') else '0', 'anticonceptivos': '1' if detail.get('anticonceptivos') else '0', 'ejercicio': '1' if detail.get('ejercicio') else '0', 'alimentacion': '1' if detail.get('alimentacion') else '0', 'peso_normal': '1' if detail.get('peso_normal') else '0', 'enfermedad_seria': '1' if detail.get('enfermedad_seria') else '0', 'alcohol': '1' if detail.get('alcohol') else '0', 'problemas_rinon': '1' if detail.get('problemas_rinon') else '0', 'problemas_espalda': '1' if detail.get('problemas_espalda') else '0', 'problemas_cardiacos': '1' if detail.get('problemas_cardiacos') else '0', 'problemas_respiratorios': '1' if detail.get('problemas_respiratorios') else '0', 'alta_presion': '1' if detail.get('alta_presion') else '0', 'baja_presion': '1' if detail.get('baja_presion') else '0', 'diabetes': '1' if detail.get('diabetes') else '0', 'hipertiroidismo': '1' if detail.get('hipertiroidismo') else '0', 'hipotiroidismo': '1' if detail.get('hipotiroidismo') else '0', 'colitis': '1' if detail.get('colitis') else '0', 'estrenimiento': '1' if detail.get('estrenimiento') else '0', 'problemas_higado': '1' if detail.get('problemas_higado') else '0', 'enfermedades_cronicas': '1' if detail.get('enfermedades_cronicas') else '0', 'cirugias': '1' if detail.get('cirugias') else '0', 'varices': '1' if detail.get('varices') else '0', 'migrana': '1' if detail.get('migrana') else '0', 'desmaya_agujas': '1' if detail.get('desmaya_agujas') else '0', 'alergias': detail.get('alergias', ''), 'medicamentos': detail.get('medicamentos', ''), 'tratamientos_previos': detail.get('tratamientos_previos', ''), }) save_csv('pacientes', out, [ 'legacy_id', 'folio', 'nombre', 'apellido_paterno', 'apellido_materno', 'telefono', 'email', 'fecha_nacimiento', 'sexo', 'lugar_nacimiento', 'empleo', 'estado_civil', 'contacto_emergencia', 'telefono_emergencia', 'telefono_casa', 'direccion', 'recomendado_por', 'comentarios', 'medico_principal', 'etiquetas', 'vip', 'fuente', 'hijos', 'embarazada', 'lactancia', 'anticonceptivos', 'ejercicio', 'alimentacion', 'peso_normal', 'enfermedad_seria', 'alcohol', 'problemas_rinon', 'problemas_espalda', 'problemas_cardiacos', 'problemas_respiratorios', 'alta_presion', 'baja_presion', 'diabetes', 'hipertiroidismo', 'hipotiroidismo', 'colitis', 'estrenimiento', 'problemas_higado', 'enfermedades_cronicas', 'cirugias', 'varices', 'migrana', 'desmaya_agujas', 'alergias', 'medicamentos', 'tratamientos_previos' ]) return out def extract_monederos(): rows = paginate('/monederos/json', lambda s, l: dt_params(s, l), 'monederos') out = [] for r in rows: saldo = r.get('saldo') or {} phone = normalize_phone(r.get('celular') or '') out.append({ 'telefono': phone, 'puntos': float(saldo.get('mn') or 0), 'activo': '1' if r.get('activado') else '0', }) save_csv('monederos', out, ['telefono', 'puntos', 'activo']) return out def extract_crm(): rows = paginate('/crm/prospectos/json', lambda s, l: dt_params(s, l), 'crm prospectos') out = [] for r in rows: out.append({ 'nombre': r.get('nombre') or '', 'folio': r.get('folio') or '', 'status': r.get('status') or 0, 'total_citas': r.get('total_citas') or 0, 'siguiente_cita': r.get('siguiente_cita') or '', }) save_csv('crm_prospectos', out, ['nombre', 'folio', 'status', 'total_citas', 'siguiente_cita']) return out def extract_servicios(): rows = paginate('/servicios/json', lambda s, l: dt_params(s, l), 'servicios') out = [] for r in rows: precio_str = (r.get('precio_minimo') or '0').replace('$', '').replace('m.n.', '').replace(',', '').strip() try: precio = float(precio_str.split()[0]) except Exception: precio = 0 out.append({ 'codigo': f"S{r.get('id', 0):04d}", 'nombre': r.get('servicio') or '', 'categoria': (r.get('categoria') or '').lower().replace(' ', '_'), 'precio': precio, 'duracion_min': 30, 'descripcion': r.get('grupo_texto') or '', 'color': r.get('color') or '#1abc9c', 'grupo': r.get('grupo_texto') or '', 'favorito': '1' if r.get('favorito') else '0', 'activo': '1' if r.get('activado') else '0', }) save_csv('servicios', out, [ 'codigo', 'nombre', 'categoria', 'precio', 'duracion_min', 'descripcion', 'color', 'grupo', 'favorito', 'activo' ]) return out def extract_ventas(start_date='2022-01-01', end_date='2026-07-06'): log(f'Extrayendo ventas de {start_date} a {end_date}...') phone_by_legacy = {} pac_path = OUTDIR / 'pacientes.csv' if pac_path.exists(): with open(pac_path, newline='', encoding='utf-8') as f: for row in csv.DictReader(f): phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '') all_rows = [] current = datetime.strptime(start_date, '%Y-%m-%d').date() end = datetime.strptime(end_date, '%Y-%m-%d').date() while current <= end: chunk_end = min(current + timedelta(days=30), end) url = f"/ventas/json/{current.strftime('%Y-%m-%d')}/{chunk_end.strftime('%Y-%m-%d')}" try: data = fetch_json(url, dt_params(0, 10000)) rows = data.get('data', []) all_rows.extend(rows) log(f' {current} - {chunk_end}: {len(rows)} ventas (total: {len(all_rows)})') except Exception as e: log(f' ERROR {current}-{chunk_end}: {e}') current = chunk_end + timedelta(days=1) time.sleep(0.5) out = [] for r in all_rows: expediente_id = r.get('expedientes_id') or '' phone = normalize_phone(phone_by_legacy.get(str(expediente_id), '')) conceptos = r.get('conceptos') or [] if not conceptos: conceptos = [{'titulo': r.get('motivo') or 'Servicio', 'subtotal': r.get('subtotal') or 0}] for c in conceptos: precio = 0 try: precio = float(c.get('subtotal') or c.get('pagado') or 0) except Exception: pass out.append({ 'legacy_id': r.get('id', ''), 'expedientes_id': expediente_id, 'paciente_telefono': phone, 'paciente_nombre': r.get('nombre') or '', 'fecha': (r.get('created_at') or '')[:10], 'servicio_codigo': '', 'servicio_nombre': c.get('titulo') or '', 'cantidad': c.get('cantidad', 1) or 1, 'precio_unitario': precio, 'descuento': 0, 'impuesto': 0, 'estado': 'confirmed' if not r.get('cancelada') else 'cancelled', 'notas': r.get('motivo') or '', }) save_csv('ventas', out, [ 'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre', 'fecha', 'servicio_codigo', 'servicio_nombre', 'cantidad', 'precio_unitario', 'descuento', 'impuesto', 'estado', 'notas' ]) return out def extract_citas(start_date='2022-11-01', end_date='2026-07-06'): log(f'Extrayendo citas de {start_date} a {end_date}...') phone_by_legacy = {} pac_path = OUTDIR / 'pacientes.csv' if pac_path.exists(): with open(pac_path, newline='', encoding='utf-8') as f: for row in csv.DictReader(f): phone_by_legacy[row.get('legacy_id', '')] = row.get('telefono', '') medicos_map = { '32': 'Dra. Alejandra Ramos', '33': 'Dra. Lidia Martinez', '46': 'Dra. Fernanda Cerecer', '49': 'RODRIGUEZ FRIDA', '54': 'Dr. Benjamín Adrián', '79': 'XIMENA', '80': 'ELY', } all_citas = [] current = datetime.strptime(start_date, '%Y-%m-%d').date() end = datetime.strptime(end_date, '%Y-%m-%d').date() days_with = 0 while current <= end: url = f"/agenda/citas/{current.strftime('%Y-%m-%d')}" try: data = fetch_json(url) citas = data.get('citas', []) if citas: days_with += 1 all_citas.extend(citas) if current.day == 1 or len(citas) > 0: log(f' {current}: {len(citas)} citas (total acumulado: {len(all_citas)})') except Exception as e: log(f' ERROR {current}: {e}') current += timedelta(days=1) time.sleep(0.05) out = [] for c in all_citas: nombre = (c.get('nombre') or '').strip() if not nombre or nombre.upper().startswith('. . NO CITAR'): continue expediente_id = c.get('expedientes_id') or '' phone = normalize_phone(c.get('telefono') or phone_by_legacy.get(str(expediente_id), '')) if not phone and c.get('whatsapp'): phone = normalize_phone(c.get('whatsapp')) email = (c.get('correo') or '').strip() if email.lower() in ('no@no.com', 'no@no'): email = '' inicio = c.get('inicio') or '' fin = c.get('fin') or '' hora_str = '' if len(inicio) == 4: hora_str = f"{inicio[:2]}:{inicio[2:]}" elif len(inicio) == 3: hora_str = f"0{inicio[0]}:{inicio[1:]}" medico_id = str(c.get('medicos_id') or '') doctor_nombre = medicos_map.get(medico_id, medico_id if medico_id and medico_id != '0' else '') out.append({ 'legacy_id': c.get('id', ''), 'expedientes_id': expediente_id, 'paciente_telefono': phone, 'paciente_nombre': nombre, 'servicio_codigo': '', 'servicio_nombre': c.get('titulo') or '', 'fecha': c.get('fecha', ''), 'hora': hora_str, 'hora_fin': f"{fin[:2]}:{fin[2:]}" if len(fin) == 4 else '', 'estado': 'confirmed', 'doctor_nombre': doctor_nombre, 'sucursal': 'rosarito', 'medio': 'onsite', 'notas': c.get('observaciones') or '', 'estado_pago': 'not_paid', 'monto_pagado': 0, }) save_csv('citas', out, [ 'legacy_id', 'expedientes_id', 'paciente_telefono', 'paciente_nombre', 'servicio_codigo', 'servicio_nombre', 'fecha', 'hora', 'hora_fin', 'estado', 'doctor_nombre', 'sucursal', 'medio', 'notas', 'estado_pago', 'monto_pagado' ]) log(f'Citas: {len(out)} registros útiles en {days_with} días con citas') return out def main(): try: login() except Exception as e: log(f'ERROR login: {e}') sys.exit(1) extract_expedientes() extract_monederos() extract_crm() extract_servicios() extract_ventas() extract_citas() log('Extracción completada. Archivos en /root/migracion/extraccion/') if __name__ == '__main__': main()