#!/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. Solo lee datos; no modifica nada en https://sistema.skeenmx.app/ """ 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=''): """Genera parámetros DataTables server-side.""" 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): """Recorre un endpoint DataTables paginado y devuelve todas las filas.""" 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): """Convierte '05 de Septiembre del 1998' → '1998-09-05'.""" 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 parse_patient_detail(html, fallback_name=''): """Extrae teléfono, email, fecha de nacimiento y sexo del detalle.""" soup = BeautifulSoup(html, 'html.parser') data = { 'celular': '', 'whatsapp': '', 'email': '', 'fecha_nacimiento': '', 'sexo': '', } # Campos de tablas for tr in soup.find_all('tr'): th = tr.find('th') td = tr.find('td') if not th or not td: continue key = ' '.join(th.get_text(strip=True).split()).lower().rstrip(':') val = ' '.join(td.get_text(strip=True).split()) if key == 'celular': data['celular'] = re.sub(r'\D', '', val) elif key == 'whatsapp': data['whatsapp'] = re.sub(r'\D', '', val) elif key == 'sexo': data['sexo'] = 'female' if 'femenino' in val.lower() else ('male' if 'masculino' in val.lower() else val) elif key == 'fecha de nacimiento': data['fecha_nacimiento'] = parse_spanish_date(val) # Email suele aparecer como texto suelto o en un mailto emails = re.findall(r'[\w.\-]+@[\w.\-]+', soup.get_text()) if emails: data['email'] = emails[0] 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): """Recorre /expediente/ver/{id} para enriquecer teléfono/email/etc.""" to_fetch = [] for r in rows: legacy_id = r.get('id') if not legacy_id: continue sid = str(legacy_id) # Solo re-extraer si no tenemos teléfono o no está cacheado if sid in cache and (cache[sid].get('celular') or cache[sid].get('whatsapp')): 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) # pequeña pausa entre completados 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', '')), {}) # Prioridad: celular > whatsapp > teléfono del listado phone = detail.get('celular') or detail.get('whatsapp') or (r.get('telefono') or '').strip() # Limpieza: quitar todo excepto dígitos; si empieza con 52 y tiene 12+ dígitos dejarlo, sino agregar prefijo MX si aplica phone = re.sub(r'\D', '', phone) if phone and not phone.startswith('52') and len(phone) == 10: phone = '52' + phone email = detail.get('email', '') # Algunos correos dummy los ignoramos 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', ''), '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', }) save_csv('pacientes', out, [ 'legacy_id', 'folio', 'nombre', 'apellido_paterno', 'apellido_materno', 'telefono', 'email', 'fecha_nacimiento', 'sexo', 'medico_principal', 'etiquetas', 'vip', 'fuente' ]) 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 = re.sub(r'\D', '', (r.get('celular') or '').strip()) if phone and not phone.startswith('52') and len(phone) == 10: phone = '52' + phone 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 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 extract_ventas(start_date='2022-01-01', end_date='2026-07-06'): """Extrae ventas por mes y enriquece con teléfono del paciente.""" log(f'Extrayendo ventas de {start_date} a {end_date}...') # Cargar mapeo legacy_id → teléfono desde pacientes.csv si ya existe 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), '')) # Servicios vendidos: tomar del primer concepto; si hay varios, crear una línea por concepto 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'): """Itera día a día sobre /agenda/citas/YYYY-MM-DD para obtener todas las citas.""" 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', '') # Mapeo de IDs de médicos del sistema legacy a nombres (de /agenda) 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 '' # El teléfono de la cita suele estar en el registro; si no, buscamos en pacientes 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 '' # inicio/fin vienen como '1430' (HHMM) 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:]}" # Nombre del médico 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()