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

RegEx feed parser for RSS, Atom, OPML ...

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.38/5 (9投票s)

2007年9月12日

CPOL

11分钟阅读

viewsIcon

30677

有没有一种简单的方法来解析feed,而不必担心XML模式的格式和版本陷阱?借助正则表达式,这个想法成为现实……

#Region " RegExFeedParser "

对于处理聚合的编码人员来说,主要的任务是编写XML解析器。有许多实现,采用了各种不同的方法和技术,因此我们可以假设这个主题已经被涵盖了。不幸的是,事实并非如此。不同的格式以及不同的版本正成为一场噩梦,因为很明显,将来我们不得不重写、扩展或升级我们的解析器。让我们尝试(希望如此)一劳永逸地解决这个问题。我们将以一种独立于XML的方式来处理feed,通过开发一个使用正则表达式的跨格式/版本解析器。

事实

目前有两种流行的格式在使用:RSS和Atom。此外,OPML与feeds直接相关。

每种格式都有不同XML模式的版本,这些版本都“活跃”着,因为feed提供商正在使用它们。例如,在RSS中,根元素可以是rdfrssitem节点可以是根节点或channel节点的子节点,而titlelink节点在所有版本中都很常见,这与descriptionpubDate不同。

即使XML节点名称不同,用于相同信息的节点也可能在XML属性中定义其内容。例如,URL信息是RSS的link节点值,是Atom的link节点的href属性值,而发布日期信息可以在RSS的pubDate节点中找到,也可以在Atom的issuedpublished节点中找到。

信息可以是纯文本或(X)HTML。

正则表达式模式

我们的第一个结论是,我们需要总共三个正则表达式模式来进行feed解析。

第一个定义了节点的存在,方式涵盖了任何有效的节点语法

<name\b[^>]*?(/>|>(\w|\W)*?</name>)

其中序列“name”代表节点名称。

第二个定义了属性的存在

\bname="[^"]*?"

其中序列“name”代表属性名称。

最后一个定义了剥离标记元素

</{0,1}name[^>]*?>

上面的模式(信不信由你)涵盖了我们解析feed文档的所有需求。对于它们的结构分析,我们可以使用这个在线正则表达式分析器或任何其他在线或离线分析器。

逻辑 vs. 逻辑

决定使用正则表达式模式解析feed文档意味着我们需要文档内容作为字符串。因此,编写类似这样的内容变得非常容易

Dim doc As New XmlDocument
doc.Load(path)
Dim content As String = doc.OuterXml

Load方法并不那么直接。实际上,它在后台使用XmlTextReader作为XmlLoader的参数,后者通过其Load方法从头开始重建整个XML文档,该方法定义了设置,最后调用LoadDocSequence方法,该方法顺序读取节点,并通过其AppendChildForNode方法使用XmlNode类来处理子节点……最后,OuterXml属性(继承自XmlNode)使用StringWriterXmlDOMTextWriter返回XmlDocument的字符串表示……我们需要所有这些吗?

我们都知道上述方法是优越的,因为它确保我们将获得一个有效的XML文档,但“但是”的情况更重要。XML文档就像HTML文档一样,是带有标签的文档。网络上有大量的格式错误的feed!你能想象一个浏览器因为文档缺少</td>而停止渲染并弹出异常警告吗?我们预定义的正则表达式模式确保只有feed文档的有效部分才会返回结果,而无需异常处理,因为任何可能的异常都将被解析跳过,因为它不会包含在正则表达式匹配中。

作为替代,我们可以使用System.IO.File处理本地文档,使用System.Net.WebClient处理Web文档,如下所示

If path.StartsWith("http:", StringComparison.CurrentCultureIgnoreCase) Then
  Using down As New WebClient
    down.Encoding = Encoding.UTF8
    down.Proxy = WebRequest.DefaultWebProxy 'optional
    content = down.DownloadString(path)
  End Using
Else
  content = File.ReadAllText(path)
End If

System.Net.WebClient允许我们直接使用CredentialsUseDefaultCredentials属性进行请求身份验证,HeadersResponseHeadersProxy用于添加代理支持……

案例分析

