Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
21 avril 2012

Aspirateur 18 (authentification, fin)

Dans ce billet nous allons finaliser l'authentification. Nous aurons alors un aspirateur opérationnel, suffisamment opérationnel pour que cette série de billets fasse une pose d'une durée inconnue.
Le gros de ce qu'il nous reste à faire concerne la couche métier, à savoir envoyer le formulaire d'authentification au serveur web et gérer les cookies. Il y a cependant un oubli qui traine au niveau de l'IHM. Le code issu du précédent billet ne permet pas de modifier la preuve d'authentification et/ou l'URL de déconnexion. Ces deux éléments n'étant pas encore utilisés par la couche métier, ce n'était pas très grave, cela risque de le devenir rapidement.

Nous modifions donc le template associé au WebSiteViewModelen lui ajoutant deux lignes, une pour la preuve d'authentification et l'autre pour l'URL de déconexion. Le XAML correspondant est :

<TextBlock Text="Preuve de cnx : " Grid.Row="7" Grid.RowSpan="3" Margin="0,8,0,0" />
<TextBox x:Name="textBoxAuthenticationProof" IsReadOnly="True"
         Text="{Binding Site.AuthenticationProof, ValidatesOnDataErrors=true}"
         Grid.ColumnSpan="1" Grid.Column="1" HorizontalAlignment="Stretch"
         Grid.Row="7" Grid.RowSpan="1" Margin="0,5,0,0" />
<TextBlock Text="URL de déconnexion : " Grid.Row="8" Grid.RowSpan="3" Margin="0,8,0,0" />
<TextBox x:Name="textBoxLogOffUrl" IsReadOnly="True"
         Text="{Binding Site.LogOffUrl, ValidatesOnDataErrors=true}"
         Grid.ColumnSpan="1" Grid.Column="1" HorizontalAlignment="Stretch"
         Grid.Row="8" Grid.RowSpan="1" Margin="0,5,0,0" />
...
<Setter TargetName="textBoxAuthenticationProof" Property="IsReadOnly" Value="False" />
<Setter TargetName="textBoxLogOffUrl" Property="IsReadOnly" Value="False" />

La vue étant plus haute, il convient d'augmenter la taille des fenêtres de visualisation pour éviter l'apparition de la barre de défilement (passez de 280 à 330 suffit).

Nous pouvons maintenant nous attaquer à l'authentification vis à vis du serveur web.
Comme nous l'avons vu dans le premier billet sur l'authentification, l'authentification formulaire repose sur l'utilisation des cookies. Si nous ne sommes pas en mesure de gérer les cookies, nous pourrons nous authentifier, mais nous ne pourrons pas être authentifiés.
Problème, la classe WebClient de .NET ne gère pas les cookies.
Du coup nous allons devoir étendre notre classe CustomWebClient pour qu'elle le fasse.
Nous allons améliorer le code de la fonction surchargée GetWebRequest pour que cette dernière gère les cookies. Heureusement pour nous, il n'y a pas grand-chose à faire, juste fournir un CookieContainer. Le reste se fera automatiquement. En particulier le CookieContainer sera automatiquement alimenté avec les cookies positionnés par le serveur. La seule chose que nous devrons faire, toujours réutiliser le même CookieContainer pour les échanges avec le serveur dans le cadre d'une aspiration.
Ce dernier point est problématique. En effet WebClient n'est utilisable que pour un échange avec le serveur. C'est donc également le cas de notre classe dérivée. Dans ces conditions, la seule solution interne à CustomWebClient serait de faire du CookieContainer un membre statique. Actuellement la chose ne poserait pas de problème, les communications avec le serveur web sont gérées par un unique thread de notre application (pour une aspiration donnée). Cela dit nous pourrions faire évoluer ce point dans le futur, que se passerait-il alors ? D'après la documentation de Microsoft, les membres non statiques de CookieContainer ne sont pas nécessairement "thread safe", ce qui pourrait poser problème.
Comme souvent, c'est un aspect fonctionnel qui va guider notre choix. Si le CookieContainer est un membre statique, comment fait-on lorsque nous ne voulons pas l'utiliser ?
Et bien nous aurions un problème. Il serait possible de l'enlever (le mettre à null en fait), mais cela l'enlèverait pour tous ces utilisateurs. Tant qu'il n'y en a qu'un, ce n'est pas gênant, si demain il y en avait deux ou plus...
Cette considération nous amène à reporter la mémorisation du CookieContainer hors de CustomWebClient, donc sur son appelant. L'implémentation de CustomWebClientdevient donc :

using System;
using System.Net;
using System.Net.Cache;
using System.Collections.Generic;
 
namespace Aspirateur.Model.Business
{
    /// <summary>
    /// Classe étendant les possibilités de WebClient.
    /// </summary>
    internal class CustomWebClient : WebClient
    {
        #region Membres
        static private List<string> userAgents;
        #endregion Membres
 
        #region Constructeurs
        public CustomWebClient() : this(null) { }
 
