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

Windows Phone 7的代码项目帖子分析器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.96/5 (28投票s)

2011 年 1 月 23 日

CPOL

5分钟阅读

viewsIcon

166445

downloadIcon

385

这是一个 WP7 应用程序,可以分析您最近的帖子,并为您提供有关您在论坛中发帖分布的汇总统计信息。

CP 帖子分析器运行中 - 帖子视图

CP 帖子分析器运行中 - 分类视图

引言

CP 帖子分析器是一个 Windows Phone 7 应用程序,可以分析任何 CP 成员发布的最后 200 条帖子(涵盖所有论坛、文章、调查等)。您需要开发者许可证才能在 WP7 设备上部署和运行此程序,但如果您没有许可证,仍然可以在模拟器上运行。我已在模拟器和三星 Focus 手机上测试过此应用程序。

警告

由于该应用程序会抓取 CP 网站的 HTML,因此如果网站布局发生变化(在抓取的页面中),该应用程序可能会停止工作。希望这种情况不会太频繁,以免应用维护起来很麻烦。

此应用程序使用极佳且强烈推荐的 HTML Agility Pack 来解析 HTML。

使用方法

我在下方包含了一些截图供您参考,但该应用程序非常简单易用。运行应用程序时,您会看到一个全景视图,第一个页面会提示您输入会员 ID(例如,我的会员 ID 是 20248)。您可以从您的个人资料页面获取会员 ID。由于该应用程序不需要您登录,因此您可以输入任何您想查看的会员 ID。该应用程序会维护您最近使用过的 10 个会员 ID 的历史记录,从而为您节省一些输入(在带有虚拟键盘的这种小型设备上输入并不愉快)。最近的会员 ID 使用隔离存储进行存储,因此会一直保留,直到您卸载该应用程序。

运行获取后,全景视图中的其他三个页面将发挥作用。第一个页面提供您最后 200 条帖子的分类分布。Lounge 属于“Page”类别,但大多数其他论坛,包括技术论坛和一些非技术论坛(如印度论坛),都属于“Forum”。您在文章中发布的任何帖子都会被分类为“Article”,然后您还有“Survey”和“Member”类别(不言而喻)。可能还有我遗漏或未遇到的其他类别,它们暂时会显示为“Unknown”。

下一页是您帖子在论坛中的分布情况,这样您就可以看到您在特定论坛发了多少帖子。由于 Chris 选择只暴露您发布的最后 200 条消息,所以此应用程序的限制也在此。因此,该应用程序基本上会分析您最近的帖子活动。在调试/测试应用程序时,我发现我的大部分帖子都在 Lounge 论坛,直到印度对南非的 ODI 比赛发生,然后我在印度论坛的帖子就像疯了一样占了主导地位。我运行了 Chris 的数据,发现他大部分时间都在 Site Bugs / Suggestions 论坛回答问题(我想他做了一项基本上吃力不讨好的工作)。

全景视图中的最后一页将列出所有 200 条帖子,为您提供近期帖子内容的快速摘要。仅显示主题标题(不显示消息内容)。

该应用程序支持休眠,因此您可以切换到另一个应用程序再回来,您的状态都会被保留。好吧,我想就这些了。如果您能想到一些有用的功能,并且考虑到我所有输入数据都来自 HTML 抓取这一限制,我很乐意尝试为您进行更改(前提是我有时间)。查看更多截图,然后是一个关于技术实现细节的简短部分。

更多截图

CP 帖子分析器在应用程序列表中的显示

CP 帖子分析器运行中 - 运行获取

CP 帖子分析器运行中 - Roger 的分类视图

CP 帖子分析器运行中 - 论坛视图

CP 帖子分析器运行中 - 磁贴图标

实现细节

该应用程序是用 SilverLight 为 WP7 编写的,并且项目尝试使用基本的 MVVM 模型。为了帮助进行数据绑定,我使用了以下几种类型来表示返回的数据。

namespace CPPostsAnalyzerWP7.Models
{
    public class PostInfo
    {
        public string ThreadName { get; set; }