我们的目标是开发一个“全局”解析器,具有处理未来格式和版本,甚至其他具有简单模式的XML文档(这里的“简单”是指模式复杂度与RSS和Atom相似或更低)的灵活性。因此,我们需要一个执行解析任务的函数。让我们将其命名为MagicMagic必须能够为不同的feed格式和版本返回易于访问的结果。换句话说,需要一个通用结果的参考。一种简单的方法是检索键/值对形式的结果,所以现在是时候通过一个初始场景来帮助我们继续前进。

我们假设我们关心RSS和Atom feeds中与“title”、“description”、“URL”(文本/HTML页面)和“publication date”相关的信息。字符串数组(命名为keys)可以用来代码化地表示这种假设。

为了检索相关信息,我们必须根据feed格式定义信息容器。因此,对于RSS feeds,我们有另一个字符串数组(命名为fields),用于titlelinkdescriptionpubDate节点。Atom不同,因为信息容器可能是节点的属性。使用“商业符号”(@)作为定义这种情况的语义,我们提出了一个字符串数组(fields),用于titlelink@hrefcontentissuedpublished节点@属性。

缺少的是信息的顶层容器(命名为container),对于RSS是item,对于Atom是entry

虽然始终可以得到纯文本或(X)HTML形式的结果,但使用一个布尔标志(命名为allowHTML)是一个好主意,这样Magic就可以选择性地从结果中删除标签。

我们已经取得了很大的进展,最后一步是决定Magic将如何返回结果。键/值对的使用引导我们选择HashTable(如果我们想执行ToString方法1到n次)或System.Collections.Specialized.NameValueCollection(名称-或键-和值按定义是字符串)或……如何处理多个结果并避免键已被使用的潜在异常(HashTable情况)或混淆的嵌套数组(NameValueCollection情况)?一个简单的答案,一个易于记忆的词是ArrayList

现在很清楚,Magic将是一个函数,其参数为content(字符串)、container(字符串)、keys(字符串数组)、fields(字符串数组)和allowHTML(布尔值),返回一个ArrayList。另一个有用的参数是maxResults(整数),因为它有助于选择前n个逻辑。假设Magic是一个类(命名为RegExFeedParser)的方法,让我们混合代码和英语来描述Magic函数的操作方法。

所有预定义的正则表达式模式都是RegExFeedParser的私有常量,按演示顺序命名为containerPatternattributePatternstripPattern

参数异常处理可以被视为可选的,因为参数是基于我们测试的案例而不是用户输入。无论如何,基本规则是**不**要有空的contentcontainer参数,keysfields参数具有相同的数组Length,且Length大于0(零)。

我们将results声明为一个新的ArrayList

我们将items声明为用于匹配content参数与containerPattern常量的正则表达式MatchCollection

For Each item In the items collection 
	We declare pairs As a New NameValueCollection 
	For Each field In the fields argument
    	We declare with direct assigment the Integer 
                   index of the field In the fields argument 
    	We declare with direct assigment an empty String value 
    	We declare with direct assigment an mask String equal with field 
    	We declare with direct assigment the Integer position of character @ in field 
    	We apply an If condition for pos greater than 0
        	We set the mask equal to the left sub part of field up to pos
    	We close the condition block
    	We define found As the Regular Expression Match of item 
                  combined with the containerPattern 
    	We apply an If condition for pos greater than 0 And also found Not empty.
        	We set the mask equal to the right sub part 
                   of field starting from the pos increased by 1 
        	We set the found equal to the Regular Expression Match 
                       of mask combined with the attributePattern
    	We close the condition block
    	We apply an If condition for found Not empty
        	We apply an inner If condition for pos greater than 0 
        	    We set value equal to found.Value modified by string replacements 
        	Inner condition Else
        	    We set value equal to found.Value modified
        	           by string replacements via Regex
        	We close the inner condition block 
        	We apply an inner If condition for False allowHTML
        	    We remove HTML tags from value with string replacements 
        	We close the inner condition block
    	We close the condition block 
    	We add the pair keys(index) and value to pairs collection 
    	We proceed to the Next field
	We add the pairs collection to the results 
	We stop parsing If results quantity equals to maxResults
We proceed to the Next item 
We return the results ArrayList

RegExFeedParser类

与上面混合的语言相比,用VB.NET编写要容易得多。

Imports System.Collections.Specialized
Imports System.Text.RegularExpressions
Imports System.Web