        public CustomWebClient(CookieContainer cookieContainer)
        {
            KeepAlive = false;
            CookieContainer = cookieContainer;
        }
 
        static CustomWebClient()
        {
            userAgents = new List<string>();
            userAgents.Add("Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; .NET CLR 2.0.50727)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 8.0; AOL 9.5; AOLBuild 4337.43; Windows NT 6.0; Trident/4.0; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.5.21022; .NET CLR 3.5.30729; .NET CLR 3.0.30618)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 7.0; AOL 9.5; AOLBuild 4337.34; Windows NT 6.0; WOW64; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; .NET CLR 3.5.30729; .NET CLR 3.0.30618)");
            userAgents.Add("Mozilla/5.0 (X11; U; Linux i686; pl-PL; rv:1.9.0.2) Gecko/20121223 Ubuntu/9.25 (jaunty) Firefox/3.8");
            userAgents.Add("Mozilla/5.0 (Windows; U; Windows NT 5.1; ja; rv:1.9.2a1pre) Gecko/20090402 Firefox/3.6a1pre (.NET CLR 3.5.30729)");
            userAgents.Add("Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.9.1b4) Gecko/20090423 Firefox/3.5b4 GTB5 (.NET CLR 3.5.30729)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 5.1; Avant Browser; .NET CLR 2.0.50727; MAXTHON 2.0)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.1; WOW64; Trident/4.0; SLCC2; Media Center PC 6.0; InfoPath.2; MS-RTC LM 8)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; WOW64; Trident/4.0; SLCC1; .NET CLR 2.0.50727; Media Center PC 5.0; InfoPath.2; .NET CLR 3.5.21022; .NET CLR 3.5.30729; .NET CLR 3.0.30618)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 7.0b; Windows NT 6.0)");
            userAgents.Add("Mozilla/4.0 (compatible; MSIE 7.0b; Windows NT 5.1; Media Center PC 3.0; .NET CLR 1.0.3705; .NET CLR 1.1.4322; .NET CLR 2.0.50727; InfoPath.1)");
            userAgents.Add("Opera/9.70 (Linux i686 ; U; zh-cn) Presto/2.2.0");
            userAgents.Add("Opera 9.7 (Windows NT 5.2; U; en)");
            userAgents.Add("Mozilla/5.0 (Windows; U; Windows NT 6.0; en-US; rv:1.8.1.8pre) Gecko/20070928 Firefox/2.0.0.7 Navigator/9.0RC1");
            userAgents.Add("Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.8.1.7pre) Gecko/20070815 Firefox/2.0.0.6 Navigator/9.0b3");
            userAgents.Add("Mozilla/5.0 (Windows; U; Windows NT 5.1; en) AppleWebKit/526.9 (KHTML, like Gecko) Version/4.0dp1 Safari/526.8");
            userAgents.Add("Mozilla/5.0 (Windows; U; Windows NT 6.0; ru-RU) AppleWebKit/528.16 (KHTML, like Gecko) Version/4.0 Safari/528.16");
            userAgents.Add("Opera/9.64 (X11; Linux x86_64; U; en) Presto/2.1.1");
        }
        #endregion Constructeurs
 
        #region Propriétés
        /// <summary>
        /// Mettre à true si on veut simplement récupérer le Header lors de la requête à suivre.
        /// </summary>
        public bool HeaderOnly { get; set; }
 
        /// <summary>
        /// Permet de définir un user agent particulier pour de la requête à suivre.
        /// </summary>
        public string UserAgent { get; set; }
 
        /// <summary>
        /// Permet de définir un timer particulier pour de la requête à suivre.
        /// </summary>
        public int? Timeout { get; set; }
 
        /// <summary>
        /// Permet de définir un timer particulier pour de la requête à suivre.
        /// </summary>
        public bool KeepAlive { get; set; }
 
        /// <summary>
        /// Doit impérativement être défini si on souhaite avoir une gestion des cookies
        /// lors de la requête à venir.
        /// Par défaut il n'y a PAS de gestion des cookies
        /// </summary>
        public CookieContainer CookieContainer { get; set; }
        #endregion Propriétés
 
        #region Méthodes surchargées
        /// <summary>
        /// Permet de contrôler finement la requête qui va partir vers le serveur.
        /// </summary>
        /// <param name="address"></param>
        /// <returns></returns>
        protected override WebRequest GetWebRequest(Uri address)
        {
            WebRequest req = base.GetWebRequest(address);
 
            // Lorsque seul le header est voulu.
            if (HeaderOnly && req.Method == "GET")
            {
                req.Method = "HEAD";
            }
 
            // Configuration fine de la requête.
            HttpWebRequest httpReq = req as HttpWebRequest;
            if (httpReq != null)
            {
                httpReq.CookieContainer = CookieContainer;
                if (UserAgent != null)
                {
                    httpReq.UserAgent = UserAgent;
                }
                else
                {
                    httpReq.UserAgent = userAgents[0];
                }
                if (Timeout.HasValue)
                {
                    httpReq.Timeout = Timeout.Value;
                }
                httpReq.KeepAlive = KeepAlive;
 
                // On autorise le contenu compressé.
                httpReq.AutomaticDecompression = DecompressionMethods.Deflate | DecompressionMethods.GZip;
 
                // Pas de cache.
                httpReq.CachePolicy = new HttpRequestCachePolicy(HttpRequestCacheLevel.NoCacheNoStore);
            }
            return req;
        }
        #endregion Méthodes surchargées
    }
}