        public string DisplayName { get; set; }

        public string TimeString { get; set; }

        public string ForumName { get; set; }

        public ForumType ForumType { get; set; }
    }
}
namespace CPPostsAnalyzerWP7.Models
{
    public class PostSummaryInfo
    {
        public string ForumName { get; set; }

        public int Count { get; set; }

        public ForumType ForumType { get; set; }
    }
}
namespace CPPostsAnalyzerWP7.Models
{
    public enum ForumType
    {
        Unknown = 0,

        Forum = 1,

        Page = 2,

        Article = 3,

        Survey = 4,

        Member = 5
    }
}

有一个 PostsFetcher 类负责 HTML 获取和解析,它使用了出色的 HTML Agility Pack 库。

// The code has been artificially line-wrapped to fit within the 
// CP article width limits. The source code in the project is formatted
// far more elegantly.

namespace CPPostsAnalyzerWP7.Models
{
    public class PostsFetcher
    {
        private string memberId;

        public PostsFetcher(string memberId)
        {
            this.memberId = memberId.Trim();            
        }

        public event EventHandler<PostInfoEventArgs> PostFetched;
        
        public event EventHandler<FetchCompletedEventArgs> FetchCompleted;

        private void FirePostFetched(PostInfoEventArgs e)
        {
            var handler = this.PostFetched;

            if (handler != null)
            {
                handler(this, e);
            }
        }

        private void FireFetchCompleted(FetchCompletedEventArgs e)
        {
            var handler = this.FetchCompleted;

            if (handler != null)
            {
                handler(this, e);
            }
        }

        private int nextPage = 1;

        private const int maxPage = 4;

        public void Fetch()
        {
            int temp;

            if (Int32.TryParse(this.memberId, out temp))
            {
                LoadNextPageAsync();
            }
            else
            {
                FireFetchCompleted(new FetchCompletedEventArgs() 
                  { Error = new ArgumentException("Invalid memberId.") });
            }
        }

        private void LoadNextPageAsync()
        {
            HtmlWeb.LoadAsync(String.Format(
              "https://codeproject.org.cn/script/Forums/Messages.aspx?fmid={0}&fid=0&pgnum={1}", 
              this.memberId, nextPage++), HtmlLoaded);
        }

        private void HtmlLoaded(object sender, HtmlDocumentLoadCompleted e)
        {
            if (e.Error != null)
            {
                FireFetchCompleted(new FetchCompletedEventArgs() { Error = e.Error });
                return;
            }

            try
            {
                ParseHtml(e);
            }
            catch (Exception ex)
            {
                FireFetchCompleted(new FetchCompletedEventArgs() { Error = ex });
            }

            if (nextPage > maxPage)
            {
                var args = new FetchCompletedEventArgs();

                foreach (var item in forumTypeCountMap)
                {
                    args.ForumTypeSummaries.Add(new PostSummaryInfo() 
                      { ForumType = item.Key, Count = item.Value, ForumName = String.Empty });
                }

                foreach (var item in forumPostsCountMap)
                {
                    args.PostSummaries.Add(new PostSummaryInfo() 
                      { ForumType = ForumType.Unknown, Count = item.Value, ForumName = item.Key });
                }

                FireFetchCompleted(args);
            }
            else
            {
                LoadNextPageAsync();
            }
        }

        private Dictionary<ForumType, int> forumTypeCountMap = new Dictionary<ForumType, int>();
        
        private Dictionary<string, int> forumPostsCountMap = new Dictionary<string, int>();

