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

Code Project 论坛分析器:找出您有多少不必要的生活!

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.92/5 (39投票s)

2011年3月26日

CPOL

6分钟阅读

viewsIcon

145019

downloadIcon

785

这是一个非官方的 Code Project 应用程序,可以分析一系列帖子来检索单个成员的发帖统计信息。

图 01 - 应用程序运行中,分析 Lounge 帖子

图 02 - 在 Excel 中绘制导出的 CSV 数据图表

引言

这是一个非官方的 Code Project 应用程序,可以分析一系列帖子来检索单个成员的发帖统计信息。与我其他的 Code Project 应用程序(以及 John 和 Luc 等人的应用程序)一样,此应用程序使用 HTML 抓取和解析来提取所需信息。因此,站点布局/CSS 的任何更改都可能破坏此应用程序的功能。在 Code Project 提供允许公开此数据的官方 Web 服务之前,没有解决方法。

使用应用程序

我有一个硬编码的论坛列表,显示在一个组合框中。我选择了更重要的论坛以及发帖量相对可观的论坛。您还可以选择要获取和分析的帖子数量。该应用程序目前支持获取 1,000 篇、5,000 篇或 10,000 篇帖子。超过 10,000 篇是不可靠的,因为您会开始遇到高负载的 Code Project 数据库服务器的影响,这意味着超时和丢失页面。这不会破坏应用程序,但应用程序将被迫跳过页面,从而导致统计准确性降低。即使是 10,000 篇帖子,在像 Bugs/Suggestions 或 C++/CLI 这样的论坛上,您仍然会遇到这种情况,因为一些旧页面中的帖子包含格式错误的 HTML,这会破坏 HTML 解析器。用户界面底部有一个状态日志,会列出此类解析错误,并在分析结束时告诉您跳过了多少帖子。

图 03 - HTML 格式错误的论坛会导致帖子被跳过(截图中有 49 个)

CP 对允许帖子中的 HTML 进行了更严格的检查,因此随着时间的推移,这种情况应该会减少。分析完成后,您可以使用“**导出**”功能将结果保存为 CSV 文件。现在可以在 Excel 中打开此 CSV 文件进行进一步的分析和统计图表绘制。

实用技巧

如果将鼠标悬停在显示名称上,它会突出显示该显示名称,并且鼠标光标会变成一只手。这意味着您可以单击显示名称在默认浏览器中打开用户的 CP 个人资料,即使在分析进行中也可以这样做。

导出到 CSV 和外国语言的成员名称

它应该会根据您当前的区域设置正确选择逗号分隔符(感谢 Mika Wendelius 帮助我正确处理这一点),但我没有将文件保存为 Unicode。因为 Excel 在处理它时似乎遇到了问题,将其视为一个大单列(而不是 3 个独立的列)。所以目前我使用的是 Encoding.Default,这比不使用任何编码要好一点,但如果任何成员显示名称包含 Unicode 字符,您可能会在 Excel 中遇到奇怪的显示。我还没有找到解决方法,目前也不知道是否要花时间研究修复。如果您知道如何解决这个问题,我将非常感谢您的任何建议。

实现细节

获取网站所有数据的主要类是 ForumAnalyzer 类。它使用了出色的 **HtmlAgilityPack** 进行 HTML 解析。以下是该类中一些更有趣的 METH ODS。

private void InitMaxPosts()
{
    string html = GetHttpPage(GetFetchUrl(1), this.timeOut);

    HtmlDocument document = new HtmlDocument();
    document.LoadHtml(html);

    HtmlNode trNode = document.DocumentNode.SelectNodes(
        "//tr[@class='forum-navbar']").FirstOrDefault();
    if (trNode != null)
    {
        if (trNode.ChildNodes.Count > 2)
        {
            var node = trNode.ChildNodes[2];
            string data = node.InnerText;
            int start = data.IndexOf("of");
            if (start != -1)
            {
                int end = data.IndexOf('(', start);
                if (end != -1)
                {
                    if (end - start - 2 > 0)
                    {
                        var extracted = data.Substring(start + 2, end - start - 2);
                        Int32.TryParse(extracted.Trim(), 
                          NumberStyles.AllowThousands, 
                          CultureInfo.InvariantCulture, out maxPosts);
                    }
                }
            }
        }
    }
}

