Files
aulas-unicaba-mvp/src/app/services/siu-guarani.service.ts
T

476 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable } from '@angular/core';
import { CapacitorHttp, CapacitorCookies, HttpResponse } from '@capacitor/core';
import { Filesystem, Directory } from '@capacitor/filesystem';
import { FileOpener } from '@capacitor-community/file-opener';
import { AuthService } from './auth.service';
@Injectable({ providedIn: 'root' })
export class SiuService {
constructor(private authService: AuthService) {}
private readonly BASE_URL = 'https://autogestion.udelaciudad.edu.ar/caba';
async getAcademicHistory(dni: string, pass: string, carreraId: string = '30'): Promise<any[]> {
try {
const authCookies = await this.authenticateSiu(dni, pass);
const sessionCookies = await this.selectCareer(carreraId, authCookies);
// 3. OBTENER PLAN DE ESTUDIO
const { html: rawHtml } = await this.getHtml(`${this.BASE_URL}/plan_estudio`, sessionCookies);
if (!rawHtml) {
throw new Error('No se recibió contenido del plan de estudios.');
}
const regex = /on_arrival\((\{.*?"id":"info_plan".*?\})\);/s;
const match = rawHtml.match(regex);
if (match && match[1]) {
const jsonData = JSON.parse(match[1]);
const contentHtml = jsonData.content;
return this.parseHtmlSubjects(contentHtml);
}
if (rawHtml.includes('acceso?auth=form') || rawHtml.includes('id="login"')) {
throw new Error('La sesión de SIU Guaraní no se pudo autenticar o expiró.');
}
throw new Error('No se encontró el bloque de materias');
} catch (error) {
console.error('Error SIU Academic History:', error);
throw error;
}
}
async getInscripcionesActivas(dni: string, pass: string, carreraId: string = '30'): Promise<any[]> {
try {
const authCookies = await this.authenticateSiu(dni, pass);
const sessionCookies = await this.selectCareer(carreraId, authCookies);
// OBTENER PÁGINA DE CURSADA
const { html: rawHtml } = await this.getHtml(`${this.BASE_URL}/cursada`, sessionCookies);
if (!rawHtml) return [];
// 1. Extraer directamente de "notificaciones" donde SIU provee el array JSON "asignaciones"
const regexNotif = /on_arrival\((\{.*?"id":"notificaciones".*?\})\);/s;
const matchNotif = rawHtml.match(regexNotif);
if (matchNotif && matchNotif[1]) {
try {
const jsonNotif = JSON.parse(matchNotif[1]);
const asignaciones = jsonNotif.info?.asignaciones;
if (Array.isArray(asignaciones) && asignaciones.length > 0) {
return asignaciones.map((asig: any) => ({
nombre: asig.NOMBRE_MATERIA || asig.MATERIA,
codigo: asig.NOMBRE || asig.MATERIA, // ej: "ASIG00127-E1-TD"
comision: asig.COMISION,
dia: asig.DIA_SEMANA
}));
}
} catch (e) {
console.warn('No se pudieron leer asignaciones estructuradas:', e);
}
}
// 2. Fallback: extraer del bloque HTML "info_materia" (tabla .js-mis-inscripciones)
const regex = /on_arrival\((\{.*?"id":"info_materia".*?\})\);/s;
const match = rawHtml.match(regex);
if (match && match[1]) {
const jsonData = JSON.parse(match[1]);
const contentHtml = jsonData.content;
return this.parseInscripcionesActivasHtml(contentHtml);
}
return [];
} catch (error) {
console.error('Error SIU Active Inscriptions:', error);
return [];
}
}
private async authenticateSiu(dni: string, pass: string): Promise<string> {
console.log('Autenticando en SIU...');
const loginData = `usuario=${encodeURIComponent(dni)}&password=${encodeURIComponent(pass)}&login=Ingresar`;
const headers = this.getCommonHeaders();
headers['Content-Type'] = 'application/x-www-form-urlencoded';
const resLogin: HttpResponse = await CapacitorHttp.post({
url: `${this.BASE_URL}/acceso?auth=form`,
data: loginData,
headers
});
let cookies = this.extractCookies(resLogin.headers);
const stored = await this.getStoredCookies();
cookies = this.mergeCookieStrings(cookies, stored);
const dataStr = this.decodeResponseData(resLogin.data);
if (dataStr && (dataStr.includes('El usuario o la contraseña no son correctos') || dataStr.includes('error-login') || dataStr.includes('login_error'))) {
throw new Error('DNI o Contraseña incorrectos en SIU Guaraní.');
}
return cookies;
}
private async selectCareer(carreraId: string, currentCookies: string): Promise<string> {
console.log('Seleccionando carrera:', carreraId);
const resCambio = await this.getHtml(`${this.BASE_URL}/acceso/cambiar_carrera?id=${carreraId}&op=inicio_alumno`, currentCookies);
const resInicio = await this.getHtml(`${this.BASE_URL}/inicio_alumno`, resCambio.cookies);
return resInicio.cookies;
}
private async getHtml(url: string, cookies: string): Promise<{ html: string; cookies: string }> {
let updatedCookies = cookies;
const res: HttpResponse = await CapacitorHttp.get({
url,
headers: this.getCommonHeaders(updatedCookies)
});
updatedCookies = this.mergeCookies(updatedCookies, res.headers);
const stored = await this.getStoredCookies();
updatedCookies = this.mergeCookieStrings(updatedCookies, stored);
let html = this.decodeResponseData(res.data);
// Fallback: si html es nulo/vacío por codificación no-UTF8 (ej. ISO-8859-1 en SIU), solicitar arraybuffer y decodificar
if (!html) {
console.log(`Reintentando ${url} con arraybuffer para decodificación ISO-8859-1...`);
const resBuffer: HttpResponse = await CapacitorHttp.get({
url,
responseType: 'arraybuffer',
headers: this.getCommonHeaders(updatedCookies)
});
updatedCookies = this.mergeCookies(updatedCookies, resBuffer.headers);
html = this.decodeBase64Response(resBuffer.data);
}
return { html, cookies: updatedCookies };
}
private decodeResponseData(data: any): string {
if (typeof data === 'string') {
return data;
}
if (data && typeof data === 'object') {
return JSON.stringify(data);
}
return '';
}
private decodeBase64Response(base64: string): string {
if (!base64 || typeof base64 !== 'string') return '';
try {
const binaryString = window.atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
try {
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
return utf8Decoder.decode(bytes);
} catch {
const latinDecoder = new TextDecoder('iso-8859-1');
return latinDecoder.decode(bytes);
}
} catch (e) {
console.error('Error decoding base64 response:', e);
return '';
}
}
private async getStoredCookies(): Promise<string> {
try {
const cookieMap = await CapacitorCookies.getCookies({ url: this.BASE_URL });
if (cookieMap && typeof cookieMap === 'object') {
return Object.entries(cookieMap)
.filter(([k, v]) => k && v)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
} catch (e) {
// CapacitorCookies can fail gracefully without breaking flow
}
return '';
}
private extractTobaContent(html: string, pageletId: string = 'pagelet'): string {
const regexes = [
/kernel\.renderer\.on_arrival\((\{.*?\})\);/gs,
/on_arrival\((\{.*?\})\);/gs
];
for (const regex of regexes) {
let match;
// Reset regex index for global searches
regex.lastIndex = 0;
while ((match = regex.exec(html)) !== null) {
try {
const jsonData = JSON.parse(match[1]);
if (jsonData.id === pageletId || jsonData.info?.id === pageletId) {
return jsonData.content || '';
}
} catch (e) { continue; }
}
}
return '';
}
async downloadCertificate(dni: string, pass: string): Promise<{ base64: string, filename: string }> {
console.log('Iniciando descarga de certificado para:', dni);
try {
const authCookies = await this.authenticateSiu(dni, pass);
const sessionCookies = await this.selectCareer('30', authCookies);
console.log('Login y selección de carrera (ID 30) listos');
// 1. VALIDAR SI EXISTE UNO VIGENTE (< 30 DÍAS)
const { html: rawHtmlSolicitudes, cookies: solicitCookies } = await this.getHtml(`${this.BASE_URL}/solicitudes`, sessionCookies);
console.log('Página de solicitudes obtenida');
const contentHtmlSolicitudes = this.extractTobaContent(rawHtmlSolicitudes, 'pagelet');
if (contentHtmlSolicitudes) {
const existingCert = this.findValidCertificate(contentHtmlSolicitudes);
if (existingCert) {
console.log('Certificado vigente encontrado:', existingCert.filename);
return await this.downloadFile(existingCert.url, existingCert.filename, solicitCookies);
}
}
// 2. GENERAR NUEVA SOLICITUD (HEADLESS)
console.log('No hay certificado vigente o no se pudo leer la tabla. Intentando generar uno nuevo...');
const { html: rawHtmlGestion, cookies: gestionCookies } = await this.getHtml(`${this.BASE_URL}/solicitudes/solicitud_gestion`, solicitCookies);
const contentHtmlGestion = this.extractTobaContent(rawHtmlGestion, 'pagelet');
if (!contentHtmlGestion) {
console.error('HTML completo de gestión no contiene on_arrival de pagelet. Muestra:', rawHtmlGestion.substring(0, 1000));
throw new Error('No se pudo localizar el generador (content missing)');
}
const parser = new DOMParser();
const doc = parser.parseFromString(contentHtmlGestion, 'text/html');
const iframe = doc.querySelector('iframe#frameSolicitud') as HTMLIFrameElement;
let iframeSrc = iframe?.getAttribute('src');
if (!iframeSrc) {
throw new Error('No se pudo localizar el generador (iframe src missing)');
}
console.log('Iframe localizado, src:', iframeSrc);
// Asegurar que la URL del iframe es completa
if (!iframeSrc.startsWith('http')) {
const baseUrlParsed = new URL(this.BASE_URL);
iframeSrc = `${baseUrlParsed.origin}${iframeSrc.startsWith('/') ? '' : '/'}${iframeSrc}`;
}
// Trigger the print event via URL parameters
const printUrl = iframeSrc.includes('?')
? `${iframeSrc}&toba_ei_notificar=imprimir`
: `${iframeSrc}?toba_ei_notificar=imprimir`;
console.log('Solicitando generación de PDF:', printUrl);
return await this.downloadFile(printUrl, `Certificado_Alumno_Regular_${new Date().getTime()}.pdf`, gestionCookies);
} catch (error) {
console.error('Error detallado en SIU Certificate:', error);
throw error;
}
}
private findValidCertificate(html: string): { url: string, filename: string } | null {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const rows = Array.from(doc.querySelectorAll('tr'));
for (const row of rows) {
const cells = Array.from(row.querySelectorAll('td'));
if (cells.length >= 3) {
const tipo = cells[0].textContent?.trim().toLowerCase() || '';
const presentarA = cells[2].textContent?.trim().toLowerCase() || '';
// Identificar constancia de alumno regular
if (tipo.includes('alumno regular')) {
const fechaStr = cells[1].textContent?.trim() || '';
console.log(`Revisando: ${tipo} | Para: ${presentarA} | Fecha: ${fechaStr}`);
if (this.isWithin30Days(fechaStr)) {
// El usuario indico que si dice "Quien corresponda" o similares (como "Google LLC") sirve.
// Solo descartamos si es para un destinatario muy especifico que no sea el solicitado.
const downloadLink = row.querySelector('a.descargar_cert, a[href*="descargar_comprobante"]') as HTMLAnchorElement;
if (downloadLink) {
return {
url: downloadLink.getAttribute('href') || '',
filename: `Certificado_SIU_${presentarA.replace(/\s+/g, '_')}_${fechaStr.replace(/\//g, '-')}.pdf`
};
}
}
}
}
}
return null;
}
private isWithin30Days(fechaStr: string): boolean {
// Formato esperado: DD/MM/YYYY
const parts = fechaStr.split('/');
if (parts.length !== 3) return false;
const date = new Date(parseInt(parts[2]), parseInt(parts[1]) - 1, parseInt(parts[0]));
const diff = new Date().getTime() - date.getTime();
const days = diff / (1000 * 60 * 60 * 24);
return days < 30;
}
private async downloadFile(url: string, filename: string, cookies?: string): Promise<{ base64: string, filename: string }> {
// Si la URL es relativa, completarla
const fullUrl = url.startsWith('http') ? url : `${this.BASE_URL}/${url.startsWith('/') ? url.substring(1) : url}`;
const response: HttpResponse = await CapacitorHttp.get({
url: fullUrl,
responseType: 'blob',
headers: this.getCommonHeaders(cookies)
});
return {
base64: response.data, // CapacitorHttp con responseType blob devuelve base64 string
filename
};
}
private getCommonHeaders(cookies?: string): Record<string, string> {
const headers: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'es-AR,es;q=0.9'
};
if (cookies) {
headers['Cookie'] = cookies;
}
return headers;
}
private extractCookies(headers: Record<string, string> | undefined): string {
return this.mergeCookies('', headers);
}
private mergeCookies(existingCookies: string, newHeaders: Record<string, string> | undefined): string {
if (!newHeaders) return existingCookies;
const rawSetCookie: string = newHeaders['set-cookie'] || newHeaders['Set-Cookie'] || '';
if (!rawSetCookie) return existingCookies;
const cookieMap = new Map<string, string>();
if (existingCookies) {
existingCookies.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
if (k) cookieMap.set(k.trim(), v.join('='));
});
}
const newCookies = rawSetCookie.split(/,(?=[^ ])/);
newCookies.forEach(c => {
const cookiePart = c.trim().split(';')[0];
const [k, ...v] = cookiePart.split('=');
if (k && v.length > 0) {
cookieMap.set(k.trim(), v.join('='));
}
});
return Array.from(cookieMap.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
private mergeCookieStrings(cookies1: string, cookies2: string): string {
if (!cookies1) return cookies2 || '';
if (!cookies2) return cookies1 || '';
const cookieMap = new Map<string, string>();
cookies1.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
if (k) cookieMap.set(k.trim(), v.join('='));
});
cookies2.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
if (k) cookieMap.set(k.trim(), v.join('='));
});
return Array.from(cookieMap.entries())
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
async saveAndOpenFile(base64Data: string, filename: string): Promise<void> {
try {
const savedFile = await Filesystem.writeFile({
path: `Certificados/${filename}`,
data: base64Data,
directory: Directory.Documents,
recursive: true
});
await FileOpener.open({
filePath: savedFile.uri,
contentType: 'application/pdf',
});
} catch (error) {
console.error('Error saving or opening file:', error);
throw error;
}
}
private parseInscripcionesActivasHtml(html: string): any[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Buscamos los elementos que contienen las inscripciones activas
const rows = Array.from(doc.querySelectorAll('.js-mis-inscripciones td, .inscripciones-lista td, .cuadro_materias td, table.tabla_cursada td'));
const results: any[] = [];
for (const td of rows) {
const text = td.textContent?.trim() || '';
if (!text || text.length < 5) continue;
// Caso 1: "Nombre Materia - ASIG00127-E1-TD"
if (text.includes(' - ')) {
const parts = text.split(' - ');
const codigo = parts[parts.length - 1].trim();
const nombre = parts.slice(0, -1).join(' - ').trim();
if (codigo.toUpperCase().includes('ASIG') || codigo.length >= 4) {
results.push({ nombre, codigo });
continue;
}
}
// Caso 2: "Nombre Materia (ASIG00127-E1-TD)"
const matchParen = text.match(/^(.*?)\s*\((ASIG[^\)]+)\)/i);
if (matchParen) {
results.push({ nombre: matchParen[1].trim(), codigo: matchParen[2].trim() });
continue;
}
// Caso 3: contiene código ASIG embebido
const matchAsig = text.match(/(ASIG[A-Z0-9\-_]+)/i);
if (matchAsig) {
const codigo = matchAsig[1].trim();
const nombre = text.replace(codigo, '').replace(/[-–()]/g, '').trim();
results.push({ nombre, codigo });
}
}
return results;
}
private parseHtmlSubjects(html: string): any[] {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const rows = Array.from(doc.querySelectorAll('tr'));
return rows.map(row => {
const cols = row.querySelectorAll('td');
if (cols.length >= 6) {
const nombreRaw = cols[0].textContent?.trim() || '';
return {
nombre: nombreRaw.split(' (')[0],
nota: cols[4].textContent?.trim(),
origen: cols[5].textContent?.trim()
};
}
return null;
}).filter(item => item && item.nombre);
}
}