        private void ParseHtml(HtmlDocumentLoadCompleted e)
        {
            var tableNode = e.Document.DocumentNode.DescendantNodes().Where(
                n => n.Name.ToLower() == "table"
                    && n.Attributes["cellspacing"] != null
                    && n.Attributes["cellspacing"].Value == "4").FirstOrDefault();

            if (tableNode == null)
                return;

            var trNodes = tableNode.Descendants("tr");
            foreach (var tdNode in trNodes)
            {
                var aNode = tdNode.Descendants("a").FirstOrDefault();
                if (aNode == null)
                    continue;

                PostInfo postInfo = new PostInfo();
                postInfo.ThreadName = aNode.InnerText.Trim();

                var divNodes = tdNode.Descendants("div").Where(
                    n => n.Attributes["class"] != null
                        && n.Attributes["class"].Value == "small-text subdue");

                if (divNodes.Count() == 2)
                {
                    var divNodesArray = divNodes.ToArray();

                    string nameAndTime = divNodesArray[0].InnerText.Trim();
                    int byPos = nameAndTime.IndexOf("by");
                    int atPos = nameAndTime.LastIndexOf("at");

                    if (byPos == -1 || atPos == -1)
                        continue;

                    postInfo.DisplayName = nameAndTime.Substring(byPos + 2, atPos - byPos - 2).Trim();

                    postInfo.TimeString = nameAndTime.Substring(atPos + 2).Trim();

                    string[] forumLines = divNodesArray[1].InnerText.Trim().Split(
                      '\r', '\n').Where(s => !String.IsNullOrEmpty(s.Trim('\r', '\n'))).ToArray();

                    if (forumLines.Length < 1 || forumLines.Length > 2)
                        continue;

                    string forumNameInput = forumLines.Length == 1 ? "(Untitled)" : forumLines[0];
                    string forumTypeInput = forumLines.Length == 1 ? forumLines[0] : forumLines[1];

                    postInfo.ForumName = forumNameInput.Trim();

                    int leftBracketPost = forumTypeInput.IndexOf('(');
                    int rightBracketPost = forumTypeInput.IndexOf(')');
                    if (leftBracketPost == -1 || rightBracketPost == -1)
                        continue;

                    ForumType forumType = ForumType.Unknown;

                    try
                    {
                        string enumLine = forumTypeInput.Substring(
                          leftBracketPost + 1, rightBracketPost - leftBracketPost - 1).Trim();
                        forumType = (ForumType)Enum.Parse(typeof(ForumType), enumLine.Trim(), true);
                    }
                    catch (ArgumentException)
                    {
                    }

                    postInfo.ForumType = forumType;
                }

                if (!forumTypeCountMap.ContainsKey(postInfo.ForumType))
                {
                    forumTypeCountMap[postInfo.ForumType] = 1;
                }
                else
                {
                    forumTypeCountMap[postInfo.ForumType]++;
                }
                
                if (!forumPostsCountMap.ContainsKey(postInfo.ForumName))
                {
                    forumPostsCountMap[postInfo.ForumName] = 1;
                }
                else
                {
                    forumPostsCountMap[postInfo.ForumName]++;
                }

                FirePostFetched(new PostInfoEventArgs() { PostInfo = postInfo });
            }
        }
    }
}

只是基本的 HTML 解析而已——正如您所见,我不得不做出一些高风险的假设(因为其中一些表格没有关联可识别的 ID)。另外,我使用 Enum.Parse 的原因是 WP7 的 mscorlib 没有 TryParse

Tombstoning

正如我之前提到的,该应用程序支持休眠,我需要做的一件事就是确保所有我想恢复的类型都完全兼容序列化(这基本上意味着它们必须具有公共属性等)。这是处理休眠的代码。

protected override void OnNavigatedFrom(System.Windows.Navigation.NavigationEventArgs e)
{
    App.MainViewModel.SaveState();

    base.OnNavigatedFrom(e);
}

protected override void OnNavigatedTo(System.Windows.Navigation.NavigationEventArgs e)
{
    App.MainViewModel.RetrieveState();

    base.OnNavigatedTo(e);
}

这是视图模型中的实现。

public void SaveState()
{
    var appService = PhoneApplicationService.Current;
    
    appService.State.Clear();

    appService.State["MemberId"] = this.MemberId;
    appService.State["CanFetch"] = this.CanFetch;
    
    if (this.CanFetch)
    {
        appService.State["MemberName"] = this.MemberName;
        appService.State["Results"] = this.Results;
        appService.State["ForumTypeSummaries"] = this.ForumTypeSummaries;
        appService.State["PostSummaries"] = this.PostSummaries;
    }
}