Public Class RegExFeedParser

  Private Const containerPattern As String = _
    "<name\b[^>]*?(/>|>(\w|\W)*?</name>)"
  Private Const attributePattern As String = "\bname=""[^""]*?"""
  Private Const stripPattern As String = "</{0,1}name[^>]*?>"

  Public Shared Function Magic(ByVal content As String, _
                               ByVal container As String, _
                               ByVal keys As String(), _
                               ByVal fields As String(), _
                               ByVal maxResults As Integer, _
                               ByVal allowHTML As Boolean) As ArrayList
    Dim results As New ArrayList
    Dim items As MatchCollection = Regex.Matches(content, _
        containerPattern.Replace("name", container))
    For Each item As Match In items
      Dim pairs As New NameValueCollection
      For Each field As String In fields
        Dim index As Integer = Array.IndexOf(fields, field)
        Dim value As String = String.Empty
        Dim mask As String = field
        Dim pos As Integer = field.IndexOf("@"c)
        If pos > -1 Then
          mask = field.Substring(0, pos)
        End If
        Dim found As Match = Regex.Match(item.Value, _
            containerPattern.Replace("name", mask))
        If pos > -1 AndAlso Not found.Equals(Match.Empty) Then
          mask = field.Substring(pos + 1)
          found = Regex.Match(found.Value, _
            attributePattern.Replace("name", mask))
        End If
        If Not found.Equals(Match.Empty) Then
          If pos > -1 Then
            value = found.Value.Replace(mask & "=", String.Empty) _
              .Replace(Chr(34), String.Empty)
          Else
            value = Regex.Replace(found.Value, _
              stripPattern.Replace("name", field), _
              String.Empty)
          End If
          ' keep untagged entity information
          If value.IndexOf("<![CDATA[") = 0 Then
            value = value.Substring(0, value.Length - 3).Substring(9)
          End If
          If allowHTML = False Then
            value = HttpUtility.HtmlDecode(value)
            ' tranform breaks to new lines
            value = Regex.Replace(value, _
              stripPattern.Replace("name", "br"), _
              vbCrLf)
            ' remove all tags
            value = Regex.Replace(value, _
              stripPattern.Replace("name", ""), _
              String.Empty)
            ' trim lines
            value = value.Replace(" " & vbCrLf, vbCrLf) _
              .Trim(New Char() {Chr(13), Chr(10)})
          End If
          pairs.Add(keys(index), value)
        End If
      Next
      results.Add(pairs)
      If results.Count = maxResults Then Exit For
    Next
    Return results
  End Function

End Class

在顶层的For Each循环中使用Array.IndexOf不是最优的,但它对于轻松阅读列表非常有效,因为眼睛只关注变量名。好吧,这不适合我们,但我们并不孤单。

HTML内容处理只是一个示例,可以根据更具体的要求进行调整。

拥有RegExFeedParser类后,我们将继续进行一个草稿示例,定义一种使用方法。

测试模块

Imports System.Collections.Specialized
Imports System.Net
Imports System.IO
Imports System.Text.RegularExpressions

