RegEx feed parser for RSS, Atom, OPML ...
有没有一种简单的方法来解析feed,而不必担心XML模式的格式和版本陷阱?借助正则表达式,这个想法成为现实……
#Region " RegExFeedParser "
对于处理聚合的编码人员来说,主要的任务是编写XML解析器。有许多实现,采用了各种不同的方法和技术,因此我们可以假设这个主题已经被涵盖了。不幸的是,事实并非如此。不同的格式以及不同的版本正成为一场噩梦,因为很明显,将来我们不得不重写、扩展或升级我们的解析器。让我们尝试(希望如此)一劳永逸地解决这个问题。我们将以一种独立于XML的方式来处理feed,通过开发一个使用正则表达式的跨格式/版本解析器。
事实
目前有两种流行的格式在使用:RSS和Atom。此外,OPML与feeds直接相关。
每种格式都有不同XML模式的版本,这些版本都“活跃”着,因为feed提供商正在使用它们。例如,在RSS中,根元素可以是rdf
或rss
,item
节点可以是根节点或channel
节点的子节点,而title
和link
节点在所有版本中都很常见,这与description
和pubDate
不同。
即使XML节点名称不同,用于相同信息的节点也可能在XML属性中定义其内容。例如,URL信息是RSS的link
节点值,是Atom的link
节点的href
属性值,而发布日期信息可以在RSS的pubDate
节点中找到,也可以在Atom的issued
或published
节点中找到。
信息可以是纯文本或(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
)使用StringWriter
和XmlDOMTextWriter
返回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
允许我们直接使用Credentials
或UseDefaultCredentials
属性进行请求身份验证,Headers
和ResponseHeaders
,Proxy
用于添加代理支持……
案例分析
我们的目标是开发一个“全局”解析器,具有处理未来格式和版本,甚至其他具有简单模式的XML文档(这里的“简单”是指模式复杂度与RSS和Atom相似或更低)的灵活性。因此,我们需要一个执行解析任务的函数。让我们将其命名为Magic
。Magic
必须能够为不同的feed格式和版本返回易于访问的结果。换句话说,需要一个通用结果的参考。一种简单的方法是检索键/值对形式的结果,所以现在是时候通过一个初始场景来帮助我们继续前进。
我们假设我们关心RSS和Atom feeds中与“title”、“description”、“URL”(文本/HTML页面)和“publication date”相关的信息。字符串数组(命名为keys
)可以用来代码化地表示这种假设。
为了检索相关信息,我们必须根据feed格式定义信息容器。因此,对于RSS feeds,我们有另一个字符串数组(命名为fields
),用于title
、link
、description
和pubDate
节点。Atom不同,因为信息容器可能是节点的属性。使用“商业符号”(@)作为定义这种情况的语义,我们提出了一个字符串数组(fields
),用于title
、link@href
、content
和issued
或published
节点@属性。
缺少的是信息的顶层容器(命名为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
的私有常量,按演示顺序命名为containerPattern
、attributePattern
和stripPattern
。
参数异常处理可以被视为可选的,因为参数是基于我们测试的案例而不是用户输入。无论如何,基本规则是**不**要有空的content
和container
参数,keys
和fields
参数具有相同的数组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扩展(blogChannel、creativeCommons、Dublic Core等),并且能够与基本feed节点合并使用。让我们通过定义keys
和fields
的值(按顺序演示)来看一些如何示例。
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>)
此模式用于keys
和fields
,通过将序列“name”替换为key
或field
值,我们得到所需的标签。再次,我们注意到任何key
或field
值本身都可以是一个正则表达式。
现在,让我们看看与博客相关的案例。通过检查来自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扩展的元数据信息。我们使用以下keys
和fields
对
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
所有评论、问题、想法、求助……都非常欢迎。
感谢您的时间。