Si vous avez regardez ce code, vous avez constatez qu'il gère également d'autres éléments. En particulier la compression des données échangées. Ce qui est intéressant, c'est que la chose est totalement transparente à l'extérieure, l'appelant de WebClient n'aura pas à décompresser les données.
Si vous ne souhaitez pas utiliser le "user agent" qui se trouve dans ce code, changez-le. Le code vous en fourni une petite liste et rien ne vous empêche d'en ajouter d'autres.

Si le CookieContainer n'est pas conservé dans CustomWebClient entre les appels, il doit l'être à l'extérieur. Dans notre cas, l'appelant est DownloadHelper.
Nous allons donc faire de DownloadHelper en une vraie classe et non plus une collection de méthode statique. Sa donnée membre importante sera un CookieContainer. Il suffira donc de toujours utiliser la même instance de DownloadHelper pour une aspiration donnée, pour implicitement toujours utiliser le même CookieContainer. Si le besoin de repartir propre se fait sentir, il suffit de créer une nouvelle instance de DownloadHelper et le tour est joué.
Commençons donc par modifier DownloadHelper en lui ajoutant le CookieContainer et en transformant toute les méthodes statiques en méthodes membres. Il ne reste plus qu'à modifier le pour un et le tour est joué. Dans le code à venir, vous noterez que les traces ont été modifiées pour être un poil plus utile :

using System;
using System.IO;
using System.Net;
using System.Text;
 
namespace Aspirateur.Model.Business
{
    /// <summary>
    /// Classe fournissant les méthodes nécessaires pour la récupération de données sur le web.
    /// </summary>
    public class DownloadHelper
    {
        #region Membres
        private CookieContainer _cookieContainer;
        #endregion Membres
 
        #region Constructeurs
        public DownloadHelper()
        {
            _cookieContainer = new CookieContainer();
        }
 
        public DownloadHelper(CookieContainer cookieContainer)
        {
            _cookieContainer = cookieContainer;
        }
        #endregion Constructeurs
 
        #region Méthodes publiques
        /// <summary>
        /// Téléchargement du fichier indiqué.
        /// </summary>
        /// <param name="targetURL">L'URL cible.</param>
        /// <param name="filePath">Le fichier destination.</param>
        /// <returns>false s'il y a eu un problème.</returns>
        public bool GetFile(Uri targetURL, string filePath)
        {
            using (CustomWebClient wc = new CustomWebClient(_cookieContainer))
            {
                try
                {
                    wc.DownloadFile(targetURL, filePath);
                    return true;
                }
                catch (Exception ex)
                {
                    if (Log.log.IsErrorEnabled)
                    {
                        Log.log.Error(string.Format("DownloadHelper.GetFile(targetURL:\"{0}\", filePath:\"{1}\")", targetURL, filePath), ex);
                    }
                    return false;
                }
            }
        }
 
        /// <summary>
        /// Récupération du code HTML d'une page web.
        /// </summary>
        /// <param name="targetURL">L'URL cible.</param>
        /// <returns>false s'il y a eu un problème.</returns>
        public string GetPage(Uri targetURL)
        {
            using (CustomWebClient wc = new CustomWebClient(_cookieContainer))
            {
                try
                {
                    wc.Encoding = System.Text.Encoding.UTF8;
                    return wc.DownloadString(targetURL);
                }
                catch (Exception ex)
                {
                    if (Log.log.IsErrorEnabled)
                    {
                        Log.log.Error(string.Format("DownloadHelper.GetPage(targetURL:\"{0}\")", targetURL), ex);
                    }
                    return string.Empty;
                }
            }
        }
 
