Code Project 论坛:新帖子监视器 






4.90/5 (43投票s)
此应用程序可监控 Code Project 论坛的新帖子。


引言
我编写此应用程序是因为我想在特定论坛出现新帖子时收到通知。特别是,我关注的是 General Indian Topics 论坛,因为 Chris 要求我对其进行管理,并将其发展成一个面向新手印度 CP 用户的入门论坛。我发现我常常错过新帖子,或者当有人回复别人(因此我没有收到电子邮件)时。显然,每隔 3-4 分钟刷新一次页面会变得有些乏味。这时我才想到,写一个小应用程序,能够抓取该论坛的 HTML 并比较两次抓取之间的论坛计数,会更简单。当我开始着手时,我想到可以为所有论坛都这样做,而最简单的方法就是抓取主论坛列表的 HTML。
该应用程序是使用 VS 2010/.NET 4.0 编写的,因此您需要 .NET 4.0 运行时。我没有使用任何 .NET 4.0 的特性,所以理论上如果您用目标框架重新构建项目,或者降级到 VS 2008,可以在 3.5 上运行它。由于这是一个非常简单的项目,这应该很容易做到。该项目使用了 CodePlex 的 HTML Agility Pack v1.4.0,所以您也需要它。我已将所需的 DLL 包含在文章下载中,应该足够了。这是 HTML Agility Pack 的 URL,包括源代码下载和文档。
使用应用程序
该应用程序的用户界面相当简单。运行时,频率滑块默认设置为 30 秒。这意味着应用程序每 30 秒抓取一次 HTML。除非您期望实时回复,否则您可能想将其降低到 180 秒左右。您可以设置的最大值为 300 秒(5 分钟)。我认为,如果超过 5 分钟,您可能就不需要这个应用程序了。任何 5 分钟的时间段内,CP 几乎肯定都会有新帖子(因为它有来自世界各地的活跃用户,因此不同的时区)。有一个按钮可以执行手动抓取。但这不会影响正在运行的计时器。如果您要起身去拿咖啡,您可以手动抓取一次,这样当您回来时,您看到的就是您不在期间发布的内容。我个人更常用于调试,而不是其他任何事情。无论如何,每当一个论坛有新帖子时,它就会显示出来(见上文的图 1)。如果您将其设置为 30 秒,您会经常发现没有新帖子(在任何论坛中)。是的,即使是 CP 也不至于那么受欢迎 :-)
请记住,论坛计数是基于增量值的。所以如果您将其设置为每 1 分钟运行一次,而您休息了 3 分钟,您只会看到最近 1 分钟内的新帖子。您在返回时,之前 2 分钟内发布的任何新帖子都会在屏幕上闪过并消失。有时您实际上想知道错过了什么。这就是历史窗口的作用——见上文的图 2。它会显示触发的最近 500 次更新。
请注意,我尝试使用绿色和橙色来设计 UI,这是我刻意且费力地完成的,以赋予应用程序 CP 风格的主题。如果您不喜欢,那么我只能说您的视力很差,而且完全没有视觉品味。真是可怜!*笑*
实现细节
和所有在 2010 年编写 Windows 应用程序的人一样,我也使用了 WPF/MVVM。事实上,如果有人告诉我如今不能编写非 WPF 应用程序,我会非常震惊。好吧,我只是开玩笑 :-)
有一个 CodeProjectForum 类,代表一个论坛(以及它的增量计数)。
class CodeProjectForum : INotifyPropertyChanged
{
    private string name;
    public string Name
    {
        get
        {
            return this.name;
        }
        set
        {
            if (this.name != value)
            {
                this.name = value;
                FirePropertyChanged("Name");
            }
        }
    }
    private string description;
    public string Description
    {
        get
        {
            return this.description;
        }
        set
        {
            if (this.description != value)
            {
                this.description = value;
                FirePropertyChanged("Description");
            }
        }
    }
    private int postCount = -1;
    public int PostCount
    {
        get
        {
            return this.postCount;
        }
        set
        {
            this.PreviousPostCount = this.postCount == -1 ? value : this.postCount;
            this.postCount = value;
            FirePropertyChanged("PostCount");
            FirePropertyChanged("Delta");
        }
    }
    public int Delta
    {
        get
        {
            return this.PostCount - this.PreviousPostCount;
        }
    }
    private int previousPostCount;
    public int PreviousPostCount
    {
        get
        {
            return this.previousPostCount;
        }
        private set
        {
            if (this.previousPostCount != value)
            {
                this.previousPostCount = value;
                FirePropertyChanged("PreviousPostCount");
            }
        }        
    }
    private DateTime lastChecked;
    public DateTime LastChecked
    {
        get
        {
            return this.lastChecked;
        }
        set
        {
            if (this.lastChecked != value)
            {
                this.lastChecked = value;
                FirePropertyChanged("LastChecked");
            }
        }
    }
    public event PropertyChangedEventHandler PropertyChanged;
    protected void FirePropertyChanged(string propertyName)
    {
        PropertyChangedEventHandler handler = PropertyChanged;
        if (handler != null)
        {
            handler(this, new PropertyChangedEventArgs(propertyName));
        }
    }
    public CodeProjectForum Clone()
    {
        return new CodeProjectForum()
        {
            Name = this.Name,
            Description = this.Description,
            PostCount = this.PostCount,
            PreviousPostCount  = this.PreviousPostCount,
            LastChecked = this.LastChecked
        };
    }
}
这是抓取 HTML、解析它并提取论坛详细信息的类。它使用后台工作线程来抓取和解析 HTML,并在完成后触发 FetchCompleted 事件(由 View Model 类在 UI 中处理)。HTML 解析并不复杂,并且通过使用 HtmlAgilityPack 进一步简化了。
class CodeProjectForumCountFetcher
{
    private List<CodeProjectForum> forums = new List<CodeProjectForum>();
    public event EventHandler<CodeProjectForumCountFetcherEventArgs> FetchCompleted;
    public void Fetch()
    {
        BackgroundWorker worker = new BackgroundWorker();
        worker.DoWork += Worker_DoWork;
        worker.RunWorkerCompleted += Worker_RunWorkerCompleted;
        worker.RunWorkerAsync();
    }
    private void Worker_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
    {
        this.FireFetchCompleted();
    }
    private void Worker_DoWork(object sender, DoWorkEventArgs e)
    {
        string html = GetHttpPage(
          "https://codeproject.org.cn/script/Forums/List.aspx", 10000);
        HtmlDocument document = new HtmlDocument();
        document.LoadHtml(html);
        foreach (HtmlNode trNode in 
          document.DocumentNode.SelectNodes("//table[@id='ForumListTable']/tr"))
        {
            var tdNodes = trNode.SelectNodes("td");
            if (tdNodes.Count != 4)
                continue;
            var forumName = tdNodes[0].InnerText.Trim();
            if (forumName.ToLowerInvariant().StartsWith("forum"))
                continue;
            int forumCount = -1;
            Int32.TryParse(tdNodes[3].InnerText.Replace(",", String.Empty).Trim(), 
                                                        out forumCount);
            var forumDescription = tdNodes[1].InnerText.Replace(" ", String.Empty).Trim();
            var forumMatches = forums.Where(forum => forum.Name == forumName);
            if (forumMatches.Count() == 0)
            {
                forums.Add(new CodeProjectForum() { Name = forumName, 
                  Description = forumDescription, 
                  PostCount = forumCount, LastChecked = DateTime.Now });
            }
            else
            {
                forumMatches.First().PostCount = forumCount;
                forumMatches.First().LastChecked = DateTime.Now;
            }
        }
    }
    private string GetHttpPage(string url, int timeout)
    {
        var request = WebRequest.Create(new Uri(url, UriKind.Absolute));
        request.Timeout = timeout;
        using (var response = request.GetResponse())
        {
            using (var responseStream = response.GetResponseStream())
            {
                using (var reader = new StreamReader(responseStream))
                {
                    return reader.ReadToEnd();
                }
            }
        }
    }
    public void FireFetchCompleted()
    {
        if (this.FetchCompleted != null)
        {
            this.FetchCompleted(this, new CodeProjectForumCountFetcherEventArgs() 
              { FetchedForums = Array.AsReadOnly(forums.Select(f => f.Clone()).ToArray()) });
        }
    }
}
主窗口和历史窗口都使用 ListBox 来显示论坛详细信息。它们只是样式差异很大。只有一个 View Model,对于历史窗口,我直接使用 ObservableCollection<> 作为它的 DataContext。这里有一些 View Model 类的片段。您可以从附件的文章下载中浏览完整的源代码。
internal class MainWindowViewModel : ViewModelBase
{
    // . . .
    
