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

Aspirateur 15 (améliorations diverses)

Dans ce billet nous allons finaliser note aspirateur à un détail prêt, l'authentification, que nous aborderons ultérieurement.
Evidement il restera des choses à améliorer, voir à corriger. L'idée de cette série de billets, en montrer suffisamment pour que le lecteur motivé, et probablement développeur, puisse continuer seul.
Et pour le lecteur non développeur ? Il aura un aspirateur fonctionnel mais qui ne correspond pas forcement à son besoin. Par exemple l'aspirateur actuel loupe des images et ne s'occupe absolument pas des vidéos. Avec ce que nous avons vu, il n'est pas très compliqué d'ajouter du code qui palie à ces problèmes. Evidemment pour un non développeur la chose reste tout de même ardue.

Pour reprendre un début de billet qui tend à devenir rituel, nous allons commencer par corriger des bogues. Ces bogues n'apparaissent qu'à cause d'une l'utilisation de plus en plus poussée et intensive de l'aspirateur, et ne surviennent bien évidement que dans de rares cas chez l'utilisateur compulsif. Hein ? Non ? Vous aussi vous avez des problèmes...

Premier problème. Lorsque nous récupérons réellement une page, au retour de l'appel à DownloadHelper.GetPage au début de PageProcessor.AspirePage donc, nous ne vérifions pas si nous devions effectivement récupérer la page. Un ajout s'impose :

#region Méthodes publiques
public void AspirePage(Uri absolutePath, string localPath, bool processPage, bool processResource)
{
...
    if (!processPage)
    {
        // C'est bien une page mais on n'aurait pas du la traiter.
        return;
    }
    if (string.IsNullOrWhiteSpace(strPage))
...

Second problème. Lorsque PageProcessor.AspirePage se rend compte que nous n'aurions pas du traiter l'élément, page ou ressource, elle rend la main à PageProcessor.AEvaluator sans lui indiquer qu'il n'y aurait pas dû y avoir de récupération. De ce fait, PageProcessor.AEvaluator réalise une substitution d'URL en indiquant une adresse locale qui pointe du coup sur un fichier vide.
Non, l'élément ne doit pas être récupéré et l'URL distante doit être laissée dans le HTML de la page.
Nous devons modifier la signature d'AspirePage et donc le code de ses appelants. Dans l'ordre d'appartinion, PageProcessor.cs et WebSiteModele.cs :

#region Méthodes publiques
/// <summary>
/// Récupération d'une page. En fait ce n'est pas forcement une page car c'est le
/// href d'un tag A. Il faut donc établir si c'est réellement une page
/// </summary>
/// <param name="absolutePath">Le chemin absolu distant de la page.</param>
/// <param name="localPath">Le chemin local ou sauver l'élément récupéré.</param>
/// <param name="processPage">Faut-il traiter les pages ?</param>
/// <param name="processResource">Faut-il traiter les ressources ?</param>
/// <param name="remote">Dit, en sortie, s'il faut utiliser l'élément distant.</param>
public void AspirePage(Uri absolutePath, string localPath, bool processPage, bool processResource, out bool remote)
{
    // A priori on doit utiliser la version locale de l'élément.
    remote = false;
 
    UrlFileMatcher urlFileMatcher = new UrlFileMatcher(_leSite, absolutePath);
 
    // Récupération du code de la page.
    // En fait ce n'est peut-être pas une page, on utilise donc une fonction
    // qui regarde réellement ce que c'est lors de la récupération (content-type du header http).
    bool isPage;
    string strPage;
    bool ok = DownloadHelper.GetPage(absolutePath, localPath, out isPage, out strPage);
    if (!ok)
    {
        // Problème => on sort.
        return;
    }
    if (!isPage)
    {
        // S'il ne fallait pas récupérer, on indique qu'il faut utiliser
        // la version distante et on vide le fichier local.
        // On ne le supprime pas pour ne pas recommencer la récupération.
        if (!processResource)
        {
            remote = true;
            using (FileStream fs = File.Create(localPath))
            {
                fs.Close();
            }
        }
        return;
    }
    if (!processPage)
    {
        // C'est bien une page mais on n'aurait pas du la traiter.
        remote = true;
        return;
    }
    if (string.IsNullOrWhiteSpace(strPage))
    {
        // C'est probablement le signe d'un problème mais on ne peut rien faire.
        return;
    }
 
    if (_leSite.AbandonAspiration)
        return;
 
    // On cherche les images.
...
}
#endregion Méthodes publiques

#region Méthodes privées
private string AEvaluator(Match m)
{
...
            bool remote;
            AspirePage(absUrl, path, proceesPage, processResource, out remote);
            if (remote)
            {
                // En fait nous n'aurion pas du récupérer.
                return m.ToString();
            }
        }
        catch (Exception Ex)
...
}
#endregion Méthodes privées
#region Méthodes publiques
public void Aspire(DomainValidator finaliseDomaine)
{
...
                // On lance la récursion.
                bool pipo;
                PageProcessor pageProc = new PageProcessor(this, urlFileMatcher, 0);
                pageProc.AspirePage(absUrl, path, proceesPage, processResource, out pipo);
            }