这用于确定论坛中帖子的最大数量。由于页面是动态的,如果您尝试获取超过最后一页的页面,不会出错,但会浪费时间和带宽,并会扰乱统计数据。因此,确保我们知道可以安全获取的最大页数非常重要。

public ICollection<Member> FetchPosts(int from)
{
    if (from > maxPosts)
    {
        throw new ArgumentOutOfRangeException("from");
    }

    string html = GetHttpPage(GetFetchUrl(from), this.timeOut);

    HtmlDocument document = new HtmlDocument();
    document.LoadHtml(html);

    Collection<Member> members = new Collection<Member>();
    
    foreach (HtmlNode tdNode in document.DocumentNode.SelectNodes(
        "//td[@class='Frm_MsgAuthor']"))
    {
        if (tdNode.ChildNodes.Count > 0)
        {
            var aNode = tdNode.ChildNodes[0];
            int id;
            if (aNode.Attributes.Contains("href") 
                && TryParse(aNode.Attributes["href"].Value, out id))
            {
                members.Add(new Member(id, aNode.InnerText));
            }
        }
    }
    
    return members;
}

这是提取帖子数据的地方。它利用了 Code Project 使用的 **Frm_MsgAuthor** CSS 类。现在既然我在这里提到了这一点,我敢打赌墨菲定律将会显现,Chris 会随意重命名该类。请注意,此类只会以大块的形式返回帖子数据,具体分析则取决于调用者。我在应用程序的视图模型中执行此操作。这个决定可能会被一些人质疑,他们可能会认为应该有一个单独的包装器来完成计算,而视图模型只需访问该数据。如果是这样,是的,他们可能是对的,但对于这样一个简单的应用程序,我选择了简洁性而不是设计上的纯粹性。

使用 ForumAnalyzer 类的代码是从后台工作线程调用的,完成后,会生成第二个后台工作线程来对结果进行排序。我还使用了 Task Parallel Library 的 Parallel.For,这给了我显著的速度提升。最初运行在我的连接上(15 Mbps,提升到 24 Mbps)需要 8-9 分钟,但一旦我添加了 Parallel.For,这个时间就缩短到一分钟多一点。从大约 8 分钟缩短到 1 分钟,这非常令人印象深刻!不过,有一些副作用,我将在下面的代码列表之后讨论。

