65.9K
CodeProject 正在变化。 阅读更多。
Home

CodeProject 文章抓取器,更新版

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (51投票s)

2010年12月11日

CPOL

17分钟阅读

viewsIcon

131768

downloadIcon

1498

全新改进!无需登录 CP 即可关注您的 CodeProject 文章和声誉。

下载 CPAM3.zip - 807KB (最后更新于 2011/05/11)

引言

本文介绍了我开发的一个程序集,用于帮助我密切关注我的文章、技巧和声誉积分。由于其主要发现机制涉及“抓取”CodeProject 网站的数据,因此其日常可用性取决于 CodeProject 神明的意愿,而他们的意愿是会变化的。

早在 2008 年,我就写过一篇文章,介绍了一个执行类似过程的程序——抓取 CodeProject 网站,以便获取文章的当前投票状态。随着 CodeProject 的不断发展,该版本代码已大大过时,并且抓取网站相关数据的代码差异巨大,因此我选择写一篇新文章,而不是对之前的文章进行大量修改。我还想给有兴趣的人一个比较两个版本代码的机会。

Screenshot_01.jpg

CPAMLib - 总体架构

这个代码库允许您抓取文章、博客和技巧,还可以跟踪您的声誉分数(博客、技巧和声誉积分的支持是此版本代码的新增功能)。抓取代码本身是多线程的,并发布进度事件(如本文系列第二部分所述的示例应用程序中所示)。

本质上,这个库维护着文章集合和声誉对象集合,并跟踪这些列表中的对象变化。抓取完成后,文章和声誉将持久化到 XML 文件中。这样,您就可以在应用程序启动时(如果需要)跟踪更改。

为了方便 WPF 用户,所有集合都派生自 ObservableCollection。但除此之外,它没有直接支持 WPF,但这不应妨碍您编写使用此库的 WPF 应用程序。事实上,我开始这么做了,但对看到一些结果更感兴趣,所以就放弃了 WPF 项目。我将在第二部分详细介绍。

最后,我不会说这些东西有多么高明,但它确实有效,而且大多数时候,这就足够了。

Article 类

此类表示三项内容之一——文章、技巧/窍门或博客条目。它的任务是简单地包含这些项的属性,并确定这些属性值自上次抓取以来是否发生变化。

初始化

尽管并非真正必要,但我提供了四个构造函数重载。一个接受无参数(实际上不应使用,因此其访问级别为 private),一个接受 XElement 参数(用于从 XML 数据文件加载属性时),而其他三个则完全相同,只是最后一个参数允许您设置文章对象所属的组(文章、博客或技巧)。此参数可以指定为相应的 enum、表示 enum 序数的整数,或表示 enum 项名称的字符串。

//--------------------------------------------------------------------------------
private Article()

//--------------------------------------------------------------------------------
public Article(XElement value)

//--------------------------------------------------------------------------------
public Article(string title, string desc, string url, DateTime posted, DateTime updated, 
                int votes, int views, int bookmarks, decimal rating, decimal popularity, 
                int downloads, ItemGroup group)

//--------------------------------------------------------------------------------
public Article(string title, string desc, string url, DateTime posted, DateTime updated, 
                int votes, int views, int bookmarks, decimal rating, decimal popularity, 
                int downloads, int group)

//--------------------------------------------------------------------------------
public Article(string title, string desc, string url, DateTime posted, DateTime updated, 
                int votes, int views, int bookmarks, decimal rating, decimal popularity, 
                int downloads, string group)

由于所有重载都执行完全相同的操作,因此它们都调用 InitCommon 方法,该方法使用指定的参数设置属性。

//--------------------------------------------------------------------------------
private void InitObject(string title, string desc, string url, DateTime posted, DateTime updated, 
                        int votes, int views, int bookmarks, decimal rating, decimal popularity, 
                        int downloads, ItemGroup group)
{
    this.RecentChanges = new ChangesDictionary();
    this.Title         = title;
    this.Description   = desc;
    this.Url           = url;
    this.DatePosted    = posted;
    this.LastUpdated   = updated;
    this.Votes         = votes;
    this.Views         = views;
    this.Bookmarks     = bookmarks;
    this.Rating        = rating;
    this.Popularity    = popularity;
    this.Group         = group;
    this.Downloads     = downloads;
    this.TimeUpdated   = new DateTime(0);
}

跟踪属性值

所有可跟踪的属性(评分、浏览量、受欢迎度等)都包含其当前值以及之前的值(如果有)。这允许您在应用程序中显示值变化。由于这些值正在被跟踪,我们还可以确定给定组中的哪些文章具有最高的评分或受欢迎度,或者其他任何已跟踪的属性。文章抓取时,使用 ApplyChanges 方法更新值。