...
#endregion Méthodes publiques

Troisième problème. Nous générons des noms locaux non cohérents lorsque l'URL distante contient des caractères accentués. La solution, utiliser Uri.UnescapeDataString. Nous devons donc modifier UrlFileMatcher.cs et tous les XxxProcessor.cs (seul PageProcessor.cs est montré dans les codes ci-dessous) :

#region Méthodes publiques
public bool ProcessUrl(string url, bool isPage, out Uri absUrl, out Uri relUrl, out string path,
                       out bool processPage, out bool processResource)
{
...
        // 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)
    {
        // Il nous faudrait loguer l'erreur mais nous ne savons pas faire.
        return false;
    }
}
#endregion Méthodes publiques
#region Méthodes privées
private string AEvaluator(Match m)
{
...
    // On substitue l'URL locale à l'URL distante.
    StringBuilder strURL = new StringBuilder();
    int iDebut = href.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 + href.Length));
    return strURL.ToString();
}
 #endregion Méthodes privées

Remarque :la ligne de code strURL.Append(Uri.UnescapeDataString(relUrl.ToString())); introduit un bogue si l'URL substituée contient un caractère blanc et que le HTML d'origine n'encadrait pas l'URL par des ' ou des ". Ajouter des " en cas d'absence de ' ou " n'est pas infaisable car l'expression régulière sait si elle les a trouvé ou non. En effet il y a un groupe de capture dédié. Je vous laisse réaliser cette amélioration.

Quatrième problème. Dans ProcessUrl, nous avons des problèmes lorsque l'URL distante ne contient pas de nom de fichier. Soyons clair, nous aurons toujours un problème car nous essayons de deviner s'il s'agit d'une URL de fichier ou de répertoire. Seul le serveur web le sait. Même si nous lui demandions, nous n'aurions pas une réponse qui nous éviterait à coup sûr des problèmes (je vous laisse chercher pourquoi). Cela dit, si nous trouvons un tag A avec un href de la forme "www.xxxx.yy/toto", nous le prenons pour un fichier. Pour que nous considérions qu'il s'agit d'un répertoire, il faut que nous trouvions "www.xxxx.yy/toto/". En fait nous aurions moins de problèmes à considérer qu'il faut une extension pour que ce soit un fichier. Attention, il n'y a pas écrit "En fait nous n'aurions pas de problèmes". Nous aurons toujours des problèmes, seulement ils seront moins graves. Ils seront même probablement invisibles pour un utilisateur qui parcourrait la version locale du site aspiré. Il faudrait regarder les URL locales pour constater qu'elles ne correspondent plus à celles du site distant. Autre effet de bord, une possible duplication des pages locales, car à considérer une page distante comme un répertoire, nous créons un répertoire local dans lequel nous mettons la page distante. Par un autre chemin nous mettons peut-être cette même page locale au bon endroit. Elle existe alors deux fois sur notre version locale du site.
Cela nous amène à modifier ProcessUrl. Tant qu'à modifier ProcessUrl, nous en profitons pour regarder si un abandon est demandé. Cela accélérera notablement la réactivité d'un abandon. Cela conduit au code suivant :