    public int FetchFrequency
    {
        get
        {
            return this.fetchFrequency;
        }
        set
        {
            if (fetchFrequency != value)
            {
                fetchFrequency = value;
                FirePropertyChanged("FetchFrequency");
            }
        }            
    }
    private string statusText;
    public string StatusText
    {
        get
        {
            return this.statusText;
        }
        set
        {
            if (statusText != value)
            {
                statusText = value;
                FirePropertyChanged("StatusText");
            }
        }
    }
    public MainWindowViewModel()
    {
        this.Forums = new ObservableCollection<CodeProjectForum>();
        this.PropertyChanged += MainWindowViewModel_PropertyChanged;
        fetcher.FetchCompleted += Fetcher_FetchCompleted;
        Fetch();
        timer.Tick += Timer_Tick;
        StartTimer();
    }
    private void StartTimer()
    {
        timer.Interval = new TimeSpan(0, 0, this.FetchFrequency);
        timer.Start();
        ResetStatusText();
    }
    private void ResetStatusText()
    {
        this.StatusText = String.Format("Timer running at {0} seconds.", 
                                        this.FetchFrequency);
    }
    void MainWindowViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
    {
        if (e.PropertyName == "FetchFrequency")
        {
            timer.Stop();
            StartTimer();
        }
    }
    private void Fetch()
    {
        this.StatusText = String.Format("Executing a fetch!");
        fetcher.Fetch();
    }
下面的代码从 ShowHistory 方法开始,您可以在其中看到,如果 HistoryWindow 尚未显示,是如何创建它的,以及如何将其 DataContext 直接设置为之前添加的论坛对象集合。
    private void ShowHistory()
    {
        if(historyWindow == null)
        {
            historyWindow = new HistoryWindow() 
              { Owner = App.Current.MainWindow, DataContext = this.forumHistory };
            historyWindow.Closed += (s, e) => { historyWindow = null; };
            historyWindow.Show();
        }
    }
    void Timer_Tick(object sender, EventArgs e)
    {
        Fetch();
    }
    private void UpdateForumsCollection(IEnumerable<CodeProjectForum> forums)
    {
        this.Forums.Clear();
        foreach (var item in forums)
        {
            this.Forums.Add(item);
            AddToHistory(item);
        }
    }
    private void AddToHistory(CodeProjectForum item)
    {
        if(forumHistory.Count > 499)
        {
            forumHistory.RemoveAt(499);
        }
        forumHistory.Insert(0, item);
    }
    void Fetcher_FetchCompleted(object sender, CodeProjectForumCountFetcherEventArgs e)
    {
        var updatedForums = e.FetchedForums.Where(
          f => f.Delta > 0).OrderByDescending(item => item.Delta);
        UpdateForumsCollection(updatedForums);
        ResetStatusText();
        if (updatedForums.Count() > 0)
        {
            SystemSounds.Exclamation.Play();
        }
    }
}
就是这样!一如既往,请随时发表您的评论和反馈,即使是很小的意见。也请随时要求添加任何功能,虽然我不能保证会实现它们,或者如果实现,也无法保证在特定的时间段内。
历史
- 2010/9/20 - 文章发布于 The Code Project。