public void ApplyChanges(Article incoming, DateTime updateTime)
{
    // since the url is always unique, we can use it as an identifier
    if (this.Url.ToLower() == incoming.Url.ToLower())
    {
        // Update the changes dictionary
        RecentChanges.AddUpdate(DataItem.Bookmarks,   ChangedValue(this.Bookmarks,   incoming.Bookmarks));
        RecentChanges.AddUpdate(DataItem.Description, ChangedValue(this.Description, incoming.Description));
        RecentChanges.AddUpdate(DataItem.Downloads,   ChangedValue(this.Downloads,   incoming.Downloads));
        RecentChanges.AddUpdate(DataItem.LastUpdated, ChangedValue(this.LastUpdated, incoming.LastUpdated));
        RecentChanges.AddUpdate(DataItem.Popularity,  ChangedValue(this.Popularity,  incoming.Popularity));
        RecentChanges.AddUpdate(DataItem.Rating,      ChangedValue(this.Rating,      incoming.Rating));
        RecentChanges.AddUpdate(DataItem.Votes,       ChangedValue(this.Votes,       incoming.Votes));
        RecentChanges.AddUpdate(DataItem.Views,       ChangedValue(this.Views,       incoming.Views, this.ViewChangeThreshold));
        RecentChanges.AddUpdate(DataItem.Title,       ChangedValue(this.Title,       incoming.Title));

        // set the properties to the new values
        this.Downloads   = incoming.Downloads;
        this.Bookmarks   = incoming.Bookmarks;
        this.LastUpdated = incoming.LastUpdated;
        this.Popularity  = incoming.Popularity;
        this.Rating      = incoming.Rating;
        this.Votes       = incoming.Votes;
        this.Views       = incoming.Views;
        this.Title       = incoming.Title;
        this.Description = incoming.Description;

        // log it
        this.TimeUpdated = updateTime;
    }
}

RecentChanges 属性表示一个 Dictionary 集合,它允许我们维护任何我们想要跟踪的可跟踪属性。我使用了 Dictionary,因为在最简单的形式中,数据可以表示为键/值对(属性及其值)。

由于我希望使该库可供 WPF 应用程序使用,我使用了一个我在网上找到的名为 ObservableDictionary 的类。请参阅下面关于我使用但并非我编写的代码的部分。

前一个方法为每个跟踪的属性调用 ChangedValue 方法,以便确定值是否已更改以及更改的方向。此信息用于 UI,以确定在更新抓取后显示什么以及如何显示。

//--------------------------------------------------------------------------------
private ChangeType ChangedValue(int currentValue, int newValue, int threshold)
{
    ChangeType changeType = ChangeType.None;
    int changeAmount = newValue - currentValue;
    if (changeAmount >= threshold)
    {
        changeType = ChangeType.Up;
    }
    else if (changeAmount < 0 && Math.Abs(changeAmount) > threshold)
    {
        changeType = ChangeType.Down;
    }
    return changeType;
}

//--------------------------------------------------------------------------------
private ChangeType ChangedValue(int currentValue, int newValue)
{
    return DetermineChangeType(currentValue.CompareTo(newValue));
}

//--------------------------------------------------------------------------------
private ChangeType ChangedValue(DateTime currentValue, DateTime newValue)
{
    return DetermineChangeType(currentValue.CompareTo(newValue));
}

//--------------------------------------------------------------------------------
private ChangeType ChangedValue(decimal currentValue, decimal newValue)
{
    return DetermineChangeType(currentValue.CompareTo(newValue));
}

//--------------------------------------------------------------------------------
private ChangeType ChangedValue(string currentValue, string newValue)
{
    ChangeType changeType = ChangeType.None;
    if (newValue != currentValue)
    {
        changeType = ChangeType.Changed;
    }
    return changeType;
}

//--------------------------------------------------------------------------------
private ChangeType DetermineChangeType(int compareResult)
{
    ChangeType changeType = ChangeType.None;
    switch (compareResult)
    {
        case -1 : changeType = ChangeType.Up;   break;
        case 0  : changeType = ChangeType.None; break;
        case 1  : changeType = ChangeType.Down; break;
    }
    return changeType;
}

为了支持 UI,以下方法确定指定项是否具有新值。

//--------------------------------------------------------------------------------
public bool GetFieldChanged(DataItem dataItem)
{
    ChangeType changeType = ChangeType.None;
    try
    {
        if (RecentChanges.ContainsKey(dataItem))
        {
            changeType = RecentChanges[dataItem];
        }
        else
        {
            RecentChanges.AddUpdate(dataItem, ChangeType.None);
            changeType = ChangeType.None;
        }
    }
    catch
    {
        throw new Exception (string.Format("DataItem {0} is invalid.", dataItem.ToString()));
    }
    return (changeType != ChangeType.None);
}

正如您所见,文章对象仅包含 UI 视角下的数据。

ArticleManager 类

此类表示文章对象的列表。它负责将文章对象保存到本地磁盘文件并从该文件中加载它们。它还负责抓取 CodeProject 的信息。

初始化是一个直接的过程。首先是构造函数,它调用离散的初始化方法。

//--------------------------------------------------------------------------------
public ArticleManager()
{
    // clear out any existing items in the list
    Clear();

    InitReputationList();
    InitAverages();
    InitScraperURLs();

    this.SaveScrapeResults = true;
    this.ScrapePosted = true;

	// for testing
    //this.SaveScrapeResults = false;
}