#region Méthodes publiques
public bool ProcessUrl(string url, bool isPage, out Uri absUrl, out Uri relUrl, out string path,
                       out bool processPage, out bool processResource)
{
    // Initialisation.
    Uri filePath = null;
    absUrl = relUrl = null;
    path = null;
    processPage = processResource = false;
 
    if (_leSite.AbandonAspiration)
    {
        return false;
    }
 
    try
    {
        // Construction de l'adresse absolue.
        Uri absFullUrl;
        if (url.IndexOf("www.") == 0)
        {
            // Uri ne sait pas choisir http par défaut. On le fait donc explicitement.
            absFullUrl = new Uri("http://" + url);
        }
        else
        {
            absFullUrl = new Uri(_currentUrl, url);
        }
        Uri absModifiedUrl = absUrl = new Uri(absFullUrl.GetLeftPart(UriPartial.Path));
 
        // Est-ce bien une URL de fichier ?
        string fileUrl = absUrl.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);
            }
            absFullUrl = new Uri(_currentUrl, url);
            absModifiedUrl = new Uri(absFullUrl.GetLeftPart(UriPartial.Path));
            // Faut-il ajouter un '/' à la fin de l'URL distante ?
            if (fileUrl[fileUrl.Length - 1] != '/')
                absUrl = new Uri(fileUrl + "/");
        }
...
}
#region Méthodes publiques

Voilà pour les bogues. Passons aux améliorations fonctionnelles.

Nous allons commencer par nous attaquer au problème des pages aspirées lorsque nous sommes au plus profond de la récursion. Une partie des conséquences du problème a été présenté dans le précédent billet. Il n'est pas nécessaire de détailler toutes les conséquences, il est par contre assez nécessaire de remédier au problème. Pour mémoire, voici ce que nous devons faire.
Lorsqu'une page est aspirée au plus profond de la récursion, nous devons noter cette particularité.
Si plus tard lors du parcours du site nous arrivons à nouveau sur cette page, nous devons éventuellement la retraiter. Quand et pourquoi ?
Nous devons la retraiter si nous ne sommes plus au fond de la récursion. Pourquoi ? Parce que nous aurons alors la possibilité d'aspirer les éléments qu'elle référence, ce qui n'est pas possible lorsqu'elle est au fond de la récursion. Ce second traitement peut nous permettre de trouver de nouveaux éléments, mais surtout, il lèvera tous les doutes sur les éléments pointés dans des tags A.
Pour cela nous allons créer une nouvelle méthode, PageNeeded, dans UrlFileMatcher. PageNeeded sera utilisée à la place de FileNeeded lors du traitement des tags A. Son code est le suivant :

#endregion Méthodes publiques
/// <summary>
/// Evalue s'il faut récupérer la page.
/// </summary>
/// <param name="path">Le chemin complet de la page local.</param>
/// <param name="profondeur">La profondeur de la récursion de la page trouvée (la page initiale constitue le niveau 0).</param>
/// <returns></returns>
public bool PageNeeded(string path, int profondeur)
{
    // Est-on est au plus profond ?
    if (_leSite.MaxDepth == profondeur)
    {
        // Oui, on ne récupère pas.
        return false;
    }
 
    bool bret = false;
    // La page n'existe pas, il faut la récupérer.
    if (!File.Exists(path))
    {
        bret = true;
    }
    // La page ne provient pas de l'aspiration courante, il faut la récupérer.
    else if (_leSite.AspireDebut.CompareTo(File.GetLastWriteTime(path)) > 0)
    {
        bret = true;
    }
    // La page vient bien de cette aspiration mais nous étions au fond
    // de la récursion. Les pages référencées n'ont donc pas étés traitées
    // correctement. Il faut recommencer le traitement si on a de la marge.
    //else if ((_leSite.MaxDepth > (profondeur + 1)) &&
    //         _leSite.PagesAuFond.Contains(path))
    else if (_leSite.PagesAuFond.Contains(path))
    {
        if (_leSite.MaxDepth > (profondeur + 1))
        {
            _leSite.PagesAuFond.Remove(path);
            bret = true;
        }
    }
 
    // Si on doit récupérer la page alors que l'on est au plus bas
    // on indique qu'il serait bon de la retraiter plus tard si
    // l'occasion se présente.
    if (bret && (profondeur + 1) == _leSite.MaxDepth)
    {
        _leSite.PagesAuFond.Add(path);
    }
 
    return bret;
}
#endregion Méthodes publiques

Ce code utilise une nouvelle propriété de WebSiteModele, PagesAuFond, que nous devons mettre en place (seul le code ajouté ou modifié est présenté) :

public partial class WebSiteModele : ModeleBase
{
    #region Membres
    // Pour le métier.
    private SortedSet<string> _pagesAuFond = new SortedSet<string>();
    #endregion Membres
 