        /// <summary>
        /// Récupération de ce qui est à priori une page.
        /// Si ce qui est récupéré est une page, le contenu est retourné dans html et page est mis à true.
        /// Si ce n'est pas une page, le contenu est directement placé dans un fichier et page est mis à false.
        /// </summary>
        /// <param name="targetURL">L'URL cible.</param>
        /// <param name="filePath">Le fichier destination.</param>
        /// <param page="html">true s'il s'agit vraiment d'une page.</param>
        /// <param name="html">Le HTML de la page si s'en est une.</param>
        /// <returns>false s'il y a eu un problème.</returns>
        public bool GetPage(Uri targetURL, string filePath, out bool page, out string html)
        {
            page = false;
            html = string.Empty;
            using (CustomWebClient wc = new CustomWebClient(_cookieContainer))
            {
                try
                {
                    byte[] body = wc.DownloadData(targetURL);
                    string type = wc.ResponseHeaders["content-type"];
                    if (type.Contains(@"text/html"))
                    {
                        // C'est une page web, on retourne le contenu.
                        page = true;
                        html = Encoding.UTF8.GetString(body);
                    }
                    else
                    {
                        // Ce n'est pas une page web, on sauve dans un fichier.
                        //using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
                        using (FileStream fs = File.Create(filePath))
                        {
                            using (BinaryWriter bw = new BinaryWriter(fs))
                            {
                                bw.Write(body);
                                bw.Close();
                            }
                        }
                    }
                    return true;
                }
                catch (Exception ex)
                {
                    if (Log.log.IsErrorEnabled)
                    {
                        Log.log.Error(string.Format("DownloadHelper.GetPage(Uri:\"{0}\", string:\"{1}\", out bool, out string)", targetURL, filePath), ex);
                    }
                    return false;
                }
            }
        }
 
        /// <summary>
        /// L'URL correspond-t-elle à une page web ?
        /// </summary>
        /// <param name="targetURL">L'URL cible.</param>
        /// <returns>true s'il s'agit d'une page web.</returns>
        public bool IsPage(Uri targetURL)
        {
            using (CustomWebClient wc = new CustomWebClient(_cookieContainer))
            {
                try
                {
                    // On précise que l'on veut uniquement l'en-tête.
                    wc.HeaderOnly = true;
                    byte[] body = wc.DownloadData(targetURL);
                    string type = wc.ResponseHeaders["content-type"];
                    return type.Contains(@"text/html");
                }
                catch (Exception ex)
                {
                    if (Log.log.IsErrorEnabled)
                    {
                        Log.log.Error(string.Format("DownloadHelper.IsPage(Uri:\"{0}\")", targetURL), ex);
                    }
                    return false;
                }
            }
        }
        #endregion Méthodes publiques
    }
}

Plus rien ne compile car il nous faut maintenant disposer d'une instance de DownloadHelper dans nos classes métiers. Non seulement il faut une instance, mais il faut la même instance pour une aspiration donnée.
La méthode la plus rationnelle de le faire consiste à leur fournir l'instance de DownloadHelper à utiliser au niveau du constructeur. A titre d'exemple voici ce que cela donne sur notre vénérable classe métier ImageProcessor:

using System;
using System.Text;
using System.Text.RegularExpressions;
 