初始化方法只是为对象做好工作准备。它确实很有趣,但我将方法注释保留在下面的代码块中,以便您了解内容。

//--------------------------------------------------------------------------------
// Sets up the list of URLs that we need to actually scrape data from 
// CodeProject.  Putting them in a dictionary mitigates the inevitable typos 
// that tend to creep into string variables.
private void InitScraperURLs()
{
	if (this.m_scraperURLs == null)
	{
		this.m_scraperURLs = new Dictionary();
		this.m_scraperURLs.Add(ScraperType.MyArticles, "https://codeproject.org.cn/script/Articles/MemberArticles.aspx?amid={0}");
		this.m_scraperURLs.Add(ScraperType.User,       "https://codeproject.org.cn/script/Membership/View.aspx?mid={0}");
	}
}

//--------------------------------------------------------------------------------
// Initializes the list (actually, it's an observable dictionary) over average 
private void InitAverages()
{
	if (Averages == null)
	{
		Averages = new AveragesDictionary();
		Averages.Add("ArticlesRating",     0M);
		Averages.Add("ArticlesPopularity", 0M);
		Averages.Add("TipsRating",         0M);
		Averages.Add("TipsPopularity",     0M);
		Averages.Add("BlogsRating",        0M);
		Averages.Add("BlogsPopularity",    0M);
		Averages.Add("OverallRating",      0M);
		Averages.Add("OverallPopularity",  0M);
	}
}

//--------------------------------------------------------------------------------
//  Initialize the list of reputation categories. 
private void InitReputationList()
{
	// because of the ReputationList.AddOrUpdate method, we don't have to do 
	// anything else regarding initialization.
	if (this.Reputations == null)
	{
		this.Reputations = new ReputationList();
	}
}

抓取 CodeProject 的实际操作在一个线程中运行,以便可以中断。为此,我设置了一个线程方法。我曾遇到 CodeProject 响应过慢导致无法检索信息的问题,这促使我实现了自动重试以及 HtmlAgilityPack 代码。

//--------------------------------------------------------------------------------
public void ScrapeWebPage(string userID)
{
    // set our maximum connection retries
    this.m_maxConnectTries = (this.AutoRefresh) ? 10 : 3;
    // set the current user ID
    this.UserID = userID;

    // if the thread isn't null
    if (this.ScraperThread != null)
    {
        // if the thread is currently running
        if (this.ScraperThread.ThreadState == System.Threading.ThreadState.Running)
        {
            // abort the thread
            try
            {
                this.ScraperThread.Abort();
            }
            catch (Exception)
            {
                // we don't care about the exceptions here
            }
        }
        // set it to null
        this.ScraperThread = null; 
    }
    // start fresh
    this.ScraperThread = new Thread(new ThreadStart(ScrapeArticles));
    this.ScraperThread.Start();
}

//--------------------------------------------------------------------------------
// The actual thread delegate
private void ScrapeArticles()
{
    bool alreadyHasData = (this.Count > 0);
    this.LastScrapeResult = ScrapeResult.Fail;
    if (!ValidUserID())
    {
        return;
    }
    this.m_validUser = true;

    // get reputation
    GetUserInfo(true);
    GetArticles();

    RaiseEventThreadComplete();
}

//--------------------------------------------------------------------------------
// Allows us to abort the thread from the UI if necessary.
public void Abort()
{
    if (this.ScraperThread != null && this.ScraperThread.ThreadState == System.Threading.ThreadState.Running)
    {
        this.ScraperThread.Abort();
    }
}

解析数据是一个漫长而复杂的过程。以下是其过程的概述:

  • 抓取用户的个人资料页面以确保 ID 有效。如果无效,则中止所有进一步的抓取。
  • 检索用户信息(声誉分数)。
  • 检索文章、博客和技巧。由于页面布局的性质,所有这些信息都是一次性抓取的,但信息是按每个组分别解析的。
  • 如果需要,将从每篇单独的文章页面抓取文章的原始发布日期。仅当文章首次出现在列表中时才执行此操作。值得注意的是,Chris 已表示愿意在“我的文章”页面上包含所有其他信息的帖子日期。我已修改代码以在实际发生时利用这一点,在此之前,上述方法将继续有效。
  • 计算文章组的平均值(供 UI 使用,如果需要)。
  • 计算文章的最高值(供 UI 使用,如果需要)。
  • 在处理过程中,会定期发布进度事件,以便 UI 可以更新自身以反映正在发生的情况。

由于解析抓取信息所需的代码量很大,我决定最好将您引向代码本身。

ReputationCategory 类

此类表示一个声誉类别,如作者、编辑等。此项负责确定其状态(铂金、黄金等),并提供适当的显示颜色(供 UI 使用)。它通过在字典集合中维护状态级别并操作该集合来实现。