    #region Propriétés
    #region PagesAuFond
    /// <summary>
    /// Liste des pages HTML traitées au fond de la récursion.
    /// </summary>
    public SortedSet<string> PagesAuFond
    {
        get { return _pagesAuFond; }
    }
    #endregion PagesAuFond
    #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 || (finaliseDomaine == null))
        {
            return;
        }
 
        try
        {
            // On peut donc y aller.
            FinaliseDomaine = finaliseDomaine;
            AspireDebut = DateTime.Now;
            _aspirationEnCours = true;
            _abandonAspiration = false;
            _pagesAuFond.Clear();
    ...
    }
    #endregion Méthodes publiques
}

Il nous faut maintenant gérer correctement la chose dans PageProcessor.cs :

#region Méthodes publiques
public void AspirePage(Uri absolutePath, string localPath, bool processPage, bool processResource, out bool remote)
{
    // A priori on doit utiliser la version locale de l'élément.
    remote = false;
 
    UrlFileMatcher urlFileMatcher = new UrlFileMatcher(_leSite, absolutePath);
 
    // Récupération du code de la page.
    // En fait ce n'est peut-être pas une page, on utilise donc une fonction
    // qui regarde réellement ce que c'est lors de la récupération (content-type du header http).
    bool isPage;
    string strPage;
    bool ok = DownloadHelper.GetPage(absolutePath, localPath, out isPage, out strPage);
    if (!ok)
    {
        // Problème => on sort.
        return;
    }
    if (!isPage)
    {
        // Si l'on a considéré qu'il s'agissait d'une page devant
        // éventuellement être retraitée, il ne faut plus le faire.
        // Remove ne levant pas d'exception pas utile d'appeler Contains.
        _leSite.PagesAuFond.Remove(localPath);
 
        // S'il ne fallait pas récupérer, on indique qu'il faut utiliser
        // la version distante et on vide le fichier local.
        // On ne le supprime pas pour ne pas recommencer la récupération.
        if (!processResource)
        {
            remote = true;
            using (FileStream fs = File.Create(localPath))
            {
                fs.Close();
            }
        }
        return;
    }
    if (!processPage)
    {
        // Si l'on a considéré qu'il s'agissait d'une page devant
        // éventuellement être retraitée, il ne faut plus le faire.
        // Remove ne levant pas d'exception pas utile d'appeler Contains.
        _leSite.PagesAuFond.Remove(localPath);
 
        // C'est bien une page mais on n'aurait pas du la traiter.
        remote = true;
        return;
    }
...
}
#endregion Méthodes publiques
 6
#region Méthodes privées
private string AEvaluator(Match m)
{
....
    // Récupération de la page si elle est trop vieille ou inexistante
    // et que la profondeur le permet.
    if (_urlFileMatcher.PageNeeded(path, _profondeur))
....
}
#endregion Méthodes privées

Si vous aspirez un site avec une profondeur suffisante... Tient nous avons oublié de vérifier que l'utilisateur ne mettait pas n'importe quoi. Allez, zou, un petit [Range(0, 20, ErrorMessage = "La profondeur doit être comprise entre 0 et 20.")] et l'utilisateur est maintenant contraint. Si vous trouvez que 20 est trop petit, ou trop grand, mettez ce qui vous convient.
Donc, si vous aspirez un site avec une profondeur suffisante, j'aime bien 5, vous devez obtenir un site relativement correct. Pas parfait, mais correct.
Cela dit il y a parfois des pages vides. Pourquoi avons-nous des pages vides ?

Il n'y a normalement que deux "bonnes" raisons de trouver des pages vides :

  • un abandon de l'aspiration. Toutes les pages ouvertes au moment de l'abandon effectif seront vides. Si par exemple l'abandon survient alors que l'on est au niveau 5 de profondeur, cela conduit à la présence de 6 fichiers vides (la page initiale est au niveau 0). Cela dit un abandon ne permet pas d'avoir une page initiale locale exploitable, en théorie le site local n'existe donc pas vraiment ;
  • les pages appartenant à un domaine connexe, dont nous ne souhaitons récupérer que les ressources, deviendront des pages vides. Cela dit, elles ne font qu'exister sur le disque, elles ne sont pas référencées dans le HTML local qui référence lui les vraies pages distantes. En ce qui concerne une navigation sur le site local, elles n'apparaîtront donc pas vides.