Module Test
  Sub Main()
    'FeedTest("http://msdn.microsoft.com/webservices/rss.xml")
    'FeedTest("http://news.google.com/?output=atom")
    'FeedTest("http://share.opml.org/opml/top100.opml")
  End Sub

  Sub FeedTest(ByVal path As String)
    Dim feedFormat As String = String.Empty
    Dim content As String = FeedContent(path, feedFormat)
    If String.IsNullOrEmpty(content) Then
      Debug.WriteLine(New String("x"c, 80))
      Debug.WriteLine("no content for " & path)
      Debug.WriteLine(New String("x"c, 80))
    Else
      Dim container As String
      Dim keys As String()
      Dim fields As String()
      Dim results As New ArrayList
      Dim maxRecords As Integer = 10
      Dim allowHTML As Boolean = True
      Dim isList As Boolean = False
      If feedFormat.StartsWith("rss") OrElse feedFormat.StartsWith("rdf") Then
        container = "item"
        keys = New String() {"title", "url", "description", "date"}
        fields = New String() {"title", "link", "description", "pubDate"}
      ElseIf feedFormat.StartsWith("feed") Then
        container = "entry"
        keys = New String() {"title", "url", "description", "date"}
        fields = New String() {"title", "link@href", "content", _
          "(published|issued)"}
      ElseIf feedFormat.StartsWith("opml") Then
        container = "outline"
        keys = New String() {"url"}
        fields = New String() {"outline@xmlUrl"}
        isList = True
      Else
        Debug.WriteLine(New String("x"c, 80))
        Debug.WriteLine("no implementation for " & feedFormat)
        Debug.WriteLine(New String("x"c, 80))
        Exit Sub
      End If
      results = RegExFeedParser.Magic(content, container, keys, fields, _
        maxRecords, allowHTML)
      If isList = True Then
        For Each result As NameValueCollection In results
          FeedTest(result("url"))
        Next
      Else
        Debug.WriteLine(New String("="c, 80))
        Debug.WriteLine("results for :" & path)
        Debug.WriteLine(New String("="c, 80))
        For Each result As NameValueCollection In results
          For Each key In keys
            Debug.WriteLine(String.Concat(key & ": " & result(key)))
          Next
        Next
      End If
    End If
  End Sub

  Function FeedContent(ByVal path As String, _
    ByRef feedFormat As String) As String
    Dim content As String
    Try
      If path.StartsWith("http:", _
        StringComparison.CurrentCultureIgnoreCase) Then
        Using down As New WebClient
          down.Encoding = Encoding.UTF8
          'down.Proxy = WebRequest.DefaultWebProxy
          content = down.DownloadString(path)
        End Using
      Else
        content = File.ReadAllText(path)
      End If
      Dim lastTag As Integer = content.LastIndexOf("</")
      If lastTag > -1 Then
        feedFormat = content.Substring(lastTag + 2)
      End If
      Return content
    Catch ex As Exception
      Debug.WriteLine(path & vbTab & ex.Message)
      Return Nothing
    End Try
  End Function

End Module

使用测试代码

我们逐个取消注释Main子程序中的每一行(一次一个),以检查我们的Magic函数对RSS、Atom和OPML的支持。对于OPML,整个代码都可以被视为聚合器内核。

通过测试,我们可能会遇到不支持的内容或棘手的XML案例。例如,来自http://rss.slashdot.org/Slashdot/slashdot的RSS 1.0版本feed在item中不包含发布日期信息。如果我们检查其XML,我们会发现实际上这种信息(可选地)作为dc:date元信息包含在内。我们现在能做什么?我们将RSS的fields变量从

fields = New String() {"title", "link", "description", "pubDate"}

to

fields = New String() {"title", "link", "description", "(pubDate|dc:date)"}

然后就完成了!每个field不仅仅是一个字符串;它是正则表达式模式的一部分!

对于有兴趣将代码用于聚合器的场景,我们将需要从feed的“header”部分检索信息,换句话说,分两步解析XML文档。让我们专注于“header”。

对于RSS feed,我们可以使用以下一组定义

maxRecords=1
container = "channel"
keys = New String() {"title", "url", "description", "date"}
fields = New String() {"title", "link", "description", "pubDate"}

对于Atom feed

maxRecords=1
container = "feed"
keys = New String() {"title", "url", "description", "date"}
fields = New String() {"title", "link", "subtitle", "updated"}

关键定义是maxRecords,因为某些fields可能在feed文档中出现不止一次。

我们可以永远“玩”它……

有效的友谊

一位朋友发来的电子邮件说,到目前为止的思路展示并没有揭示其强大之处,这是本文将进行以下内容扩展的原因。感谢George :)

隐藏的可见性

我们有足够的信息来很好地了解正则表达式模式的力量。这些模式本身以及我们使用它们的方式使我们能够创建任何通用或有针对性的feed及相关解决方案,没有限制。用简单的英语来说:每当有新的实现或更新版本时,我们只需要更改变量(或将它们保存在外部的设置),而不是重写或修改解析例程等!

Syndication Extensions

status=READY

正在使用的field作为模式——“”(pubDate|dc:date)——的讨论案例隐藏了一个意想不到的事实:字段可以取值,涵盖所有现有和未来的syndication扩展(blogChannelcreativeCommonsDublic Core等),并且能够与基本feed节点合并使用。让我们通过定义keysfields的值(按顺序演示)来看一些如何示例。

blogChannel扩展

Keys: siteBlogRoll, authorSubscriptions, authorlink, changes

Fields: blogChannel:brogRoll, blogChannel:mySubscriptions, blogChannel:blink, blogChannel:changes