//--------------------------------------------------------------------------------
// Set the current status for this category - this is done so that we can 
// establish the correct color displayed in the interface component. This is 
// called from the constructor and when the old points are updated.
private void SetCurrentStatus()
{
    if (this.StatusLevels == null)
    {
        BuildStatusLevels();
    }
    this.Status = (this.Points < StatusLevels[ReputationLevel.Platinum]) ? ReputationLevel.Gold   : ReputationLevel.Platinum;
    this.Status = (this.Points < StatusLevels[ReputationLevel.Gold])     ? ReputationLevel.Silver : this.Status;
    this.Status = (this.Points < StatusLevels[ReputationLevel.Silver])   ? ReputationLevel.Bronze : this.Status;
    this.Status = (this.Points < StatusLevels[ReputationLevel.Bronze])   ? ReputationLevel.None   : this.Status;
}

//--------------------------------------------------------------------------------
// Establishes the points levels that indicate what reputation color is 
// appropriate.
private void BuildStatusLevels()
{
    if (this.StatusLevels == null)
    {
        // There are two points spreads currently being used. Author and Authority 
        // use one point spread, and all of the other categories use another. We're 
        // going to create a dictionary that holds the point spread for this 
        // category, so we need to fdetermine which one we're going to use.

        // assume this is a category type 1 (almost all of the categories comform to this)
        int categoryType = 1;

        // if this category is on of the "exceptional" categories, set the type to 2
        switch (this.Category)
        {
            case ReputationCategoryType.Author:
            case ReputationCategoryType.Authority:
                categoryType = 2;
                break;
        }

        // now create the dictionary
        this.StatusLevels = new ReputationStatusLevels();
        this.StatusLevels.Add(ReputationLevel.None,     0);
        this.StatusLevels.Add(ReputationLevel.Bronze,   (categoryType == 1) ? 100  : 50);
        this.StatusLevels.Add(ReputationLevel.Silver,   (categoryType == 1) ? 500  : 1000);
        this.StatusLevels.Add(ReputationLevel.Gold,     (categoryType == 1) ? 1500 : 5000);
        this.StatusLevels.Add(ReputationLevel.Platinum, (categoryType == 1) ? 2500 : 10000);
    }
}

//--------------------------------------------------------------------------------
// Gets the appropriate color for the status item based on the current points 
// value.
public string GetStatusColorForBrowser()
{
    string color;
    switch (this.Status)
    {
        case ReputationLevel.Bronze   : color = "#F4A460"; break;
        case ReputationLevel.Silver   : color = "#D3D3D3"; break;
        case ReputationLevel.Gold     : color = "#FFD700"; break;
        case ReputationLevel.Platinum : color = "#ADD8E6"; break;
        default                       : color = "#FFFFFF"; break;
    }
    return color;
}

ReputationList 类

此类允许我们添加或更新每个状态项的当前值。实际上,此类相当无聊,包含的方法很少值得在此文章中进行详细审视,但为了完整起见,这里是代码。

以下是实际将声誉类别添加到集合中的重载的 AddOrUpdate 方法。

//--------------------------------------------------------------------------------
/// Add or update the specified item as scraped from the web page
public void AddOrUpdate(ReputationCategoryType categoryType, int points)
{
    ReputationCategory item = Find(categoryType);
    if (item == null)
    {
        item = new ReputationCategory(categoryType, points);
        this.Add(item);
    }
    else
    {
        item.Points = points;
    }
}

//--------------------------------------------------------------------------------
/// Add or update the item specified by the XElement object (as loaded from the xml data file public void AddOrUpdate(XElement element)
{
    ReputationCategoryType categoryType = Globals.StringToEnum(element.GetValue("Name", 
	                                                           ReputationCategoryType.Unknown.ToString()), 
                                                               ReputationCategoryType.Unknown);
    int points = Convert.ToInt32(element.GetValue("Points", "0"));
    AddOrUpdate(categoryType, points);
}

此方法允许我们查找(并返回)列表中的类别。

//--------------------------------------------------------------------------------
public ReputationCategory Find(ReputationCategoryType categoryType)
{
    foreach (ReputationCategory item in this)
    {
        if (item.Category == categoryType)
        {
            return item;
        }
    }
    return null;
}

此方法允许我们汇总所有类别的积分。

//--------------------------------------------------------------------------------
public void SetTotalPoints()
{
    int total = 0;
    foreach(ReputationCategory category in this)
    {
        total += category.Points;
    }
    this.TotalPoints = total;
}

ChangesDictionary 类

我在此类中添加了一个方法,允许我向集合中添加新对象或更新集合中现有对象。这是该方法:

//--------------------------------------------------------------------------------
public void AddUpdate(DataItem dataItem, ChangeType changeType)
{
    if (this.ContainsKey(dataItem))
    {
        this[dataItem] = changeType;
    }
    else
    {
        this.Add(dataItem, changeType);
    }
}

其他值得关注的点

我讨厌打字,尤其是尖括号。因此,我几乎总是创建一个派生自适当集合类型的类。这有两个目的:一是提供一个地方来扩展集合以支持集合中包含的特定对象类型。这样可以避免大量的类型转换(在 .Net 中效率很低)。很多时候,无需添加任何功能,但没关系——我认为当您引用像这样的内容时,代码更易于阅读:

ChangesDictionary m_changesDictionary;

而不是这样的:

Dictionary m_changesDictionary;

ExtensionMethods 类

我喜欢扩展方法——它们允许您扩展 .Net 框架附带的任何类。就我而言,我扩展了 XElement 和 ObservableCollection 类。首先,我想简化从 XElement 获取值的方法,避免在靠近程序员的代码中有太多丑陋的代码。由于仅仅在出现意外情况时处理异常有些适得其反,因此我创建了这个扩展方法来获取元素的值,并在子元素值不存在时返回指定的默认值。我还有其他代码(来自另一个项目),它重载了此方法以支持字符串对象以外的各种类型,但在该项目中我不需要它们,因此不包含在内。如果您需要它们,请告诉我。

//--------------------------------------------------------------------------------
public static string GetValue(this XElement root, string name, string defaultValue)
{
    return (string)root.Elements(name).FirstOrDefault() ?? defaultValue;
}

对于 ObservableCollection,我需要能够对其进行排序,但它没有排序功能,所以我必须即兴发挥。快速的网络搜索后,我在“SDN 论坛”上找到了以下代码。我试图再次找到该代码,但未能成功,因此您只能相信我并不是自己想出了这段代码,而是从别处找到的。有一个警告是,集合中的项必须继承自 IComparable。

//--------------------------------------------------------------------------------
public static void Sort(this ObservableCollection collection)  where T : IComparable
{
    List sorted = collection.OrderBy(x => x).ToList();
    for (int i = 0; i < sorted.Count(); i++)
    {
        collection.Move(collection.IndexOf(sorted[i]), i);
    }
}

//--------------------------------------------------------------------------------
public static void Sort(this ObservableCollection collection, GenericComparer comparer)  where T : IComparable
{
    List sorted = collection.ToList();
    sorted.Sort(comparer);
    for (int i = 0; i < sorted.Count(); i++)
    {
        collection.Move(collection.IndexOf(sorted[i]), i);
    }
}

其他非我原创的代码

我很懒。我不想写不必要的代码,所以我经常在网上搜索可能已经实现过的代码。如果它对我有用,我就使用它。这个项目也不例外,以下是我从别处找到的代码:

HtmlAgilityPack 库

我之前讨论了 HtmlAgilityPack 程序集和 ObservableDictionary 类。在 HtmlAgilityPack 的情况下,我只包含已编译的 DLL。它的文件日期是 2010 年 2 月,因此您可能想看看是否有更新。我提供的版本与我的代码一起工作,我个人不想确保拥有最新最好的。

截至 2010 年 12 月,您可以在此处找到最新代码:HtmlAgilityPack on CodePlex

GenericComparer 类

我真的不记得在哪里找到的了,但当我用 Google 搜索“GenericComparer”时,得到了 6500 多个结果。如果您想找到最接近的版本以满足好奇心,请随意。我之所以包含这段文字,只是为了让没有人可以指责我将非我原创的发明据为己有。

ObservableDictionary 类

为了尽可能兼容 WPF,我搜索并找到了 ObservableDictionary 类。我 *认为* 它来自 Dr. WPF 的博客。这是相关博客文章的 URL:Dr. WPF - Can I bind my ItemsControl to a dictionary?

我的项目包含一个包含此代码的文件夹,以及一个包含关于该类的观察和注释的文本文件。请注意,此类在 VS2010 中会生成警告,但我尚未处理(再次提醒您,我很懒,而且由于这些警告似乎没有产生任何不良后果,所以我选择忽略它们)。

关于 CPAMLib 的最终评论

此库(截至 2010 年 12 月 10 日)已更新,以适应用户个人资料页面、文章/技巧/博客列表页面以及单个文章页面本身的所有最新格式和内容更改。我个人没有博客条目,因此我承认在测试抓取这方面存在一些懒惰。根据理论,它*应该*可以工作,但我尚未在该领域进行广泛测试。

我还添加了对 CP 人员*可能*会实施的关于文章、技巧和博客原始发布日期的更改的支持。即使他们不这样做,代码也能正常工作(并且仍然可以抓取文章发布日期,尽管速度会慢得多)。

示例应用程序

为了保持简单,我决定使用 WinForms 应用程序。这让我可以在布局、控件和内容方面大量借鉴原始应用程序。毕竟,完全重新发明轮子没有意义,而且,要改进完美(笑)真的很难。换句话说,旧版本的应用程序本质上是一头需要粉饰一番的猪。

该程序通过 CPAMLib 程序集抓取 CodeProject 网站的数据,并在 WebBrowser 控件中显示它。它还跟踪更改,允许您按各种属性对显示的数据进行排序,并每小时自动抓取一次网站。我个人不让它运行足够长的时间来自动抓取,但其他人可能需要这个功能,所以它就在那里。

视觉呈现

如果您熟悉原始的 CPAM 应用程序,那么这个应用程序应该看起来很熟悉。

Screenshot_02.jpg

窗口有三个主要区域:

0) 设置面板 - 此面板允许用户选择排序选项、显示/不显示的内容以及控制数据抓取。

1) 平均值面板 - 此面板显示文章、技巧和日志的当前分数,以及这些项目组的平均受欢迎度。

2) 主面板 - 此面板显示文章、技巧和/或博客列表,以及每项的相应状态和统计信息。

默认情况下,应用程序配置为显示文章、博客和技巧,按各自的组排列,显示选定用户ID的所有这些项目,并按评分降序排序。它还最初配置为显示用户的声誉分数。

由于所有真正的繁琐工作都隐藏在 CPAM3Lib 程序集中,我们只需启动抓取过程,等待它完成,然后显示结果。

数据成员

需要以下数据成员。正如您所见,需要管理的内容不多。

#region Data Members
private BackgroundWorker m_refreshWorker         = new BackgroundWorker();
private bool             m_hasNavigateMsgHandler = false;
#endregion Data Members

#region Custom Events and Delegates
private enum          ScrapeEvents { Progress, Fail, Complete, Start};
private event         TimeToGoEventHandler TimeToGo = delegate {};
private delegate void DelegateUpdateForm(ScrapeEvents scrapeEvent, ScrapeEventArgs e);
private delegate void DelegateUpdateStatusStripResult();
private delegate void DelegateUpdateStatusStripProgress(ScrapeEventArgs e);
private delegate void DelegateUpdateStatusStripTimeToGo(TimeToGoArgs e);
#endregion Custom Events and Delegates

初始化

构造函数执行一些必要的任务,例如初始化文章管理器对象(声明为 globals 类的静态数据成员)、将历史数据读取到数据文件中、挂钩到文章管理器的事件泵以及初始化窗体上的控件。
public Form1()
{
    InitializeComponent();

    // set up our data foilder (where we store the last-scraped data)
    Globals.CreateAppDataFolder("CPAM3");
    Globals.Manager.AppDataPath = Globals.AppDataFolder;
    // Load the data (if we have any)
    Globals.Manager.LoadData();

    this.comboBoxSortCategory.SelectedIndex = this.comboBoxSortCategory.FindStringExact("Rating");
    InitListView();
    InitRefreshWorker();
    Globals.Manager.ScrapeComplete += new ScraperEventHandler(articleManager_ScrapeComplete);
    Globals.Manager.ScrapeProgress += new ScraperEventHandler(articleManager_ScrapeProgress);
    Globals.Manager.ScrapeFail     += new ScraperEventHandler(articleManager_ScrapeFail);
    this.TimeToGo                  += new TimeToGoEventHandler(Form1_TimeToGo);

    InitFormControls();
}

private void InitFormControls()
{
    this.textboxUserID.Text                 = CPAM3Browser.Settings.Default.UserID;
    this.checkBoxShowArticles.Checked       = CPAM3Browser.Settings.Default.ShowArticles;
    this.checkBoxShowTips.Checked           = CPAM3Browser.Settings.Default.ShowTips;
    this.checkBoxShowBlogs.Checked          = CPAM3Browser.Settings.Default.ShowBlogs;
    this.checkBoxShowInGroups.Checked       = CPAM3Browser.Settings.Default.ShowInGroups;
    this.checkNewInfo.Checked               = CPAM3Browser.Settings.Default.ShowChangesOnly;
    this.checkShowIcons.Checked             = CPAM3Browser.Settings.Default.ShowIcons;
    this.checkShowIconLegend.Checked        = CPAM3Browser.Settings.Default.ShowIconLegend;
    this.checkShowReputation.Checked        = CPAM3Browser.Settings.Default.ShowReputation;
    this.checkboxSortDescending.Checked     = CPAM3Browser.Settings.Default.SortDescending;
    this.checkAutoRefresh.Checked           = (string.IsNullOrEmpty(this.textboxUserID.Text)) 
	                                          ? false 
                                              : CPAM3Browser.Settings.Default.AutoRefresh;
    this.comboBoxSortCategory.SelectedIndex = CPAM3Browser.Settings.Default.SortColumn;
}

自动刷新

您可以选择让应用程序每 60 分钟自动刷新一次结果。如果启用此功能,则后台工作对象会持续运行,在每个小时的开头触发一次刷新。我已经为后台工作者启用了所有事件,但该应用程序目前只处理进度事件。

//--------------------------------------------------------------------------------
private void InitRefreshWorker()
{
    this.m_refreshWorker.WorkerReportsProgress      = true;
    this.m_refreshWorker.WorkerSupportsCancellation = true;
    this.m_refreshWorker.RunWorkerCompleted        += new RunWorkerCompletedEventHandler(refreshWorker_RunWorkerCompleted);
    this.m_refreshWorker.ProgressChanged           += new ProgressChangedEventHandler(refreshWorker_ProgressChanged);
    this.m_refreshWorker.DoWork                    += new DoWorkEventHandler(refreshWorker_DoWork);
}