Il en résulte que si au cours d'une navigation dans la version locale du site, vous tombez sur une page vide, c'est qu'il y a eu un ou plusieurs problèmes lors de l'aspiration. Très probablement une exception a été levée lors de la récupération de la page. Le malheur c'est que nous n'avons aucune idée de ce qui s'est passé lors de la récupération des pages. Pour résoudre ce problème il faut que nous disposions de traces.

J'insiste sur un point souvent négligé. Dans la vraie vie, la mise en place de traces est très importante et doit se faire dès le début du projet et non en plein milieu, ou encore pire comme nous, pratiquement à la fin. Les traces, on en entend moins parler que des tests unitaires alors que c'est très probablement plus important, dans le sens où on en a besoin qu'il y ait ou pas des tests.

Il existe plein de moyen de mettre en place des traces. Personnellement j'ai choisi il y a bientôt 10 ans log4net. Je n'ai eu à ce jour aucune raison de regretter ce choix. Il y a peut-être mieux, mais log4net ne m'a jamais planté les perfs et est (de plus en plus) facile à mettre en œuvre.
Pour ce que nous allons faire dans notre aspirateur, log4net est nettement surdimensionné. Cela dit, si nous voulions "professionnaliser" notre code, et donc mettre des vraies traces dans toutes les couches, log4net serait complètement adapté. En effet la souplesse offerte par son mode de configuration permet d'avoir une configuration centralisée même lorsque l'on empile des couches et des couches de DLL.

Dans ce billet nous n'allons pas mettre en œuvre log4net dans les règles de l'art. Nous allons y aller un peu comme des sauvages. Si vous voulez le faire proprement, vous trouverez plein de billets sur le sujet. Lancez votre moteur de recherche préféré et de très nombreux résultats apparaîtrons. La seule chose qui peut poser problème, par rapport ce qui est facilement remonté comme résultat, c'est l'utilisation dans le cadre d'une DLL lorsque l'exécutable appelant est inconnu. Dans ce cas, il faut se pencher sur les entrepôts (repository pour vos recherches sur la toile). Ce n'est pas notre cas, nous allons faire simple.

Comme il est un peu tard pour mettre en place de vraies traces, nous allons uniquement en mettre là où nous avons besoin d'informations. Ce qui nous intéresse est donc le catch de la seconde version de la méthode GetPage de la classe DownloadHelper (celle ajoutée dans le précédent billet). Tant qu'à faire nous allons être un peu plus généreux et mettre des traces plus globalement dans la DLL Aspirateur.Model, mais pas ailleurs.
Cela dit, si demain nous devions ajouter des traces dans d'autres parties de notre code, nous souhaiterions tout de même que toutes les traces soient regroupées. Dit autrement, nous ne souhaitons pas que les traces de Aspirateur.Model possèdent leur propre entrepôt (Repository) et aient une configuration log4net potentiellement différente de celle du reste du programme.

Passons à la pratique.

Première chose à faire, récupérer les binaires sur le site log4net et décompresser le fichier "zip" récupéré.
Deuxième chose, prendre la bonne version. A priori la version pour .NET 4 correspond à notre cas. Attention, il faut prendre la version pour application cliente et non pour serveur web. Donc le répertoire n'est pas "net", mais "net-cp". "cp" signifie "Client Profile".
Troisième chose, référencer "log4net.dll" dans notre projet Aspirateur.Model. Pour éviter les problèmes, la dll a été au préalable placée dans un nouveau répertoire inconnu de Visual Studio, "Librairies", situé directement à la racine de notre solution (dans le répertoire "Aspirateur" si vous êtes un peu perdu).
Il ne reste plus qu'à coder.

Log4net n'étant mise en œuvre que dans une DLL nous n'avons guère le choix quant à la définition de la configuration, nous devons passer par un fichier spécifique. Sans grande originalité nous le nommons "log4net.config" et nous considérons qu'il sera toujours dans le même répertoire que l'exécutable utilisant la DLL.
Pourquoi pas dans le même répertoire que la DLL ?
Parce que, même si ce n'est pas le cas ici, comment feriez-vous, simplement, avec une DLL dans le GAC ?
Le code d'initialisation de log4net en lui-même est simple, la chose qui peut poser problème c'est de s'assurer que ce code sera exécuté avant toute autre utilisation de log4net.
La solution classique, lorsque l'on n'est pas tout en haut (i.e. au niveau de l'exécutable appelant les DLL), c'est de passer par un constructeur statique. Dans ce cas .NET nous garantit que le code sera exécuté avant toute utilisation de la classe.
La conséquence directe de cette méthode, c'est qu'il faut utiliser log4net à travers la dite classe. Nous en ajoutons donc une, que nous nommons Log, et que nous plaçons à la racine du projet Aspirateur.Model.
Son code, tout en finesse, est le suivant :

using System.IO;
using System.Reflection;
using log4net;
using log4net.Config;
 
namespace Aspirateur.Model
{
    class Log
    {
        public static readonly ILog log = LogManager.GetLogger("Aspirateur.Model");
 
        static Log()
        {
            XmlConfigurator.Configure(new FileInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) + @"\log4net.config"));
        }
    }
}