namespace Aspirateur.Model.Business
{
    /// <summary>
    /// Classe de traitement des tags HTML img.
    /// </summary>
    public class ImageProcessor
    {
        #region Membres
        // L'expression régulière permettant le traitement des tags img.
        private static Regex _regex = new Regex("<\\s*img\\b(?>\\s+(?:src\\s*=\\s*(?([\"'])([\"'])(?<src>.*?(?=\\1))\\1|(?<src>[^>\\s]*))())|[^\\s>]+|\\s+?)*\\2>",
                                                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant |
                                                RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
 
        // L'instance à utiliser pour le traitement des URL.
        private UrlFileMatcher _urlFileMatcher;
        // L'instance à utiliser pour récupérer les fichiers.
        private DownloadHelper _downloadHelper;
        #endregion Membres
 
        #region Constructeurs
        public ImageProcessor(UrlFileMatcher urlFileMatcher, DownloadHelper downloadHelper)
        {
            _urlFileMatcher = urlFileMatcher;
            _downloadHelper = downloadHelper;
        }
        #endregion Constructeurs
 
        #region Méthodes publiques
        public string ProcessPage(string pageContent)
        {
            return _regex.Replace(pageContent, new MatchEvaluator(ImageEvaluator));
        }
        #endregion Méthodes publiques
 
        #region Méthodes privées
        /// <summary>
        /// Fonction appelée lorsqu'un tag IMG est trouvé.
        /// </summary>
        /// <param name="m">Le tag img trouvé.</param>
        /// <returns>Le tag img avec l'URL locale.</returns>
        private string ImageEvaluator(Match m)
        {
            // L'expression régulière nous assure la présence de src.
            Group src = m.Groups["src"];
 
            // Génération des noms nécessaires au traitement.
            Uri absUrl, relUrl;
            string path;
            bool pipo;
            if (!_urlFileMatcher.ProcessUrl(src.Value, false, out absUrl, out relUrl, out path, out pipo, out pipo))
            {
                // On ne sait pas traiter.
                return m.ToString();
            }
 
            // Récupération de l'image si elle est trop vieille ou inexistante.
            if (_urlFileMatcher.FileNeeded(path, false))
            {
                if (!_downloadHelper.GetFile(absUrl, path))
                {
                    // Rien en locale, on garde le tag d'origine.
                    return m.ToString();
                }
            }
 
            // On substitue l'URL locale à l'URL distante.
            StringBuilder strURL = new StringBuilder();
            int iDebut = src.Index - m.Groups[0].Index;
            strURL.Append(m.Groups[0].Value.Substring(0, iDebut));
            // Il faut convertir les caractères échappés.
            strURL.Append(Uri.UnescapeDataString(relUrl.ToString()));
            strURL.Append(m.Groups[0].Value.Substring(iDebut + src.Length));
            return strURL.ToString();
        }
        #endregion Méthodes privées
    }
}

A vous de faire la même chose sur les autres classes métiers concernée, BourrinProcessor, LinkProcessor et ScriptProcessor.
Maintenant se sont les appels aux constructeurs qui posent problèmes.
Pour nous simplifier la vie, nous allons mémoriser l'instance de DownloadHelper à utiliser dans WebSiteModele. Cela implique qu'une instance donnée de WebSiteModele ne peut faire qu'une utilisation à la fois de son instance de DownloadHelper. Il ne peut donc pas y avoir, pour un site donné, une recherche des formulaires et simultanément une aspiration. Il faut coder cette contrainte, même s'il semble peu probable qu'un utilisateur tente de l'outrepasser.
Les modifications à apporter à WebSiteModelesont les suivantes :

#region Membres
// Pour le métier.
private bool _rechercheFormEnCours = false;
#endregion Membres
 
#region Propriétés
#region DownloadHelper
/// <summary>
/// L'instance de DownloadHelper à utiliser pour toute la session d'aspiration.
/// </summary>
public DownloadHelper DownloadHelper { get; private set; }
#endregion DownloadHelper
#endregion Propriétés
 
#region Méthodes publiques
public void Aspire(DomainValidator finaliseDomaine)
{
    // On vérifie que l'on n'est pas déjà au travail
    // et que l'appel est valide.
    if (_aspirationEnCours || _rechercheFormEnCours || (finaliseDomaine == null))
    {
        return;
    }
 
    try
    {
        // On peut donc y aller.
        FinaliseDomaine = finaliseDomaine;
        DownloadHelper = new DownloadHelper();
        ...
    }
    finally
    {
        // C'est fini.
        DownloadHelper = null;
        _aspirationEnCours = false;
        _abandonAspiration = false;
    }
}
 
public Dictionary<string, FormModele> GetFormList()
{
    // On vérifie que l'on n'est pas déjà au travail.
    if (_aspirationEnCours || _rechercheFormEnCours)
    {
        return null;
    }
 
    try
    {
        // On vérifie que la première page est bien une page.
        _rechercheFormEnCours = true;
        DownloadHelper = new DownloadHelper();
        if (!DownloadHelper.IsPage(BaseURL))
        {
            return null;
        }
 
        // On est maintenant certain qu'il s'agit d'une page.
        FormProcessor formProc = new FormProcessor(new UrlFileMatcher(this, BaseURL));
        return formProc.ProcessPage(DownloadHelper.GetPage(BaseURL));
    }
    finally
    {
        DownloadHelper = null;
        _rechercheFormEnCours = false;
    }
}
#endregion Méthodes publiques

Revenir à une solution qui compile est maintenant simple, il suffit de modifier tous les new XxxProcessor(urlFileMatcher); se trouvant dans PageProcessor par des new XxxProcessor(urlFileMatcher, _leSite.DownloadHelper); et de modifier l'appel à la méthode GetPage qui n'est plus statique.

A ce stade du billet nous avons écrit pas mal de code mais nous n'avons absolument pas amélioré notre fonctionnel, du moins en ce qui concerne l'authentification. Il est temps de s'y mettre. Nous ajoutons donc une méthode dédié à l'envoie d'un formulaire au serveur web dans la classe DownloadHelper. Le code de cette méthode, SendForm, est le suivant :

public bool SendForm(FormModele form, out string html)
{
    html = string.Empty;
    using (CustomWebClient wc = new CustomWebClient(_cookieContainer))
    {
        if (form.Method == HttpMethod.POST)
        {
            StringBuilder param = new StringBuilder();
            if (form.Encoding == HttpEncoding.URLENCODED)
            {
                wc.Headers["Content-type"] = "application/x-www-form-urlencoded";
                foreach (FormFieldModele ff in form.FieldList)
                {
                    param.Append(Uri.EscapeDataString(ff.Name));
                    param.Append("=");
                    param.Append(Uri.EscapeDataString(ff.Content));
                    param.Append("&");
                }
                param.Remove(param.Length - 1, 1);
            }
            else
            {
                // Calcul du séparateur.
                string boundary = "----------------------------" + DateTime.Now.Ticks.ToString("x");
                // Attention, dans le contenu, le séparateur doit être précédé de --
                // On enlève donc -- pour ne pas avoir à les ajouter après.
                wc.Headers["Content-type"] = "multipart/form-data; boundary=" + boundary.Substring(2);
 
                foreach (FormFieldModele ff in form.FieldList)
                {
                    param.Append(boundary);
                    param.Append("\r\nContent-Disposition: form-data; name=\"");
                    param.Append(Uri.EscapeDataString(ff.Name));
                    // On met deux sauts de ligne après le nom du champ
                    // car on ne met pas les éléments optionnels comme :
                    // content-type: text/plain;charset=windows-1250
                    // content-transfer-encoding: quoted-printable
                    param.Append("\"\r\n\r\n");
                    param.Append(Uri.EscapeDataString(ff.Content));
                    param.Append("\r\n");
-*+	
                }
                param.Append(boundary);
                // Ajouter -- à la fin du dernier séparateur.
                param.Append("--\r\n");
            }
 
            try
            {
                wc.Headers["Accept"] = "text/html, application/xhtml+xml, */*";
                wc.Encoding = System.Text.Encoding.UTF8;
                html = wc.UploadString(form.Action, "POST", param.ToString());
                return true;
            }
            catch (Exception ex)
            {
                if (Log.log.IsErrorEnabled)
                {
                    Log.log.Error(string.Format("DownloadHelper.SendForm(FormModele:\"{0}\", out string) version POST", form), ex);
                }
                return false;
            }
        }
        else
        {
            // On verra plus tard.
            return false;
        }
    }
}

Cette fonction ne gère pas tous les cas d'envoi de formulaire. Elle ne traite que le "POST". Pourquoi ne gère telle pas le "GET" . Parce que nous n'en avons pas besoin et qu'il est peu probable que nous en aillons besoin un jour. Certes le mot de passe est envoyé en clair avec un "POST", mais il n'est pas visible sur l'URL comme cela serait le cas avec un "GET". Contrairement à ce qui a été indiqué dans le premier billet sur l'authentification, encodage "multipart/form-data" est présent dans ce code.

Maintenant que nous savons envoyer un formulaire, nous pouvons nous authentifier. Nous modifions AspirePage à cet effet :

#region Méthodes publiques
public void AspirePage(Uri absolutePath, string localPath, bool processPage, bool processResource, out bool remote)
{
    ...
 
    // Dans le cas où il faut s'authentifier il est peut-être temps de le faire.
    // On ne le fait pas plus tôt car un utilisateur ne commence pas par s'authentifier
    // mais par demander la page initiale. Il s'authentifie ensuite depuis la page initiale.
    if (_profondeur == 0 && _leSite.Authenticate)
    {
        string strPageV2;
        ok = _leSite.DownloadHelper.SendForm(_leSite.AuthenticationForm, out strPageV2);
        if (!ok)
        {
            // Problème => on sort.
            return;
        }
 
        // On cherche si l'authentification est réussie?
        if (!strPageV2.Contains(_leSite.AuthenticationProof))
        {
            // Problème => on sort.
            if (Log.log.IsWarnEnabled)
            {
                Log.log.Warn(string.Format("PageProcessor.AspirePage(absolutePath:\"{0}\", ...), AuthenticationProof non trouvé", absolutePath));
            }
            return;
        }
        strPage = strPageV2;
    }
 
    // On cherche les images.
    ...
}
#endregion Méthodes publiques

On peut noter que l'authentification a été placée après avoir récupéré la première page, mais au tout début de son traitement. De ce fait notre aspirateur ne se comporte pas comme un vrai utilisateur qui ne s'authentifierais pas avant que ne soit demandé par son butineur les css et autres scripts. Si vous souhaitez vous approcher du comportement d'un vrai utilisateur, à vous de jouer.

Notre Aspirateur sait maintenant s'authentifier. De plus, il valide que l'authentification est réussie. Génial, c'est fini !

Pas complètement.

Si notre aspirateur sait s'authentifier, il ne sait pas éviter de fermer la pseudo session qu'il vient d'ouvrir. Nous devons rapidement lui apprendre à le faire.
La chose n'est pas très compliquée. Lorsqu'un lien vers une page est trouvé, il faut simplement vérifier que ce lien ne correspond pas à la page de déconnexion. Il suffit donc d'ajouter un simple test à la méthode ProcessUrl de la classe UrlFileMatcher. Ce test est if (url == _leSite.LogOffUrl) return false;. Etant donné que le code de cette méthode comporte un bogue lorsque l'URL distante est dotée de paramètre, je vous propose un code complet de la méthode qui intègre la correction du bogue :

public bool ProcessUrl(string url, bool isPage, out Uri absUrl, out Uri relUrl, out string path,
                       out bool processPage, out bool processResource)
{
    // Initialisation.
    absUrl = relUrl = null;
    path = null;
    processPage = processResource = false;
 
    if (_leSite.AbandonAspiration)
    {
        return false;
    }
 
    // Est-ce l'URL de déconnexion ?
    if (url == _leSite.LogOffUrl)
    {
        return false;
    }
 
    try
    {
        // Construction de l'adresse absolue distante à utiliser pour récupérer
        // l'élément sur le serveur distant.
        if (url.IndexOf("www.") == 0)
        {
            // Uri ne sait pas choisir http par défaut. On le fait donc explicitement.
            absUrl = new Uri("http://" + url);
        }
        else
        {
            absUrl = new Uri(_currentUrl, url);
        }
        // On ne doit plus toucher à absUrl donc on conserve une copie locale
        // pour les futures manipulations.
        Uri absFullUrl = absUrl;
        // Extraction de la partie chemin du fichier.
        Uri absFilePath = new Uri(absFullUrl.GetLeftPart(UriPartial.Path));
 
        // Est-ce bien une URL de fichier ?
        string fileUrl = absFilePath.ToString();
        if (string.IsNullOrWhiteSpace(Path.GetExtension(fileUrl)))
        {
            // Non => on doit ajouter le nom complet par défaut.
            // Reste à trouver où le placer.
            int cut = url.LastIndexOf('?');
            if (cut == -1)
            {
                cut = url.LastIndexOf('#');
            }
            // On ajoute le nom par défaut.
            if (cut == -1)
            {
                url = Path.Combine(fileUrl, _leSite.DefaultFileName);
            }
            else
            {
                string urlTemp = Path.Combine(fileUrl, _leSite.DefaultFileName);
                url = urlTemp + url.Substring(cut);
            }
            // Version "locale" de l'URL absolue distante et de la partie chemin de fichier.
            absFullUrl = new Uri(_currentUrl, url);
            absFilePath = new Uri(absFullUrl.GetLeftPart(UriPartial.Path));
 
            // Faut-il ajouter un '/' à la fin de l'URL distante ?
            // Ce '/' sert au constructeur de UrlFileMatcher lors de la prochaine récusrsion.
            if (fileUrl[fileUrl.Length - 1] != '/')
            {
                absUrl = new Uri(fileUrl + "/" + absUrl.Query);
            }
        }
 
        // On vérifie que l'adresse absolue pointe bien vers le site principal.
        Uri filePath;
        if (_leSite.BaseURL.IsBaseOf(absFullUrl))
        {
            // On traite toujours.
            processPage = processResource = true;
 
            // Construction de l'URL relative.
            relUrl = _currentUrl.MakeRelativeUri(absFullUrl);
 
            // Construction du chemin complet local.
            filePath = new Uri(_leSite.BaseDirectory, _leSite.BaseVirtualDiretory.MakeRelativeUri(absFilePath));
        }
        else
        {
            // Le domaine est-il connu ?
            string authority = absFullUrl.GetLeftPart(UriPartial.Authority);
            if (!_leSite.DomainList.ContainsKey(authority))
            {
                // En multi-thread prévoir le cas où plus d'un thread
                // se retrouvent là pour la même chose.
 
                // Non, on demande à l'utilisateur ce que l'on doit faire.
                if (_leSite.FinaliseDomaine == null)
                {
                    // On ne pourra rien demander à l'utilisateur...
                    return false;
                }
                // Pour cela on doit créer le domaine et demander à l'utilisateur de le finaliser.
                _leSite.BeginEdit();
                DomainModele domain = new DomainModele(new Uri(authority), @"non\", false, false);
                // On ajoute le domaine dans la liste pour bloquer, prochainement, d'autres threads
                // qui pourraient avoir besoin de ce même domaine.
                _leSite.DomainList.Add(authority, domain);
                // On demande à l'utilisateur ce qu'il veut faire.
                var srv = _leSite.FinaliseDomaine(domain);
                // On sauve la nouvelle configuration.
                _leSite.EndEdit();
                bool ok = srv.MettreAJour(_leSite);
                srv.AppliquerLesChangements();
            }
 
            // Dans le cas où le domaine est en cours de configuration,
            // il faut attendre qu'elle se finisse.
 
            // Devons-nous traiter ?
            DomainModele infos = _leSite.DomainList[authority];
            if ((!isPage && !infos.Resources) ||
                (isPage && !infos.Pages && !infos.Resources))
            {
                // On ne doit pas traiter.
                return false;
            }
 
            // On dit ce qui devrait réellement être fait.
            processPage = infos.Pages;
            processResource = infos.Resources;
 
            // Construction de l'URL relative.
            // Retour à la racine du site aspiré depuis la page courante.
            Uri gotoRoot = _currentUrl.MakeRelativeUri(_leSite.BaseVirtualDiretory);
            // Chemin relatif sur le domaine annexe de l'élément courant.
            Uri gotoElement = infos.Authority.MakeRelativeUri(absFullUrl);
            // Uri ne sait pas concaténer lorsque la première partie est une URL relative.
            // Du coup il faut passer par les concaténations de chaînes de caractères.
            relUrl = new Uri(gotoRoot.ToString().UriCombine('/', infos.VirtualSubdirectory, gotoElement.ToString()), UriKind.Relative);
 
            // Construction du chemin complet local.
            filePath = new Uri(_leSite.BaseDirectory, infos.Subdirectory);
            filePath = new Uri(filePath, infos.Authority.MakeRelativeUri(absFilePath));
        }
 
        // On s'assure que le répertoire de destination existe.
        path = Uri.UnescapeDataString(filePath.LocalPath);
        Directory.CreateDirectory(Path.GetDirectoryName(path));
 
        // Tout va bien.
        return true;
    }
    catch (Exception ex)
    {
        if (Log.log.IsErrorEnabled)
        {
            Log.log.Error(string.Format("UrlFileMatcher.ProcessUrl(url:\"{0}\", isPage:\"{1}\", out absUrl, out relUrl, out path, out processPage, out processResource)", url, isPage), ex);
        }
        return false;
    }
}

Cette modification en implique une autre dans le constructeur de la classe UrlFileMatcher:

public UrlFileMatcher(WebSiteModele leSite, Uri currentUrl)
{
    _leSite = leSite;
    if (string.IsNullOrEmpty(currentUrl.Query))
    {
        _currentUrl = currentUrl;
    }
    else
    {
        _currentUrl = new Uri(currentUrl.GetLeftPart(UriPartial.Path));
    }
}

Pour finir se billet, nous allons vérifier que si une authentification est demandée, nous disposons bien d'un élément nous permettant de savoir que l'authentification est réussie. Nous ajoutons donc au début de la méthode Aspire de WebSiteModelele code suivant :

// On vérifie que les conditions sont réunies.
if (Authenticate && string.IsNullOrWhiteSpace(AuthenticationProof))
{
    // L'authentification ne pourra pas être validée.
    Log.log.Warn("WebSiteModele.Aspire(DomainValidator), AuthenticationProof vide!");
    return;
}

Voilà, notre aspirateur sait s'authentifier, enfin dans une grande majorité des cas, et il sait rester authentifié pendant qu'il aspire le site cible.
Si vous testez sur un site et que ça ne passe pas (celui de CanalBlog par exemple), c'est soit que vous utilisez des caractères qu'EscapeDataString gère mal (le ! par exemple), soit qu'il y a un problème plus grave.
Dans le premier cas, un petit tour sur la toile devrait vous permettre de modifier le code pour traiter correctement les caractères qui vous posent problème (%21 pour le !).
Dans le second cas, il vous faudra coder une réponse appropriée au problème, ce qui est au de-là de ce que ce billet vise. Typiquement des cookies peuvent être définis par du code script à l'intérieur de la page initiale. Le code script n'étant pas pris en compte par notre aspirateur, ça ne risque pas de fonctionner. Il vous faudra alors étudier de code script et produire un équivalent. Avec un peu de chance, les valeurs des dits cookies seront fixes en partant de la première page et il suffira de les ajouter dans le fichier de configuration de l'aspirateur.
Pour ceux qui se demandent comment on procède pour trouver ce qui ne va pas, c'est très simple. On compare ce qui se passe avec un "vrai" butineur et ce qui se passe avec l'aspirateur, au niveau du réseau ça va de soi. On utilise donc un sniffer réseau. Bon il n'est pas nécessaire de descendre très bas dans la pile des protocoles, personnellement je ne descends quasiment pas et j'utilise depuis des années un grand classique, à savoir Fiddler. Avec un outil de ce genre, il est simplissime de comparer ce qui se passe dans les deux cas. Fiddler possède plein de fonctionnalités très utiles. Je vous laisse consulter sa documentation. Sachez qu'il peut, par exemple, vous servir de bouchon et vous éviter certaines galères.

Même si l'authentification passe sur le site qui vous intéresse, plein de choses peuvent encore être ajoutées.
A minima, il serait utile, voir indispensable, de pouvoir définir une zone dans le site cible. La zone serait définie soit de manière positive, seules les URL appartenant aux répertoires définis sont aspirées, soit de manière négative, les URL appartenant aux répertoires définis ne doivent pas être aspirées.
D'autres tags HTML peuvent présenter un intérêt.
Aborder le monde merveilleux du son et des vidéos pourrait être un plus certain.
Regarder un peu le contenu des scripts, l'analyser donc, pourrait rendre des services.
Tout cela est vrai, mais vous en savez maintenant assez pour le faire vous-même. A vous de jouer...

Dans le code que vous trouverez ici, des modifications non présentées dans le texte ont été apportées.
La première concerne l'expression régulière de BourrinProcessor qui gère mieux un cas particulier dans les URL.
La seconde concerne l'analyse du "href" d'un tag "A". Avant de convertir le contenu en minuscule, il est plus sage de vérifier qu'il y a un contenu.
La troisième concerne la gestion de la profondeur de la récursion. Maintenant si la profondeur maximale est 0 seule la première page est aspirée. A 1, les pages qu'elle référence sont également aspirées. La suite est facile à deviner.
Plusieurs concernent une amélioration des informations remontées par les traces.

Amusez-vous bien en spécialisant cette base de travail et vous devrez pouvoir rapidement disposer d'une copie locale du site qui vous intéresse. A bientôt pour d'autres aventures.

Publicité
Publicité
Commentaires
Fanfois et ses papillons
Publicité
Archives
Publicité