//--------------------------------------------------------------------------------
// Fired when the user checks the auto-refresh checkbox. It sits and spins 
// waiting for the next scrape time (every hour).
void refreshWorker_DoWork(object sender, DoWorkEventArgs e)
{
    BackgroundWorker worker = sender as BackgroundWorker;
    //Globals.Manager.SupressWarnings = true;
    DateTime now      = DateTime.Now;
    DateTime nextTime = new DateTime(0);
    int      interval = 1000;
    int      updateMinutes = 60;

    do
    {
        if (!worker.CancellationPending && now >= nextTime)
        {
            DelegateUpdateForm method = new DelegateUpdateForm(UpdateFormControls);
            Invoke(method, ScrapeEvents.Start, new ScrapeEventArgs(""));
            Globals.Manager.ScrapeWebPage(this.textboxUserID.Text);

            // We do NOT support the rescan for tips posted dates here because I 
            // didn't feel like dealing with the invoke stuff.

            if (nextTime.Ticks == 0)
            {
                TimeSpan span = new TimeSpan(0, updateMinutes - now.Minute, 0);
                // for testing
                if (updateMinutes > 5)
                {
                    nextTime = now.AddMinutes((span.Minutes < 5) 
                               ? updateMinutes + span.Minutes 
                               : span.Minutes);
                }
                else
                {
                    nextTime = now.AddMinutes(span.Minutes);
                }
            }
            else
            {
                nextTime = now.AddMinutes(updateMinutes);
            }
        }
        if (!worker.CancellationPending)
        {
            RaiseEventTimeToGo(nextTime);
            Thread.Sleep(interval);
            now = DateTime.Now;
        }
    } while (!worker.CancellationPending);
}

//--------------------------------------------------------------------------------
void refreshWorker_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
}

//--------------------------------------------------------------------------------
void refreshWorker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
}

WebBrowser 控件

该应用程序的核心是 WebBrowser 控件。我原以为 HTML 是一种简单的方式来显示数据,而无需我费太多心思。但我错了。首先,我们必须初始化控件。我们使用 about:blank URL 让控件有地方浏览,然后更新控件。

//--------------------------------------------------------------------------------
private void InitListView()
{
    this.webBrowser1.Navigate("about:blank");
    HtmlDocument doc = this.webBrowser1.Document;
    doc.Write(string.Empty);
    if (Globals.Manager.Count > 0)
    {
        UpdateListView(true);
    }
}

//--------------------------------------------------------------------------------
/// Set the web browser anchor property
private void DisplayWebBrowser_Load(object sender, EventArgs e)
{
    // We have to do this because there's no way to set the anchor property 
    // using the designer
    this.Anchor = AnchorStyles.Bottom | 
    AnchorStyles.Left   | 
    AnchorStyles.Right  | 
    AnchorStyles.Top;
}

//--------------------------------------------------------------------------------
// Builds the html that is displayed in the web browser
public void UpdateListView(bool startingWithData)
{
    if (!startingWithData)
    {
        this.Text = string.Format("(last update - {0}", 
		                          Globals.Manager.UpdateTime.ToString("yyyy/MM/dd at hh:mm"));
    }

    // remove the message handler temporarily while we build and navigate to our 
    // web browser control
    if (m_hasNavigateMsgHandler)
    {
        this.webBrowser1.Navigating -= new WebBrowserNavigatingEventHandler(webBrowser1_Navigating);
    }

	// Determine what we need to do based on the current state of the 
	// applicable checkboxes
    ShowArticles    = this.checkBoxShowArticles.Checked;
    ShowBlogs       = this.checkBoxShowBlogs.Checked;
    ShowTips        = this.checkBoxShowTips.Checked;
    ShowChanges     = this.checkNewInfo.Checked;
    ShowIcons       = this.checkShowIcons.Checked;
    ShowIconLegends = this.checkShowIconLegend.Checked;
    ShowReputation  = this.checkShowReputation.Checked;

    // construct the html for our web browser control
    bool          foundGroup = false;
    string        html       = "";
    StringBuilder htmlAll    = new StringBuilder("");

    // first, check to see if we have anything to do
    if (Globals.Manager.Count <= 0)
    {
        htmlAll.Append("<html><body style='font-family:arial;'>");
        htmlAll.Append("No articles found. CodeProject might be temporarily unavailable.");
        htmlAll.Append("</body></html>");
    }
    else
    {
		// build the html we're going to display
        htmlAll.Append(BuildHtmlHeader());
        htmlAll.Append(BuildReputationHtml());

        // we have articles, blogs, and/or tips, so get to work
        // if the user wants to show articles
        if (ShowArticles)
        {
            // build the appropriate table
            html = BuildArticleHtml(ItemGroup.Articles, ref foundGroup);
            // and if the group was found, add it to the stringbuilder
            if (foundGroup)
            {
                htmlAll.Append(html);
            }
        }

        // if the user wants to show blogs
        if (this.ShowBlogs)
        {
            // build the appropriate table
            html = BuildArticleHtml(ItemGroup.Blogs, ref foundGroup);
            // and if the group was found, add it to the StringBuilder
            if (foundGroup)
            {
                htmlAll.Append(html);
            }
        }
        // if the user wants to show tips/tricks
        if (this.ShowTips)
        {
            // build the appropriate html
            html = BuildArticleHtml(ItemGroup.Tips, ref foundGroup);
            // and if the group was found, add it to the StringBuilder
            if (foundGroup)
            {
            htmlAll.Append(html);
            }
        }
        htmlAll.Append(BuildHtmlFooter());
    }

    // set our docuement text
    this.webBrowser1.DocumentText = htmlAll.ToString();

    // re-add the message handler so we can respond to link clicks on the 
    // article items that are displayed
    this.webBrowser1.Navigating += new WebBrowserNavigatingEventHandler(webBrowser1_Navigating);
}