L'ajout de traces peut alors se limiter à une ligne de code aussi simple que :

catch (Exception ex)
{
    // On logue l'erreur.
    Log.log.Error(ex);
    return false;
}

Pour que ce code fonctionne, il nous faut définir le fichier de configuration adapté. C'est probablement là que log4net montre tout son intérêt. Nous pouvons en effet choisir plusieurs types de traces et plusieurs informations automatiquement sauvegardées.
Nous allons faire du classique, un fichier de traces. L'inconvénient avec les fichiers de traces c'est qu'ils peuvent rapidement de venir très gros. Log4net a bien évidement la solution classique à ce problème, les fichiers tournants. Compte tenu de la nature de notre application, nous allons définir un critère de taille pour nos fichiers de traces.
Nous allons également demander à log4net d'ajouter automatiquement des informations à ce que nous fournissons. En effet si vous regardez le code précédent, nous ne fournissons pour ainsi dire rien, uniquement l'exception, le minimum donc. Dans nos traces nous aimerions bien disposer d'informations supplémentaires. Disons au moins l'heure et l'identifiant du thread (qui devrait toujours être le même pour une aspiration donnée).
Cela nous donne le XML suivant :

<?xml version="1.0" encoding="utf-8" ?>
<log4net>
  <appender name="RollingFile" type="log4net.Appender.RollingFileAppender">
    <file value="Aspirateur.log" />
    <appendToFile value="true" />
    <rollingMode value="size" />
    <maximumFileSize value="10MB" />
    <maxSizeRollBackups value="2" />
    <layout type="log4net.Layout.PatternLayout">
      <conversionPattern value="%-5level %date [%thread] %logger %method - %message%newline" />
    </layout>
  </appender>
 
  <root>
    <level value="DEBUG" />
    <appender-ref ref="RollingFile" />
  </root>
</log4net>

A titre d'exemple, voici ce que j'obtiens uniquement avec Log.log.Error(ex); dans la bonne surcharge de GetPageet un site bien choisi pour générer des erreurs :

ERROR 2012-03-20 15:26:25,342 [10] Aspirateur.Model GetPage - System.Net.WebException: Le serveur distant a retourné une erreur : (403) Interdit.
   à System.Net.WebClient.DownloadDataInternal(Uri address, WebRequest& request)
   à System.Net.WebClient.DownloadData(Uri address)
   à Aspirateur.Model.Business.DownloadHelper.GetPage(Uri targetURL, String filePath, Boolean& page, String& html) dans D:\mifanfois\Aspirateur\Aspirateur.Model\Business\DownloadHelper.cs:ligne 75
ERROR 2012-03-20 15:26:25,475 [10] Aspirateur.Model GetPage - System.Net.WebException: Le serveur distant a retourné une erreur : (403) Interdit.
   à System.Net.WebClient.DownloadDataInternal(Uri address, WebRequest& request)
   à System.Net.WebClient.DownloadData(Uri address)
   à Aspirateur.Model.Business.DownloadHelper.GetPage(Uri targetURL, String filePath, Boolean& page, String& html) dans D:\mifanfois\Aspirateur\Aspirateur.Model\Business\DownloadHelper.cs:ligne 75
ERROR 2012-03-20 15:26:25,576 [10] Aspirateur.Model GetPage - System.Net.WebException: Le serveur distant a retourné une erreur : (403) Interdit.
   à System.Net.WebClient.DownloadDataInternal(Uri address, WebRequest& request)
   à System.Net.WebClient.DownloadData(Uri address)
   à Aspirateur.Model.Business.DownloadHelper.GetPage(Uri targetURL, String filePath, Boolean& page, String& html) dans D:\mifanfois\Aspirateur\Aspirateur.Model\Business\DownloadHelper.cs:ligne 75

