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

Code Project 论坛:新帖子监视器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.90/5 (43投票s)

2010年9月20日

CPOL

5分钟阅读

viewsIcon

687388

downloadIcon

622

此应用程序可监控 Code Project 论坛的新帖子。

图 1 - 主窗口

图 2 - 历史窗口

引言

我编写此应用程序是因为我想在特定论坛出现新帖子时收到通知。特别是,我关注的是 General Indian Topics 论坛,因为 Chris 要求我对其进行管理,并将其发展成一个面向新手印度 CP 用户的入门论坛。我发现我常常错过新帖子,或者当有人回复别人(因此我没有收到电子邮件)时。显然,每隔 3-4 分钟刷新一次页面会变得有些乏味。这时我才想到,写一个小应用程序,能够抓取该论坛的 HTML 并比较两次抓取之间的论坛计数,会更简单。当我开始着手时,我想到可以为所有论坛都这样做,而最简单的方法就是抓取主论坛列表的 HTML。

一句警告:该应用程序从特定的 URL 获取 HTML。如果 URL 发生变化,或者 HTML 内容发生变化,解析论坛计数的部分代码就会失效。直到 CP 提供一个可靠的 Web 服务来返回论坛计数等信息的那一天,这个应用程序的编写基础将永远是不稳固的。对于 Luc Pattyn 等人编写的类似应用程序也是如此。

该应用程序是使用 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。
© . All rights reserved.