public void RetrieveState()
{
    var appService = PhoneApplicationService.Current;

    if (appService.State.ContainsKey("MemberId"))
    {
        this.MemberId = (string)appService.State["MemberId"];
    }

    if (appService.State.ContainsKey("CanFetch"))
    {
        this.CanFetch = (bool)appService.State["CanFetch"];
    }

    if (!this.CanFetch)
    {
        this.CanFetch = true;
        return;
    }

    if (appService.State.ContainsKey("MemberName"))
    {
        this.MemberName = (string)appService.State["MemberName"];
    }

    if (appService.State.ContainsKey("Results"))
    {
        this.Results.Clear();

        foreach (PostInfo item in (IEnumerable<PostInfo>)appService.State["Results"])
        {
            this.Results.Add(item);
        }
    }

    if (appService.State.ContainsKey("ForumTypeSummaries"))
    {
        this.ForumTypeSummaries.Clear();

        foreach (PostSummaryInfo item in 
          (IEnumerable<PostSummaryInfo>)appService.State["ForumTypeSummaries"])
        {
            this.ForumTypeSummaries.Add(item);
        }
    }

    if (appService.State.ContainsKey("PostSummaries"))
    {
        this.PostSummaries.Clear();

        foreach (PostSummaryInfo item in 
          (IEnumerable<PostSummaryInfo>)appService.State["PostSummaries"])
        {
            this.PostSummaries.Add(item);
        }
    }
}

我只是顺带提一下,我曾尝试序列化整个视图模型但都失败了,从一开始就注定要失败。我最终放弃了,决定只序列化我具体想要的东西。我想我本可以成功,但这会浪费太多精力而没有任何回报,除了可能一些虚假提升的自尊,而我对此并不在意!

隔离存储

收件人列表通过隔离存储进行保存,因此您最近的 10 次搜索将被记住。

public MainViewModel()
{
    IsolatedStorageFile store = IsolatedStorageFile.GetUserStoreForApplication();
    if (!store.FileExists(configPath))
        return;

    using (StreamReader reader = new StreamReader(
      new IsolatedStorageFileStream(configPath, FileMode.Open, store)))
    {
        var lines = reader.ReadToEnd().Trim().Split('\r', '\n').Where(
          s => !String.IsNullOrEmpty(s.Trim('\r', '\n'))).ToList();
        lines.ForEach(line => recentMemberIds.Add(line));
    }
}

private void SaveConfig()
{
    IsolatedStorageFile store = IsolatedStorageFile.GetUserStoreForApplication();
    string baseDirectory = System.IO.Path.GetDirectoryName(configPath);
    if (!store.DirectoryExists(baseDirectory))
    {
        store.CreateDirectory(baseDirectory);
    }

    using (StreamWriter writer = new StreamWriter(
      new IsolatedStorageFileStream(configPath, FileMode.Create, store)))
    {
        foreach (var entry in recentMemberIds)
        {
            writer.WriteLine(entry);
        }
    }
}

内置支持使得使用隔离存储非常容易。最初,我确实考虑过保存帖子然后累积它们,以便我可以分析超过 200 条帖子,但这假设用户会足够频繁地运行它,以至于不会丢失帖子(这很难强制执行,实际上是不可能的)。所以我就放弃了,认为 200 条足够了。有一天,如果 Chris 将该限制增加到 1000 条,我将在那时更新该应用程序。

结论

好了,这就是全部内容。它已经经过了相当彻底的测试,但可能存在问题,尤其是在其他手机型号上。错误处理有点隐蔽,所以如果遇到任何错误,它不会崩溃,但也不会告知您。您可以尝试再次运行 Fetch,或者关闭并重新运行应用程序(尽管我从未不得不这样做)。

请随时提交您的反馈、批评和建议。

历史

  • 2011 年 1 月 23 日 - 文章首次发布。
© . All rights reserved.