En ce qui concerne notre problème spécifique, tout est là. Le moins que l'on puisse dire c'est que ce n'est pas la complexité de mise en œuvre qui peut servir d'excuse à ceux qui ne mettent pas de traces dans leurs programmes.
Dans notre cas nous traçons une exception et cette dernière comporte toutes les informations concernant l'endroit où nous nous trouvons. Ce ne sera pas toujours le cas, et d'une manière générale, il est plus courant d'avoir un texte indiquant où l'on est et ce que l'on trace, suivi si nécessaire de l'exception en second paramètre.
Toujours dans les choses courantes avec log4net, vérifier si le niveau de trace est demandé. Ici nous ne faisons rien de particulier avant d'appeler Log.log.Error, nous ne perdons donc pas inutilement du temps si les traces de niveau "ERROR" ne sont pas demandée. De plus la fonction appelée ne fera rien si le niveau n'est pas actif. Cela dit il n'est pas rare que le message que l'on souhaite tracer nécessite des calculs. Dans ce cas appeler IsXxxEnabled, où "Xxx" est remplacé par le niveau cible, peut éviter une perte de temps à priori minime mais qui multipliée par le nombre d'occurrences peut devenir sensible.

Vu que nous avons commencé, autant finir. Nous allons donc compléter tous les blocs catch de Aspirateur.Model avec des traces. Pour ne pas avoir besoin de lire l'intégralité du texte de l'exception, nous allons nous même fournir dans le message tracé l'identification de l'appelant. Nous modifions donc le pattern des messages en conséquence :

conversionPattern value="%-5level %date [%thread] %logger - %message%exception%newline" />

Le code typique d'un catch devient :

catch (Exception ex)
{
    Log.log.Error("DownloadHelper.GetPage(Uri, string, out bool, out string)", ex);
    return false;
}

Nous avons maintenant des traces qui vont nous permettre de savoir pourquoi les choses ne se passent pas comme prévu.
Si vous regardez ce qui se passe sur les aspirations qui se terminent avec des pages vides, vous constaterez que c'est le serveur qui refuse de nous fournir les pages. Généralement il a une bonne raison, la sécurité. Si vous aspirez un site sans authentification les choses doivent bien se passer. Notez que l'aspiration peut être longue. C'est normal.
Comme nous n'allons pas traiter l'authentification dans ce billet, ce sera l'objet du prochain billet, nous sommes arrivés à sa fin.

Quoi ?
Le billet est trop court !
Comment faire pour les vidéos ?
Pourquoi lorsque l'on passe la souris sur un élément d'une page, il ne se passe pas la même chose que sur le vrai site ?

Bon c'est bien parce que c'est vous.