private void Fetch()
{
    canFetch = false;
    canExport = false;
    this.logs.Clear();

    var dispatcher = Application.Current.MainWindow.Dispatcher;
    Stopwatch stopWatch = new Stopwatch();
    
    BackgroundWorker worker = new BackgroundWorker();
    worker.DoWork += (sender, e) =>
        {
            ForumAnalyzer analyzer = new ForumAnalyzer(this.SelectedForum);

            dispatcher.Invoke((Action)(() =>
            {
                this.TimeElapsed = TimeSpan.FromSeconds(0).ToString(timeSpanFormat);
                this.Total = 0;
                this.results.Clear();
                AddLog(new LogInfo("Started fetching posts..."));
            }));

            Dictionary<int, MemberPostInfo> results = 
                  new Dictionary<int, MemberPostInfo>();
            stopWatch.Start();

            ParallelOptions options = new ParallelOptions() 
              { MaxDegreeOfParallelism = 8 };
            Parallel.For(0, Math.Min((int)(PostCount)this.PostsToFetch, 
                analyzer.MaxPosts) / postsPerPage, options, (i) =>
            {
                ICollection<Member> members = null;
                int trials = 0;

                while (members == null && trials < 5)
                {
                    try
                    {
                        members = analyzer.FetchPosts(i * postsPerPage + 1);
                    }
                    catch
                    {
                        trials++;
                    }
                }

                if (members == null)
                {
                    dispatcher.Invoke((Action)(() =>
                    {
                        AddLog(new LogInfo(
                            "Http connection failure", i, postsPerPage));
                    }));

                    return;
                }

                if (members.Count < postsPerPage)
                {
                    dispatcher.Invoke((Action)(() =>
                    {
                        AddLog(new LogInfo(
                            "Html parser failure", i, postsPerPage - members.Count));
                    }));
                }

                lock (results)
                {
                    foreach (var member in members)
                    {
                        if (results.ContainsKey(member.Id))
                        {
                            results[member.Id].PostCount++;
                        }
                        else
                        {
                            results[member.Id] = new MemberPostInfo() 
                              { Id = member.Id, DisplayName = member.DisplayName, 
                                  PostCount = 1 };

                            dispatcher.Invoke((Action)(() =>
                            {
                                this.results.Add(results[member.Id]);
                            }));
                        }
                    }

                    dispatcher.Invoke((Action)(() =>
                    {
                        this.Total += members.Count;
                        this.TimeElapsed = stopWatch.Elapsed.ToString(timeSpanFormat);
                    }));
                }
            });
        };

    worker.RunWorkerCompleted += (s, e) =>
        {
            stopWatch.Stop();

            BackgroundWorker sortWorker = new BackgroundWorker();
            sortWorker.DoWork += (sortSender, sortE) =>
            {
                var temp = this.results.OrderByDescending(
                    ks => ks.PostCount).ToArray();

                dispatcher.Invoke((Action)(() =>
                {
                    AddLog(new LogInfo("Sorting results..."));
                    foreach (var item in temp)
                    {
                        this.results.Remove(item);
                        this.results.Add(item);
                    }
                }));
            };

            sortWorker.RunWorkerCompleted += (sortSender, sortE) =>
                {
                    AddLog(new LogInfo("Task completed!"));
                    canFetch = true;
                    canExport = true;
                    CommandManager.InvalidateRequerySuggested();
                };

            sortWorker.RunWorkerAsync();                    
        };

    worker.RunWorkerAsync();
}

当我添加 Parallel.For 时,我注意到的第一件事是错误和超时数量显著增加,以至于结果几乎没有用。发生的情况是我在与 CP 内置的洪水保护系统对抗,我意识到如果我并行启动太多连接,这根本行不通。经过反复试验,我最终将并发的最大级别降低到 8,这给了我最好的结果。巧合的是,我有 4 个核心带有超线程,也就是 8 个虚拟 CPU - 所以这对我来说很完美。请注意,这纯属巧合,我不得不降低并行度的原因是 CP 的洪水预防系统,而不是我拥有的核心数量。

使用 Parallel.For 的一个主要副作用是,我失去了按顺序获取页面的能力。如果我没有使用并行循环,我可以检测到 HTML 解析错误,然后跳过那个帖子,继续处理下一个帖子。但对于并发循环,如果遇到错误,我被迫跳过该页面的其余部分。当然,并非不可能通过启动一个侧任务来正确处理这种情况,该任务将仅获取被跳过的帖子(不包括格式错误的帖子),但这大大增加了代码的复杂性。我决定可以接受 10,000 篇帖子中丢失 50-100 篇。这低于 1% 的准确性偏差,我认为对于该应用程序来说是可以接受的。

好了,就是这些了。感谢阅读本文并尝试该应用程序。一如既往,我非常感谢任何和所有的反馈、批评和评论。

参考文献

致谢

感谢以下 CP 用户帮助测试应用程序!非常非常感激。我只列出了前 3 位,但还有很多人也提供了帮助。所以感谢他们所有人,我为没有一一列出所有人表示歉意(我没想到会有这么多人如此乐于助人)!

历史

  • 2011 年 3 月 26 日 - 文章首次发布
© . All rights reserved.