为简便起见,我将不展示实际构建 HTML 的代码(您可以在上面的代码片段中看到它们的使用)。应用程序只是使用文章管理器中的数据来构建输出。但我想提一下,为了控制内存使用,我使用了 StringBuilder 对象来保存正在构建的 HTML。

图标含义

在下面的图例中,“文章”应理解为文章、技巧或博客条目。所有组都经过单独分析,因此每个组都有自己的图标。

new_32.png - 表示新文章。当您初次启动应用程序时,所有文章都将显示为新文章。

bestrating_32.png - 表示评分最高/最佳的文章。

lowrate_32.png - 表示评分最低/最差的文章。

votes_32.png - 表示投票最多的文章。

viewed_32.png - 表示页面浏览量最多的文章。

popular_32.png - 表示最受欢迎的文章。

bookmark_32.png - 表示收藏数最多的文章。

download_32.png - 表示下载量最多的文章(CP 尚未提供)。

up_32.png - 表示相关字段的值已增加。

down_32.png - 表示相关字段的值已减少。

示例应用程序的功能

  • 抓取状态、下次抓取事件时间以及当前抓取进度都在窗口底部的状态栏中报告。
  • 为了使报告的更改保持在我认为合理的范围内,为了在统计信息中报告为“已更改”,所需的视图数量为 10。您可以通过私有的 Article.ViewChangeThreshold 数据成员来增加或减少此值。
  • 最初,程序按评分降序显示文章。您可以随时通过选择窗体顶部的组合框中的不同属性来更改此设置。
  • 报告了更改的文章显示为蓝色背景,而未更改的文章显示为白色/灰色。为了让您并排查看差异,这是一个截图:
Screenshot_03.jpg

历史

  • 2011/11/13 - 另一个网站更改,a) 重命名了一些元素 ID,b) 暴露了一个涉及在解析数据之前清理数据的编码错误。
  • 2011/05/11 - 我修复了标题栏(显示了奇怪的信息),并修复了替换技巧被计入表单顶部显示的技巧/窍门平均分和受欢迎度平均值的问题。由于它们不获得投票,因此不应将其计入平均值。
  • 2011/05/05 - 似乎表单右上角统计框中显示的平均值显示了可疑的值。我修改了数据源的初始化方式,并将平均值恢复到正常状态。
  • 2011/05/04 - 添加了按下载次数排序的支持。我还为每个组表添加了一行,显示每个组(文章、博客和技巧)的总投票数、浏览量、收藏数和下载数。
  • 2011/03/27 - 添加了按下载次数的支持。它*应该*能顺利适应,但如果出现奇怪的情况,只需删除 C:\Program Data\CPAM3\*.xml 并重新运行程序。它将创建一个新文件,您就可以正常使用了。
  • 2011/03/22 - 我昨天进行的修复被一个简单的拼写错误(“Organiser”一词)所挫败(CP 拼写错误——应该是“Organizer”,但我必须符合他们的用法才能让我的代码正常工作)。因此,如果您已经运行过旧的新代码一次,则必须运行此版本两次才能修复显示问题。问题是,一个名为“Unknown”的新声誉积分类别显示在第一个表中,“Organizer”积分未更新,导致总积分值不正确。无论如何,这可以修复这些问题,但请记住,您必须运行两次才能看到完全修复。
  • 2011/03/22 - 用户个人资料页面稍作更改,导致此应用程序中断。我不得不完全更改 ArticleManager.ParseReputationScores 来进行必要的修改。如果您有任何问题,请在下面的论坛中告知我。
  • 2010/12/29 - 我发现替换的技巧/窍门*不会*累积浏览量(目前),因此我必须添加一个方法,将空字符串标准化为至少包含一个有效的数字值零(“0”)。此修复还解决了导致技巧/窍门发布日期始终显示为 1990/01/01 的问题。
  • 2010/12/13 - 解决了导致应用程序在网站更新后完全失败的问题。还修复了导致非美国用户出现问题的错误(谢谢 Petr!)。非常感谢 Chris Maunder 在格式方面的帮助。我真的很惊讶他*这么快*就解决了这个问题。
  • 2010/12/12 - 文章列表页面的格式已更改,我正在与 CP 管理员沟通解决问题。在此期间,本文的下载已被禁用,直到问题解决。
  • 2010/12/11 - 原始提交。
© . All rights reserved.