using System.Net; using System.Net.Mail; using System.Text; namespace SGU.AuthBroker.Core.Profiles; public static class SguProfileParser { private const string AdministrativeNameId = "ctl00_contenedor_decEncabezado_lblNombre"; private const string EmployeeTypeId = "ctl00_contenedor_decEncabezado_lblIndicadorValue"; private const string EmailId = "ctl00_contenedor_decEncabezado_lblCorreo"; private const string JobTitleId = "ctl00_contenedor_decEncabezado_lblPuesto"; private const string DepartmentId = "ctl00_contenedor_decEncabezado_lblDependencia"; private const string MenuNameId = "ctl00_lblNombreUsuario"; private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP"; private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP"; private const string StudentPaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApPatAlumnoHP"; private const string StudentMaternalSurnameId = "ctl00_contenedor_HistorialAlumno1_lblApMatAlumnoHP"; private const string StudentEmailId = "ctl00_contenedor_HistorialAlumno1_lblCorreoAlumnoHP"; private const string StudentCareerId = "ctl00_contenedor_HistorialAlumno1_Header1_lblCarrera"; private const string StudentStreetId = "ctl00_contenedor_HistorialAlumno1_lblDomicilioAlumnoHP"; private const string StudentNeighborhoodId = "ctl00_contenedor_HistorialAlumno1_lblColoniaAlumnoHP"; private const string StudentStateId = "ctl00_contenedor_HistorialAlumno1_lblEstadoAlumnoHP"; private const string StudentCityId = "ctl00_contenedor_HistorialAlumno1_lblCiudadAlumnoHP"; private const string StudentMunicipalityId = "ctl00_contenedor_HistorialAlumno1_lblDeloMunAlumnoHP"; private const string StudentPostalCodeId = "ctl00_contenedor_HistorialAlumno1_lblCPAlumnoHP"; public static InstitutionalProfile? ParseAdministrative(string html, string expectedEmployeeNumber) { ArgumentNullException.ThrowIfNull(html); ArgumentException.ThrowIfNullOrWhiteSpace(expectedEmployeeNumber); string? identity = ExtractSpanText(html, AdministrativeNameId); if (!TrySplitAdministrativeIdentity(identity, out string? employeeNumber, out string? displayName) || !string.Equals(employeeNumber, expectedEmployeeNumber, StringComparison.Ordinal)) { return null; } InstitutionalProfile profile = new( EmployeeNumber: employeeNumber, DisplayName: NormalizeTitle(displayName, 256), Email: NormalizeEmail(ExtractSpanText(html, EmailId)), EmployeeType: NormalizeSentence(ExtractSpanText(html, EmployeeTypeId), 256), JobTitle: NormalizeTitle(ExtractSpanText(html, JobTitleId), 64), Department: NormalizeTitle(ExtractSpanText(html, DepartmentId), 64)); return profile.HasValues ? profile : null; } public static InstitutionalProfile? ParseMenu(string html) { ArgumentNullException.ThrowIfNull(html); InstitutionalProfile profile = new( DisplayName: NormalizeTitle(ExtractSpanText(html, MenuNameId), 256)); return profile.HasValues ? profile : null; } public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber) { ArgumentNullException.ThrowIfNull(html); ArgumentException.ThrowIfNullOrWhiteSpace(expectedStudentNumber); string? studentNumber = NormalizeInstitutionalNumber(ExtractSpanText(html, StudentNumberId)); if (!string.Equals(studentNumber, expectedStudentNumber, StringComparison.Ordinal)) { return null; } string? givenName = NormalizeName(ExtractSpanText(html, StudentGivenNameId), 64); string? paternalSurname = NormalizeSurname(ExtractSpanText(html, StudentPaternalSurnameId), 64); string? maternalSurname = NormalizeSurname(ExtractSpanText(html, StudentMaternalSurnameId), 64); string? surname = NormalizeSurname(JoinNonEmpty(" ", paternalSurname, maternalSurname), 64); string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256); string? street = NormalizeTitle(ExtractSpanText(html, StudentStreetId), 512); string? neighborhood = NormalizeTitle(ExtractSpanText(html, StudentNeighborhoodId), 256); string? city = NormalizeTitle(ExtractSpanText(html, StudentCityId), 128); string? municipality = NormalizeTitle(ExtractSpanText(html, StudentMunicipalityId), 128); string? streetAddress = BuildStreetAddress(street, neighborhood, municipality, city); InstitutionalProfile profile = new( EmployeeNumber: studentNumber, DisplayName: displayName, GivenName: givenName, Surname: surname, Email: NormalizeEmail(ExtractSpanText(html, StudentEmailId)), EmployeeType: "Alumno", JobTitle: BuildStudentJobTitle(ExtractSpanText(html, StudentCareerId)), StreetAddress: streetAddress, City: city ?? municipality, State: NormalizeTitle(ExtractSpanText(html, StudentStateId), 128), PostalCode: NormalizePostalCode(ExtractSpanText(html, StudentPostalCodeId))); return profile.HasValues ? profile : null; } private static bool TrySplitAdministrativeIdentity( string? value, out string? employeeNumber, out string? displayName) { employeeNumber = null; displayName = null; if (string.IsNullOrWhiteSpace(value)) { return false; } int separator = value.IndexOf(" - ", StringComparison.Ordinal); if (separator != 6) { return false; } string candidateNumber = value[..separator]; string candidateName = value[(separator + 3)..].Trim(); if (candidateNumber.Length != 6 || !candidateNumber.All(char.IsAsciiDigit) || string.IsNullOrWhiteSpace(candidateName)) { return false; } employeeNumber = candidateNumber; displayName = candidateName; return true; } private static string? ExtractSpanText(string html, string id) { foreach (char quote in new[] { '"', '\'' }) { string marker = $"id={quote}{id}{quote}"; int searchFrom = 0; while (searchFrom < html.Length) { int idIndex = html.IndexOf(marker, searchFrom, StringComparison.OrdinalIgnoreCase); if (idIndex < 0) { break; } int spanStart = html.LastIndexOf("', idIndex); if (spanStart > precedingTagEnd) { int openingTagEnd = html.IndexOf('>', idIndex); int closingTagStart = openingTagEnd < 0 ? -1 : html.IndexOf("= 0 && closingTagStart >= 0) { string innerHtml = html[(openingTagEnd + 1)..closingTagStart]; return NormalizeText(innerHtml); } } searchFrom = idIndex + marker.Length; } } return null; } private static string? NormalizeText(string htmlFragment) { StringBuilder withoutTags = new(htmlFragment.Length); bool insideTag = false; foreach (char character in htmlFragment) { if (character == '<') { insideTag = true; } else if (character == '>') { insideTag = false; } else if (!insideTag) { withoutTags.Append(character); } } string decoded = WebUtility.HtmlDecode(withoutTags.ToString()); StringBuilder normalized = new(decoded.Length); bool previousWasWhitespace = true; foreach (char character in decoded) { bool whitespace = char.IsWhiteSpace(character) || character == '\u00A0'; if (whitespace) { if (!previousWasWhitespace) { normalized.Append(' '); } } else { normalized.Append(character); } previousWasWhitespace = whitespace; } string result = normalized.ToString().Trim(); return result.Length == 0 ? null : result; } private static string? NormalizeEmail(string? value) { string? candidate = Limit(value, 256); if (candidate is null || !MailAddress.TryCreate(candidate, out MailAddress? address) || !string.Equals(address.Address, candidate, StringComparison.OrdinalIgnoreCase)) { return null; } return address.Address.ToLowerInvariant(); } private static string? NormalizeTitle(string? value, int maximumLength) { string? candidate = Limit(value, maximumLength); return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate); } private static string? NormalizeName(string? value, int maximumLength) { string? candidate = Limit(value, maximumLength); return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate); } private static string? NormalizeSurname(string? value, int maximumLength) { string? candidate = Limit(value, maximumLength); return candidate is null ? null : SpanishTextNormalizer.ToSurnameCase(candidate); } private static string? NormalizeInstitutionalNumber(string? value) { string? candidate = value?.Trim(); return candidate is { Length: 6 } && candidate.All(char.IsAsciiDigit) ? candidate : null; } private static string? BuildStudentJobTitle(string? value) { string? candidate = Limit(value, 256); if (candidate is null) { return null; } candidate = candidate.Replace( "COMPUTACIO NALES", "COMPUTACIONALES", StringComparison.OrdinalIgnoreCase); string career = SpanishTextNormalizer.ToTitleCase(candidate); const string degreePrefix = "Licenciatura en "; if (career.StartsWith(degreePrefix, StringComparison.OrdinalIgnoreCase)) { career = career[degreePrefix.Length..]; } return TruncateAtWordBoundary($"Estudiante de {career}", 64); } private static string? BuildStreetAddress( string? street, string? neighborhood, string? municipality, string? city) { List lines = []; AddDistinct(lines, street); AddDistinct(lines, neighborhood); if (!string.Equals(municipality, city, StringComparison.OrdinalIgnoreCase)) { AddDistinct(lines, municipality); } return Limit(string.Join("\r\n", lines), 1024); } private static void AddDistinct(List values, string? candidate) { if (!string.IsNullOrWhiteSpace(candidate) && !values.Contains(candidate, StringComparer.OrdinalIgnoreCase)) { values.Add(candidate); } } private static string? NormalizePostalCode(string? value) { string? candidate = value?.Trim(); if (candidate is null || candidate.Length is < 4 or > 5 || !candidate.All(char.IsAsciiDigit)) { return null; } return candidate.PadLeft(5, '0'); } private static string? JoinNonEmpty(string separator, params string?[] values) { string result = string.Join(separator, values.Where(value => !string.IsNullOrWhiteSpace(value))); return string.IsNullOrWhiteSpace(result) ? null : result; } private static string TruncateAtWordBoundary(string value, int maximumLength) { if (value.Length <= maximumLength) { return value; } int boundary = value.LastIndexOf(' ', maximumLength - 1, maximumLength); return value[..(boundary > 0 ? boundary : maximumLength)].TrimEnd(); } private static string? NormalizeSentence(string? value, int maximumLength) { string? candidate = Limit(value, maximumLength); return candidate is null ? null : SpanishTextNormalizer.ToSentenceCase(candidate); } private static string? Limit(string? value, int maximumLength) { string? candidate = value?.Trim(); return string.IsNullOrEmpty(candidate) || candidate.Length > maximumLength || candidate.Contains('\uFFFD') ? null : candidate; } }