blogChannel:blogRoll和blogChannel:mySubscriptions返回OPML文件的URL(此案例已包含在测试模块的代码示例中)

Creative Commons Extension

Keys: license

Fields: creativeCommons:license

Dublin Code Extension

Keys: title, subject, type, source, relation, creator, date ...

Fields: dc:title, dc:subject, dc:type, dc:source, dc:relation, dc:creator, dc:date ...

Basic Geo (WGS84 lat/long)

Keys: latitude, longitude

Fields: geo:lat, geo:long

……就这样!没有代码,没有新类,没有方法……

XML作为扩展混乱语言

这个标题并不代表XML本身无可辩驳的价值,而是当我们试图处理各种隐藏着高复杂性的语言实现时可能产生的感受。我们的解析方法具有扩展到唯一路径的基础路径的逻辑。以RSS为例,我们有基础路径:/item扩展到/title和/link。Magic函数每次只能解析/item/title和/item/link路径一次。这个事实看起来是一个限制,但现实并非如此。

我们将看到containerPattern常量的常用用法。

<name\b[^>]*?(/>|>(\w|\W)*?</name>)

此模式用于keysfields,通过将序列“name”替换为keyfield值,我们得到所需的标签。再次,我们注意到任何keyfield值本身都可以是一个正则表达式。

现在,让我们看看与博客相关的案例。通过检查来自http://news.google.com/?output=atom的XML文档,我们看到每个entry节点不一定有一个唯一的link节点。值得庆幸的是,每个link节点都用一个rel参数(逻辑类似于元数据)来描述自己,这意味着根本没有问题。

用于唯一解析的field值是“link”。假设我们的应用程序需要备用、回复、自身和编辑(rel属性值)的链接信息。如果我们创造性地将值视为正则表达式模式,我们可以得出fields,例如

link[^>]*(\brel="alternate"){1}
link[^>]*(\brel="replies"){1}
link[^>]*(\brel="self"){1}
link[^>]*(\brel="edit"){1}

现在我们应用@语义来定义包含所需信息的属性,如下所示

link[^>]*(\brel="alternate"){1}@href
link[^>]*(\brel="replies"){1}@href
link[^>]*(\brel="self"){1}@href
link[^>]*(\brel="edit"){1}@href

再次搞定!没有代码,没有新类,没有方法……

不停歇

上述与博客相关的XML文档包含openSearch扩展的元数据信息。我们使用以下keysfields

Keys: results, first, count

Fields: openSearch:totalResults, openSearch:startIndex, openSearch:itemsPerPage

再次搞定!没有代码,没有新类,没有方法……

Google Sitemaps

Google sitemaps是一个很好的使用我们的代码处理非feed相关XML文档的例子。我们假设我们想在应用程序中添加对sitemaps的支持。此时,我们必须扩展我们的Test.FeedTest方法以涵盖sitemap和sitemap index文件。

首先,通过复制“feed”的条件块并粘贴两次,我们得到

ElseIf feedFormat.StartsWith("feed") Then
  container = "entry"
  keys = New String() {"title", "url", "description", "date"}
  fields = New String() {"title", "link@href", "content", _
          "(published|issued)"}
ElseIf feedFormat.StartsWith("feed") Then
  container = "entry"
  keys = New String() {"title", "url", "description", "date"}
  fields = New String() {"title", "link@href", "content", _
          "(published|issued)"}

我们修改粘贴的代码如下

ElseIf feedFormat.StartsWith("urlset") Then
  maxResults = 10000 ' for the top 10,000 records
  container = "url"
  keys = New String() {"url"}
  fields = New String() {"loc"}
ElseIf feedFormat.StartsWith("sitemapindex") Then
  container = "sitemap"
  keys = New String() {"url"}
  fields = New String() {"loc"}

最后,我们扩展“OPML”的条件块。

ElseIf feedFormat.StartsWith("opml") Then

变成

ElseIf feedFormat.StartsWith("opml") OrElse _
       feedFormat.StartsWith("sitemapindex") Then

现在,我们准备使用我们自己的sitemap文件或Google庞大的那个(近4MB,包含35,000多个记录,位于http://www.google.com/sitemap.xml)来测试我们的应用程序……

……又一次搞定!只有设置的简单编码,没有新类,没有方法……

#End Region

所有评论、问题、想法、求助……都非常欢迎。

感谢您的时间。

© . All rights reserved.