CodeProject 文章抓取器,更新版
全新改进!无需登录 CP 即可关注您的 CodeProject 文章和声誉。
引言
本文介绍了我开发的一个程序集,用于帮助我密切关注我的文章、技巧和声誉积分。由于其主要发现机制涉及“抓取”CodeProject 网站的数据,因此其日常可用性取决于 CodeProject 神明的意愿,而他们的意愿是会变化的。
早在 2008 年,我就写过一篇文章,介绍了一个执行类似过程的程序——抓取 CodeProject 网站,以便获取文章的当前投票状态。随着 CodeProject 的不断发展,该版本代码已大大过时,并且抓取网站相关数据的代码差异巨大,因此我选择写一篇新文章,而不是对之前的文章进行大量修改。我还想给有兴趣的人一个比较两个版本代码的机会。
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 应用程序,那么这个应用程序应该看起来很熟悉。
窗口有三个主要区域:
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。
图标含义
在下面的图例中,“文章”应理解为文章、技巧或博客条目。所有组都经过单独分析,因此每个组都有自己的图标。
- 表示新文章。当您初次启动应用程序时,所有文章都将显示为新文章。
- 表示评分最高/最佳的文章。
- 表示评分最低/最差的文章。
- 表示投票最多的文章。
- 表示页面浏览量最多的文章。
- 表示最受欢迎的文章。
- 表示收藏数最多的文章。
- 表示下载量最多的文章(CP 尚未提供)。
- 表示相关字段的值已增加。
- 表示相关字段的值已减少。
示例应用程序的功能
- 抓取状态、下次抓取事件时间以及当前抓取进度都在窗口底部的状态栏中报告。
- 为了使报告的更改保持在我认为合理的范围内,为了在统计信息中报告为“已更改”,所需的视图数量为 10。您可以通过私有的
Article.ViewChangeThreshold
数据成员来增加或减少此值。
- 最初,程序按评分降序显示文章。您可以随时通过选择窗体顶部的组合框中的不同属性来更改此设置。
- 报告了更改的文章显示为蓝色背景,而未更改的文章显示为白色/灰色。为了让您并排查看差异,这是一个截图:

历史
- 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 - 原始提交。