Enrich administrative profiles from SGU
This commit is contained in:
@@ -27,4 +27,23 @@ public sealed record InstitutionalProfile(
|
||||
City is not null ||
|
||||
State is not null ||
|
||||
PostalCode is not null;
|
||||
|
||||
public InstitutionalProfile Overlay(InstitutionalProfile? values) =>
|
||||
values is null
|
||||
? this
|
||||
: this with
|
||||
{
|
||||
EmployeeNumber = values.EmployeeNumber ?? EmployeeNumber,
|
||||
DisplayName = values.DisplayName ?? DisplayName,
|
||||
GivenName = values.GivenName ?? GivenName,
|
||||
Surname = values.Surname ?? Surname,
|
||||
Email = values.Email ?? Email,
|
||||
EmployeeType = values.EmployeeType ?? EmployeeType,
|
||||
JobTitle = values.JobTitle ?? JobTitle,
|
||||
Department = values.Department ?? Department,
|
||||
StreetAddress = values.StreetAddress ?? StreetAddress,
|
||||
City = values.City ?? City,
|
||||
State = values.State ?? State,
|
||||
PostalCode = values.PostalCode ?? PostalCode
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ public static class SguProfileParser
|
||||
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 AdministrativeGivenNameId = "ctl00_contenedor_txtNombre";
|
||||
private const string AdministrativePaternalSurnameId = "ctl00_contenedor_txtApaterno";
|
||||
private const string AdministrativeMaternalSurnameId = "ctl00_contenedor_txtAmaterno";
|
||||
private const string AdministrativeStreetId = "ctl00_contenedor_txtCalle";
|
||||
private const string AdministrativeExteriorNumberId = "ctl00_contenedor_txtNoExt";
|
||||
private const string AdministrativeInteriorNumberId = "ctl00_contenedor_txtNoInt";
|
||||
private const string AdministrativePostalCodeId = "ctl00_contenedor_txtCP";
|
||||
private const string AdministrativeStateId = "ctl00_contenedor_ddlEstado";
|
||||
private const string AdministrativeCityId = "ctl00_contenedor_ddlLocalidad";
|
||||
private const string AdministrativeNeighborhoodId = "ctl00_contenedor_ddlColonia";
|
||||
private const string MenuNameId = "ctl00_lblNombreUsuario";
|
||||
private const string StudentNumberId = "ctl00_contenedor_HistorialAlumno1_lblClaveAlumnoHP";
|
||||
private const string StudentGivenNameId = "ctl00_contenedor_HistorialAlumno1_lblNombreAlumnoHP";
|
||||
@@ -56,6 +66,51 @@ public static class SguProfileParser
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseAdministrativePersonal(string html)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
string? givenName = NormalizeName(ExtractInputValue(html, AdministrativeGivenNameId), 64);
|
||||
string? paternalSurname = NormalizeSurname(
|
||||
ExtractInputValue(html, AdministrativePaternalSurnameId),
|
||||
64);
|
||||
string? maternalSurname = NormalizeSurname(
|
||||
ExtractInputValue(html, AdministrativeMaternalSurnameId),
|
||||
64);
|
||||
string? surname = NormalizeSurname(
|
||||
JoinNonEmpty(" ", paternalSurname, maternalSurname),
|
||||
64);
|
||||
string? displayName = NormalizeName(JoinNonEmpty(" ", givenName, surname), 256);
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
DisplayName: displayName,
|
||||
GivenName: givenName,
|
||||
Surname: surname);
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseAdministrativeLocation(string html)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
string? street = NormalizeTitle(ExtractInputValue(html, AdministrativeStreetId), 512);
|
||||
string? exteriorNumber = NormalizeAddressUnit(
|
||||
ExtractInputValue(html, AdministrativeExteriorNumberId));
|
||||
string? interiorNumber = NormalizeAddressUnit(
|
||||
ExtractInputValue(html, AdministrativeInteriorNumberId));
|
||||
string? streetLine = BuildAdministrativeStreetLine(street, exteriorNumber, interiorNumber);
|
||||
string? neighborhood = NormalizeTitle(
|
||||
ExtractSelectedOptionText(html, AdministrativeNeighborhoodId),
|
||||
256);
|
||||
|
||||
InstitutionalProfile profile = new(
|
||||
StreetAddress: BuildStreetAddress(streetLine, neighborhood, null, null),
|
||||
City: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeCityId), 128),
|
||||
State: NormalizeTitle(ExtractSelectedOptionText(html, AdministrativeStateId), 128),
|
||||
PostalCode: NormalizePostalCode(ExtractInputValue(html, AdministrativePostalCodeId)));
|
||||
return profile.HasValues ? profile : null;
|
||||
}
|
||||
|
||||
public static InstitutionalProfile? ParseStudent(string html, string expectedStudentNumber)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
@@ -162,6 +217,158 @@ public static class SguProfileParser
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractInputValue(string html, string id)
|
||||
{
|
||||
string? openingTag = FindOpeningTag(html, "input", id);
|
||||
return openingTag is null
|
||||
? null
|
||||
: NormalizeText(ExtractAttributeValue(openingTag, "value") ?? string.Empty);
|
||||
}
|
||||
|
||||
private static string? ExtractSelectedOptionText(string html, string id)
|
||||
{
|
||||
string? openingTag = FindOpeningTag(html, "select", id);
|
||||
if (openingTag is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
int openingTagIndex = html.IndexOf(openingTag, StringComparison.OrdinalIgnoreCase);
|
||||
int contentStart = openingTagIndex + openingTag.Length;
|
||||
int contentEnd = html.IndexOf("</select", contentStart, StringComparison.OrdinalIgnoreCase);
|
||||
if (openingTagIndex < 0 || contentEnd < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string optionsHtml = html[contentStart..contentEnd];
|
||||
string? selectedValue = ExtractAttributeValue(openingTag, "value");
|
||||
List<string> nonPlaceholderOptions = [];
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < optionsHtml.Length)
|
||||
{
|
||||
int optionStart = optionsHtml.IndexOf("<option", searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||
if (optionStart < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int optionTagEnd = optionsHtml.IndexOf('>', optionStart);
|
||||
int optionEnd = optionTagEnd < 0
|
||||
? -1
|
||||
: optionsHtml.IndexOf("</option", optionTagEnd + 1, StringComparison.OrdinalIgnoreCase);
|
||||
if (optionTagEnd < 0 || optionEnd < 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
string optionTag = optionsHtml[optionStart..(optionTagEnd + 1)];
|
||||
string? optionText = NormalizeText(optionsHtml[(optionTagEnd + 1)..optionEnd]);
|
||||
string? optionValue = ExtractAttributeValue(optionTag, "value");
|
||||
if (optionText is not null &&
|
||||
!optionText.StartsWith("Seleccione", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
nonPlaceholderOptions.Add(optionText);
|
||||
}
|
||||
|
||||
if (optionText is not null &&
|
||||
(HasAttribute(optionTag, "selected") ||
|
||||
(selectedValue is not null &&
|
||||
string.Equals(optionValue, selectedValue, StringComparison.Ordinal))))
|
||||
{
|
||||
return optionText;
|
||||
}
|
||||
|
||||
searchFrom = optionEnd + "</option".Length;
|
||||
}
|
||||
|
||||
return nonPlaceholderOptions.Count == 1 ? nonPlaceholderOptions[0] : null;
|
||||
}
|
||||
|
||||
private static string? FindOpeningTag(string html, string tagName, 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 tagStart = html.LastIndexOf($"<{tagName}", idIndex, StringComparison.OrdinalIgnoreCase);
|
||||
int precedingTagEnd = html.LastIndexOf('>', idIndex);
|
||||
if (tagStart > precedingTagEnd)
|
||||
{
|
||||
int tagEnd = html.IndexOf('>', idIndex);
|
||||
if (tagEnd >= 0)
|
||||
{
|
||||
return html[tagStart..(tagEnd + 1)];
|
||||
}
|
||||
}
|
||||
|
||||
searchFrom = idIndex + marker.Length;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractAttributeValue(string openingTag, string attributeName)
|
||||
{
|
||||
foreach (char quote in new[] { '"', '\'' })
|
||||
{
|
||||
string marker = $"{attributeName}={quote}";
|
||||
int valueStart = openingTag.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (valueStart < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
valueStart += marker.Length;
|
||||
int valueEnd = openingTag.IndexOf(quote, valueStart);
|
||||
if (valueEnd >= 0)
|
||||
{
|
||||
return WebUtility.HtmlDecode(openingTag[valueStart..valueEnd]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasAttribute(string openingTag, string attributeName)
|
||||
{
|
||||
int searchFrom = 0;
|
||||
while (searchFrom < openingTag.Length)
|
||||
{
|
||||
int index = openingTag.IndexOf(attributeName, searchFrom, StringComparison.OrdinalIgnoreCase);
|
||||
if (index < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool validStart = index == 0 ||
|
||||
char.IsWhiteSpace(openingTag[index - 1]) ||
|
||||
openingTag[index - 1] == '<';
|
||||
int after = index + attributeName.Length;
|
||||
bool validEnd = after >= openingTag.Length ||
|
||||
char.IsWhiteSpace(openingTag[after]) ||
|
||||
openingTag[after] is '=' or '>' or '/';
|
||||
if (validStart && validEnd)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
searchFrom = after;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string? NormalizeText(string htmlFragment)
|
||||
{
|
||||
StringBuilder withoutTags = new(htmlFragment.Length);
|
||||
@@ -285,6 +492,28 @@ public static class SguProfileParser
|
||||
return Limit(string.Join("\r\n", lines), 1024);
|
||||
}
|
||||
|
||||
private static string? BuildAdministrativeStreetLine(
|
||||
string? street,
|
||||
string? exteriorNumber,
|
||||
string? interiorNumber)
|
||||
{
|
||||
string? line = JoinNonEmpty(" ", street, exteriorNumber);
|
||||
if (line is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return interiorNumber is null
|
||||
? line
|
||||
: $"{line}, Int. {interiorNumber}";
|
||||
}
|
||||
|
||||
private static string? NormalizeAddressUnit(string? value)
|
||||
{
|
||||
string? candidate = Limit(value, 32);
|
||||
return candidate is null ? null : SpanishTextNormalizer.ToTitleCase(candidate);
|
||||
}
|
||||
|
||||
private static void AddDistinct(List<string> values, string? candidate)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(candidate) &&
|
||||
|
||||
@@ -47,6 +47,8 @@ public sealed class BrokerOptions
|
||||
{
|
||||
Ntlm.AuthenticationPath,
|
||||
Ntlm.AdministrativeProfilePath,
|
||||
Ntlm.AdministrativePersonalProfilePath,
|
||||
Ntlm.AdministrativeLocationProfilePath,
|
||||
Ntlm.StudentProfilePath,
|
||||
Ntlm.MenuProfilePath
|
||||
})
|
||||
@@ -122,7 +124,7 @@ public sealed class NtlmOptions
|
||||
|
||||
public int TimeoutSeconds { get; init; } = 20;
|
||||
|
||||
public int ProfileTimeoutSeconds { get; init; } = 60;
|
||||
public int ProfileTimeoutSeconds { get; init; } = 90;
|
||||
|
||||
public int MaxRedirects { get; init; } = 5;
|
||||
|
||||
@@ -131,6 +133,12 @@ public sealed class NtlmOptions
|
||||
public string AdministrativeProfilePath { get; init; } =
|
||||
"/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx";
|
||||
|
||||
public string AdministrativePersonalProfilePath { get; init; } =
|
||||
"/psulsa/gadmon/capitalhumano/datos/personales.aspx";
|
||||
|
||||
public string AdministrativeLocationProfilePath { get; init; } =
|
||||
"/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx";
|
||||
|
||||
public string StudentProfilePath { get; init; } =
|
||||
"/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx";
|
||||
|
||||
|
||||
@@ -325,6 +325,21 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
response,
|
||||
identity,
|
||||
timeout.Token).ConfigureAwait(false);
|
||||
if (identity.Role == InstitutionalRole.Administrative &&
|
||||
string.Equals(
|
||||
profile?.EmployeeNumber,
|
||||
identity.NumericId,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
profile = await TryEnrichAdministrativeProfileAsync(
|
||||
client,
|
||||
profile!,
|
||||
allowedHosts,
|
||||
timeout.Token,
|
||||
cancellationToken,
|
||||
elapsed).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (profile is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
@@ -379,6 +394,104 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<InstitutionalProfile> TryEnrichAdministrativeProfileAsync(
|
||||
HttpClient client,
|
||||
InstitutionalProfile verifiedProfile,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken timeoutToken,
|
||||
CancellationToken requestCancellationToken,
|
||||
Stopwatch elapsed)
|
||||
{
|
||||
InstitutionalProfile profile = verifiedProfile;
|
||||
(string Path, Func<string, InstitutionalProfile?> Parser)[] pages =
|
||||
[
|
||||
(options.AdministrativePersonalProfilePath, SguProfileParser.ParseAdministrativePersonal),
|
||||
(options.AdministrativeLocationProfilePath, SguProfileParser.ParseAdministrativeLocation)
|
||||
];
|
||||
|
||||
foreach ((string path, Func<string, InstitutionalProfile?> parser) in pages)
|
||||
{
|
||||
try
|
||||
{
|
||||
string? html = await TryFetchAdditionalProfilePageAsync(
|
||||
client,
|
||||
GetProfileUri(path),
|
||||
allowedHosts,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
profile = profile.Overlay(html is null ? null : parser(html));
|
||||
}
|
||||
catch (OperationCanceledException) when (!requestCancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"SGU administrative profile enrichment reached its total timeout after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(
|
||||
exception,
|
||||
"An optional SGU administrative profile page failed after {ElapsedMilliseconds} ms; preserving fields already collected.",
|
||||
elapsed.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async Task<string?> TryFetchAdditionalProfilePageAsync(
|
||||
HttpClient client,
|
||||
Uri requestedUri,
|
||||
HashSet<string> allowedHosts,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri current = requestedUri;
|
||||
for (int hop = 0; hop <= options.MaxRedirects; hop++)
|
||||
{
|
||||
if (!IsAllowedHttpsUri(current, allowedHosts))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using HttpRequestMessage request = new(HttpMethod.Get, current);
|
||||
using HttpResponseMessage response = await client
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
int statusCode = (int)response.StatusCode;
|
||||
if (statusCode is >= 300 and < 400)
|
||||
{
|
||||
Uri? redirect = ResolveAllowedRedirect(current, response, allowedHosts);
|
||||
if (redirect is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await DrainResponseAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
current = redirect;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (statusCode is >= 200 and < 300)
|
||||
{
|
||||
return await ReadLimitedStringAsync(
|
||||
response.Content,
|
||||
options.MaxProfileBytes,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Optional SGU profile page {Path} returned HTTP {StatusCode}.",
|
||||
requestedUri.AbsolutePath,
|
||||
statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.LogWarning(
|
||||
"Optional SGU profile page {Path} exceeded the redirect limit.",
|
||||
requestedUri.AbsolutePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void AddCredential(
|
||||
Uri uri,
|
||||
CredentialCache credentialCache,
|
||||
@@ -472,6 +585,9 @@ public sealed class NtlmCredentialValidator : INtlmCredentialValidator
|
||||
return new Uri(endpoint, path);
|
||||
}
|
||||
|
||||
private Uri GetProfileUri(string path) =>
|
||||
new(new Uri(options.Endpoint, UriKind.Absolute), path);
|
||||
|
||||
private async Task<InstitutionalProfile?> TryReadProfileAsync(
|
||||
HttpResponseMessage response,
|
||||
UserIdentity identity,
|
||||
|
||||
@@ -30,10 +30,12 @@
|
||||
"Endpoint": "https://sgu.ulsa.edu.mx/",
|
||||
"Domain": "",
|
||||
"TimeoutSeconds": 20,
|
||||
"ProfileTimeoutSeconds": 60,
|
||||
"ProfileTimeoutSeconds": 90,
|
||||
"MaxRedirects": 5,
|
||||
"AuthenticationPath": "/psulsa/",
|
||||
"AdministrativeProfilePath": "/psulsa/gadmon/capitalhumano/controlincidencias/incidencias.aspx",
|
||||
"AdministrativePersonalProfilePath": "/psulsa/gadmon/capitalhumano/datos/personales.aspx",
|
||||
"AdministrativeLocationProfilePath": "/psulsa/gadmon/capitalhumano/datos/ubicacion.aspx",
|
||||
"StudentProfilePath": "/psulsa/alumnos/consultainformacionalumnos/consultainformacion.aspx",
|
||||
"MenuProfilePath": "/psulsa/menu.aspx",
|
||||
"MaxProfileBytes": 524288,
|
||||
|
||||
Reference in New Issue
Block a user