Si le coté bourrin de notre code a pu poser problème, c'est parfois sa subtilité (si si, j'y tiens, sa subtilité) qui pose problème. Nous avons choisi la finesse pour analyser la page. Nous cherchons des tags HTML et si nous les trouvons, nous récupérons la ressource, ou la page, qui leur est associée.
Le problème de cette approche c'est que nous risquons de devoir apprendre à traiter beaucoup de tags avant d'être capable de récupérer tout ce que nous voulons récupérer. Cela a un impact sur les performances car, compte tenu de la manière dont nous avons procédé, chaque tag traité conduit à une analyse complète de la page. Dit autrement, si nous traitons 5 tags, nous faisons 5 passes sur la page récupérée, 10 tags => 10 passes.
Plus gênant, même une fois tous les tags traités nous n'aurons pas fini. En effet certaines ressources peuvent simplement être référencées lors d'un appel à un script.
Typiquement, un script est associé à l'attribut onMouseOver pour modifier l'image et montrer à l'utilisateur que l'IHM est tip top.
Nous abordons là un domaine où la finesse montre rapidement ces limites.
Pondre la belle expression régulière qui va se dépatouiller avec tous les scripts que les spychés tordues des développeurs peuvent pondre, relève de la quête du Graal. Ne disposant pas des 2 ou 3 milles ans nécessaires à un telles quête, ayant par ailleurs une certaines expériences de ce qu'un développeur peut pondre, je vous le dit sans détour, il convient de passer à la méthode gros bourrin qui tâche sévère.
Autre excuse, s'il en était nécessaire, les vidéos.
Même si HTML 5 devrait améliorer les choses, une vidéo dans une page c'est généralement un tag object (au hasard, Flash) et bonjour la dance des param. Là aussi la quête de la regexp qui tue promet d'être longue et fastidieuse.
La solution, le "grosbillisme" (chez moi c'est affectueux, j'ai connu personnellement le vrai Gros Bill, l'unique, le vrai , mais pas revu depuis plus de 20 ans. Cela vous donne une idée de mon âge).

L'approche, moins subtile, consiste à ne pas chercher à analyser le HTML mais simplement à chercher des noms de ressources. Par exemple nous cherchons des images. Une image ne correspond plus à un tag mais a un nom se finissant par une extension connue, ".gif" pour en citer une assez courante.
L'avantage de cette méthode c'est qu'elle peut permettre de tout trouver en une seule passe, sans rien comprendre de l'usage qui est fait de la chose.
L'inconvénient, c'est que cette méthode ne peut pas trouver une ressource dont nous n'avons pas prévu l'extension. D'un autre coté il faut bien anticiper quelque chose.

Nous ajoutons donc une nouvelle classe métier, BourrinProcessor, dont une première version du code est la suivante :

using System;
using System.Text.RegularExpressions;
 
namespace Aspirateur.Model.Business
{
    /// <summary>
    /// Classe de traitement directe des noms de fichiers.
    /// </summary>
    public class BourrinProcessor
    {
        #region Membres
        // L'expression régulière permettant le traitement des noms fichiers.
        private static Regex _regex = new Regex("(https?://|/)?(([^/\"'\\s=\\(\\)]+)/)*[^/\"'\\s=\\(\\)]+\\.(gif|jpg|png|bmp)",
                                                RegexOptions.IgnoreCase | RegexOptions.CultureInvariant |
                                                RegexOptions.Multiline | RegexOptions.Singleline | RegexOptions.Compiled);
 
        // La classe à utiliser pour le traitement de l'URL.
        private UrlFileMatcher _urlFileMatcher;
        #endregion
 
        #region Constructeurs
        public BourrinProcessor(UrlFileMatcher urlFileMatcher)
        {
            _urlFileMatcher = urlFileMatcher;
        }
        #endregion
 
        #region Méthodes publiques
        public string ProcessPage(string pageContent)
        {
            return _regex.Replace(pageContent, new MatchEvaluator(FileNameEvaluator));
        }
        #endregion
 
        #region Méthodes privées
        /// <summary>
        /// Fonction appelée lorsqu'un nom de fichier est trouvé.
        /// </summary>
        /// <param name="m">Le chemin du fichier trouvé.</param>
        /// <returns>Le chemin du fichier locale.</returns>
        private string FileNameEvaluator(Match m)
        {
            // Génération des noms nécessaires au traitement.
            Uri absUrl, relUrl;
            string path;
            bool pipo;
            if (!_urlFileMatcher.ProcessUrl(m.Value, false, out absUrl, out relUrl, out path, out pipo, out pipo))
            {
                // On ne sait pas traiter.
                return m.ToString();
            }
 
            // Récupération de la ressource si elle n'est pas déjà présente en locale.
            if (_urlFileMatcher.FileNeeded(path, false))
            {
                if (!DownloadHelper.GetFile(absUrl, path))
                {
                    // Rien en locale, on garde le tag d'origine.
                    return m.ToString();
                }
            }
 
            // On retourne le chemin locale.
            return Uri.UnescapeDataString(relUrl.ToString());
        }
        #endregion
    }
}

Maintenant il faut l'appeller. Je ne pense pas que cela nécessite une explication particulière. Personnellement j'ai donné le choix à l'utilisateur. Il peut choisir soit la méthode normale de récupération des images, soit la méthode bourrine. Pour cela j'ai ajouté une nouvelle propriété au site, propriété qui est sauvée dans le fichier XML. Comme nous avons déjà fait ce genre de chose, je vous laisse le faire seul (c'est de toute façon dans le code fourni avec ce billet).
Pourquoi avoir dit "première version" ?
Parce qu'il va vous falloir ajouter les extensions de fichier qui vous intéressent. La version finale dépendra donc de ce qui vous intéresse. Le code téléchargeable en possède déjà une autre.

Nous voilà arrivé à la fin de ce billet. Si vous souhaitez récupérer le code à jour, c'est ici.

A bientôt.

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