在 C# 搜索引擎/网络爬虫中添加功能
为 Searcharoo 项目添加高级搜索引擎功能(和持久化目录)
背景
本文承接之前的两个 Searcharoo 示例
Searcharoo 版本 1 介绍了如何构建一个简单的搜索引擎,该搜索引擎从指定文件夹爬取文件系统,并索引所有 HTML(或其他已知类型)的文档。开发了一个基本的设计和对象模型,以支持简单的单字搜索,搜索结果显示在一个基础的查询/结果页面上。
Searcharoo 版本 2 重点在于添加一个“爬虫”来查找要索引的数据,通过跟踪网页链接(而不是仅仅查看文件系统中的目录列表)。这意味着通过 HTTP 下载文件,解析 HTML 以查找更多链接,并确保我们不会陷入递归循环,因为许多网页会相互引用。本文还讨论了如何将多个搜索词的结果合并到一个“匹配”集中。
引言
本文(Searcharoo 版本 3)涵盖了三个主要方面
- 为目录实现“保存到磁盘”功能
- 功能建议、错误修复以及整合其他人对先前文章的贡献代码(主要来自 CodeProject - 谢谢!)
- 改进代码本身(添加注释、移动类、提高可读性,并希望使其更容易修改和重用)
新“功能”包括
- 将目录(内存中用于快速搜索)保存到磁盘
- 使爬虫能够识别并跟踪 FRAMESET 和 IFRAME 中引用的页面(le_mo_mo 建议)
- 结果分页显示,而不是全部列在一个页面上(由 Jim Harkins 提交)
- 标准化单词和数字(去除标点符号等)
- (可选)对英文单词进行词干提取,以减小目录大小(Chris Taylor 和 Trickster 建议)
- (可选)使用停用词来减小目录大小
- (可选)创建“Go word”列表,专门为领域特定词汇(如“C#”)创建目录,这些词汇否则可能会被忽略
错误修复包括
- 正确解析可能带有额外属性的
标签,例如在 ASP.NET 环境中的 ID= 属性。(xenomouse 提交) - 处理服务器设置的用于跟踪“会话”的 Cookie。(Simon Jones 提交)
- 检查重定向后的“最终”URL,以确保正确的页面被索引和链接。(Simon Jones 提交)
- 正确解析(并遵守!)ROBOTS meta 标签。(我自己在发现的这个 bug)
代码布局改进包括
- 将 SearcharooSpider.aspx 中混乱的爬虫代码移到一个真正的 C# 类中(并实现 EventHandler 以允许监视进度)
- 将偏好设置封装到一个静态类中
- 使用 #regions 布局 Searcharoo.cs(如果您有 VS.NET,易于阅读)
- 为搜索框创建用户控件(Searcharoo.ASCX)- 如果您想重新品牌化,只需在一个地方修改即可。
- 使用 PagedDataSource 实现分页,您可以轻松地在 Searcharoo3.aspx 中更改结果的“模板”(例如链接大小/颜色/布局)
设计
(从版本 1 开始)基本的 Catalog-File-Word 设计保持不变,但此版本实现了许多额外的类。
为了构建目录,SearcharooSpider.aspx 调用 Spider.BuildCatalog(),该方法
- 访问 Preferences 静态对象以读取设置
- 创建空的 Catalog
- 创建 IGoWord、IStopper 和 IStemming 的实现(基于 Preferences)
- 处理 startPageUri(使用 WebRequest)
- 创建 HtmlDocument,填充属性,包括 Link 集合
- 解析页面内容,根据需要创建 Word 和 File 对象
- 为每个 LocalLink 递归应用步骤 4 到 6
- 使用 CatalogBinder 将 Catalog 二进制序列化到磁盘
- 将 Catalog 添加到 Application.Cache[] 中,供 Searcharoo3.aspx 用于搜索!
代码结构
这些是此版本中使用的文件(包含在 下载中)。
web.config | 14 个设置,用于控制爬虫和搜索页面的行为。它们都是“可选的”(即,如果未提供配置设置,爬虫和搜索页面将运行),但我建议至少提供<add key="Searcharoo_VirtualRoot" value="https:///content/" /> |
---|---|
Searcharoo.cs | 该应用程序的大部分代码都在这个文件中。版本 2 中 ASPX 文件中的许多类(如 Spider 和 HtmlDocument)已被移到这个文件中,因为它更容易阅读和维护。新的版本 3 功能(Stop、Go、Stemming)都添加在这里。![]() |
Searcharoo3.aspx | 搜索页面(输入和结果)。检查 Application-Cache 中是否有 Catalog,如果没有,则创建它(反序列化或运行 SearcharooSpider.aspx)。 |
Searcharoo.ascx | 新用户控件,包含两个 asp:Panel
|
SearcharooSpider.aspx | 主页面(Searcharoo3.aspx)将 Server.Transfer 到此页面以创建新 Catalog(如果需要)。 版本 2 中此页面中几乎所有代码都已迁移到 Searcharoo.cs - OnProgressEvent() 允许它在爬虫进行时仍显示“进度”消息。 |
将 Catalog 保存到磁盘
将目录保存到磁盘有几个好处
- 它可以在与网站不同的服务器上构建(对于小型网站,代码可能没有权限在 Web 服务器上写入磁盘)
- 如果服务器的 Application 重启,目录可以重新加载,而不是完全重新构建
- 您最终可以“看到”目录中存储了什么信息 - 有助于调试!
框架中提供了两种序列化(XML 和二进制)方式,由于 XML 是“人类可读的”,所以似乎是尝试它的逻辑选择。序列化 Catalog 所需的代码非常简单 - 下面的代码来自 Catalog.Save() 方法,因此对此的引用就是 Catalog 对象。
XmlSerializer serializerXml = new XmlSerializer( typeof( Catalog ) );
System.IO.TextWriter writer
= new System.IO.StreamWriter( Preferences.CatalogFileName+".xml" );
serializerXml.Serialize( writer, this );
writer.Close();
我主要使用的“测试数据集”是CIA World Factbook(下载),它在磁盘上大约是52.6 MB(仅 HTML,不包括图像和不可搜索数据) - 所以当我看到 XML 序列化的 Catalog 本身是其三倍大,达到156 MB(是的,兆字节!)时,我多么“惊讶”。几乎无法轻松打开它,只能通过命令提示符“type”它。

哎哟 - 太浪费空间了!更糟的是,这是我第一次注意到 File 类中定义的字段被声明为 public 而不是 private(参见以 `_` 开头的字段)。首先,让我们去除序列化重复项(应该是 private 的字段及其 public 属性对应项)-- 而不是改变可见性(并可能破坏代码),可以在定义中添加 `[XmlIgnore]` 属性。为了进一步减少重复文本的数量,使用 `[XmlElement]` 属性将元素名称压缩为单个字母,并且为了减少 `<>` 的数量,一些属性被标记为 `[XmlAttribute]` 进行序列化。
[Serializable]
public class Word
{
[XmlElement("t")] public string Text;
[XmlElement("fs")] public File[] Files
...
[Serializable]
public class File
{
[XmlIgnore] public string _Url;
...
[XmlAttribute("u")] public string Url { ...
[XmlAttribute("t")] public string Title { ...
[XmlElement("d")] public string Description { ...
[XmlAttribute("d")] public DateTime CrawledDate { ...
[XmlAttribute("s")] public long Size { ...
...
XML 文件现在只有微小(不是!)的49 MB,仍然太大而无法用记事本打开,但可以通过 cmd 轻松查看。正如您在下面看到的,“压缩”XML 确实节省了一些空间 - 至少 Catalog 现在比源数据小了!

即使输出缩小了,49MB 的 XML 仍然有点太冗长而不实用(这并不奇怪,XML 通常是这样!),所以让我们将索引序列化为二进制格式(同样,框架类使这非常简单)。
System.IO.Stream stream = new System.IO.FileStream
(Preferences.CatalogFileName+".dat" , System.IO.FileMode.Create );
System.Runtime.Serialization.IFormatter formatter =
new System.Runtime.Serialization.Formatters.Binary.BinaryFormatter();
formatter.Serialize (stream, this);
stream.Close();
改为二进制序列化的结果非常显著 - 相同的目录数据是4.6 MB,而不是 150 MB!大约是 XML 大小的 3%,这绝对是正确的方式。
现在 Catalog 可以成功保存到磁盘,似乎重新将其加载到内存和 Application Cache 中很简单...
从磁盘加载 Catalog
不幸的是,事情并没有那么简单。每当 Application 重启时(例如修改了 web.config 或 Searcharoo.cs),代码都无法反序列化文件,而是抛出这个晦涩的错误
无法找到程序集 h4octhiw, Version=0.0.0.0, Culture=neutral, PublicKeyToken=null
起初我感到困惑 - 我没有任何名为h4octhiw的程序集,所以一时不清楚为什么找不到它。但有几个提示
- “找不到”的程序集似乎有一个随机生成的名称……而我们知道什么程序集使用随机生成的名称?\Temporary ASP.NET Files\ 目录,动态编译的程序集(来自 src="" 和 ASPX)保存在那里。
- 错误行只引用了“object”和“stream”类型 - 它们肯定不是有问题的原因吧?
- 阅读堆栈跟踪(从底部开始,向上阅读,一如既往)(点击图片),您可以推断 Deserialize 方法创建了一个 BinaryParser,该 BinaryParser 创建一个 ObjectMap,其中包含一个 MemberNames 数组,该数组又请求 ObjectReader.GetType(),这会触发 GetAssembly() 方法……但它失败了!嗯 - 听起来它可能正在查找已序列化的类型 - 为什么找不到它们?
如果您精通 Google 技能,而不是在搜索ASP.NET "Cannot find the assembly"时返回的数十个无用链接,您将很幸运地偶然发现这篇关于序列化的 CodeProject 文章,您将了解到一项非常有趣的知识
类型信息在类被序列化时也会被序列化,以便使用类型信息反序列化该类。类型信息包括命名空间、类名、程序集名称、区域性信息、程序集版本和公钥标记。只要您的反序列化类和被序列化的类位于同一个程序集中,就不会有问题。但是,如果序列化器位于单独的程序集中,.NET 就找不到您的类的类型,因此无法反序列化它。
但这意味着什么?每次 Web/IIS 的“Application”重启时,您所有的 ASPX 和 src="" 代码都会被重新编译到一个新的、随机命名的程序集中,位于 \Temporary ASP.NET Files\。所以,尽管 Catalog 类是基于相同的代码,但它的类型信息(命名空间、类名、程序集名称、区域性信息、程序集版本和公钥标记)是不同的!
而且,重要的是,当一个类被二进制序列化时,它的类型信息会与它一起存储(题外话:XML 序列化不会发生这种情况,所以如果我们坚持使用 XML,我们可能都没事)。
结果是:每次重新编译后(无论是什么触发的:web.config 更改、代码更改、IIS 重启、机器重启等),我们的 Catalog 类都有不同的类型信息 - 当它尝试加载之前保存的序列化版本时,它不匹配,框架找不到前一个 Catalog 类型定义的程序集(因为它只是临时的,并且在重新编译时已被删除)。
自定义格式化程序实现
听起来复杂?确实有点,但整个“临时程序集”的东西是隐式发生的,大多数开发人员不需要了解或关心太多。值得庆幸的是,我们也不必过于担心,因为关于序列化的 CodeProject 文章也提供了解决方案:一个辅助类,它会“欺骗”Binary Deserializer 使用“当前”的 Catalog 类型。
public class CatalogBinder: System.Runtime.Serialization.SerializationBinder
{
public override Type BindToType (string assemblyName, string typeName)
{
// get the 'fully qualified (ie inc namespace) type name' into an
// array
string[] typeInfo = typeName.Split('.');
// because the last item is the class name, which we're going to
// 'look for' in *this* namespace/assembly
string className=typeInfo[typeInfo.Length -1];
if (className.Equals("Catalog"))
{
return typeof (Catalog);
}
else if (className.Equals("Word"))
{
return typeof (Word);
}
if (className.Equals("File"))
{
return typeof (File);
}
else
{ // pass back exactly what was passed in!
return Type.GetType(string.Format( "{0}, {1}", typeName,
assemblyName));
}
}
}
瞧!现在 Catalog 可以保存/加载了,搜索引擎比以前更加健壮。您可以保存/备份 Catalog,打开调试模式查看其内容,甚至可以在另一台机器上生成它(例如在本地 PC 上),然后上传到您的 Web 服务器!
使用“调试”XML 序列化文件,我第一次能够看到 Catalog 的内容,我发现很多“垃圾”被存储,这既浪费内存/磁盘空间,又无用/不可搜索。随着这个版本的主要任务完成,似乎应该进行一些错误修复并添加一些“真正的搜索引擎”功能来清理 Catalog 的内容。
新功能和错误修复
FRAME 和 IFRAME 支持
CodeProject 会员le_mo_mo指出爬虫没有跟踪(和索引)框架内容。这只是对查找链接的正则表达式的一个小改动 - 之前支持 `A` 和 `AREA` 标签,所以将 `FRAME` 和 `IFRAME` 添加到模式中很简单。
foreach (Match match in Regex.Matches(htmlData
, @"(?<anchor><\s*(a|area|frame|iframe)\" +
@"s*(?:(?:\b\w+\b\s*(?:=\s*(?:""[^""]*""|'[^']" +
@"*'|[^""'<> ]+)\s*)?)*)?\s*>)"
, RegexOptions.IgnoreCase|RegexOptions.ExplicitCapture))
{
停用词
让我们从Google 对停用词的定义开始
Google 会忽略常用词和字符,例如“where”和“how”,以及某些单个数字和单个字母。这些词语很少有助于缩小搜索范围,并可能减慢搜索结果的速度。我们称它们为“停用词”。
基本前提是我们不想浪费目录空间来存储永远不会使用的数据,“停用词”的假设是您永远不会搜索“a in at I”之类的词,因为它们几乎出现在每个页面上,因此实际上无助于您找到任何内容!
这是来自 MIT 的基本定义以及一些有趣的统计数据和停用词思考,包括“经典”停用词的难题:用户是否应该能够搜索莎士比亚的独白“生存还是毁灭”?
Searcharoo 随附的停用词代码非常基础 - 它会剔除所有一个和两个字母的单词,加上
the, and, that, you, this, for, but, with, are, have, was, out, not
更复杂的实现留给其他人贡献(或者未来的版本, whichever comes first)。
词语标准化
我注意到单词经常被存储时,还包含了它们在源文本中相邻的任何标点符号。例如,Catalog 包含带有 Word 实例的 Files,例如
"People | people | people* | people |
这阻止了包含这些单词的页面在搜索时被返回,除非用户输入了完全相同的标点符号 - 在上面的例子中,搜索people只会返回一页,而您期望它返回所有四页。
Searcharoo 的前一个版本确实有一个“黑名单”标点符号 `[,./?;:()-=etc]`,但这还不够,因为我无法预测/预见到所有可能的标点符号。此外,它是使用 Trim() 方法实现的,该方法没有解析出单词内的标点符号(题外话:对括号词的处理在版本 3 中仍然不令人满意)。以下允许索引的字符的“白名单”确保 NO 标点符号被意外存储为单词的一部分。
key = System.Text.RegularExpressions.Regex.Replace(key, @"[^a-z0-9,.]"
, ""
, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
文化提示:这种移除标点符号的“白名单”方法非常以英语为中心,因为它将至少删除大多数欧洲语言中的一些字符,并且它将删除大多数亚洲语言内容中的所有内容。
如果您想使用 Searcharoo 处理非英语字符集,您应该找到上面那行代码,并将其替换为版本 2 中的这个“黑名单”。虽然它允许搜索更多字符,但结果更有可能被标点符号污染,从而降低可搜索性。
key = word.Trim
(' ','?','\"',',','\'',';',':','.','(',')','[',']','%','*','$','-').ToLower();
数字标准化
数字是词语标准化的一个特例:一些标点符号对于解释数字是必需的(例如小数点),然后将其转换为正确的数字。
虽然不完美,但这表示写成 0412-345-678 或 (04)123-45678 的电话号码都将被 Catalog 存储为 0412345678,因此搜索 0412-345-678 或 (04)123-45678 都会匹配这两个源文档。
private bool IsNumber (ref string word)
{
try
{
long number = Convert.ToInt64(word); //;int.Parse(word);
word = number.ToString();
return (word!=String.Empty);//true;
}
catch
{
return false;
}
}
Go words
阅读上面的词语标准化部分后,您可以看到如何对技术术语/短语(如 C# 或 C++)进行目录化和搜索是不可能的 - 非字母数字字符在被目录化之前就被过滤掉了。
为了避免这种情况,Searcharoo 允许创建一个“Go words”列表。“Go word”与“Stop word”相反:而不是阻止目录化,它会获得一个进入目录的通行证,绕过标准化和词干提取代码。
这种方法的弱点在于您必须提前知道所有用户可能搜索的 Go words。将来,您可能需要存储每个不成功的搜索词以供以后分析和扩展您的 Go word 列表。Go word 的实现非常简单
public bool IsGoWord (string word)
{
switch (word.ToLower())
{
case "c#":
case "vb.net":
case "asp.net":
return true;
break;
}
return false;
}
词干提取
“词干提取”最基本的解释是,它试图识别“相关的”单词,并在响应查询时返回它们。最简单的例子是复数:搜索“field”也应该找到“fields”的实例,反之亦然。更复杂的例子是“realize”和“realization”,“populate”和“population”——
此页面How a Search Engine Works包含关于词干提取和上述其他技术(如上面所述)的简要说明。
《Porter Stemming Algorithm》已经以 C# 类的形式存在,因此直接在 Searcharoo3 中使用(感谢 Martin Porter 的贡献和感谢!)。
对 Catalog 大小影响
上面的停用词、词干提取和标准化步骤都是为了“整理”Catalog 并希望能减小其大小/提高搜索速度。结果列于下文,针对我们的CIA World Factbook
source 800 个文件 52.6 MB |
原始 * | + 停用词 | + 词干提取 | +'白名单' 标准化 |
---|---|---|---|---|
唯一单词 | 30,415 | 30,068 | 26,560 | 26,050 |
XML 序列化 | 156 MB ^ | 149 MB | 138 MB | 136 MB |
二进制序列化 | 4.6 MB | 4.5 MB | 4.1 MB | 4.0 MB |
二进制与源数据的百分比 | 8.75% | 8.55% | 7.79%% | 7.60% |
* 黑名单标准化,代码中已注释掉,并在“文化提示”中提及
^ 使用 [Attributes]“压缩”XML 输出后为 49 MB
结果是单词数量减少了 14%,二进制文件大小减少了 13%(主要是由于添加了词干提取)。因为整个 Catalog 保存在内存中(在 Application Cache 中),保持小尺寸很重要 - 也许未来的版本可以持久化部分“工作副本”到磁盘,并允许爬取非常大的站点,但目前 Catalog 的大小似乎不到源数据大小的 10%。
…但是 UI 呢?
搜索用户界面也进行了一些改进
- 将搜索输入移入 Searcharoo.ascx 用户控件
- 在搜索词中添加与爬虫期间相同的词干提取、停用词和 Go word 解析
- 使用新的 ResultFile 类生成结果列表,以构建 DataSource 并绑定到 Repeater 控件
- 添加 PagedDataSource 和自定义分页链接,而不是一个长长的结果列表(感谢 Jim Harkin 的反馈/代码和 uberasp.net)
ResultFile 和 SortedList
在版本 2 中,输出结果非常粗糙:代码中充斥着 `Response.Write` 调用,使得重新格式化输出变得困难。Jim Harkins 发布了一些 Visual Basic 代码,下面将其转换为 C#。
// build each result row
foreach (object foundInFile in finalResultsArray.Keys)
{
// Create a ResultFile with it's own Rank
infile = new ResultFile ((File)foundInFile);
infile.Rank = (int)((DictionaryEntry)finalResultsArray[foundInFile]).Value;
sortrank = infile.Rank * -1000; // Assume not 'thousands' of results
if (output.Contains(sortrank) )
{ // rank exists - drop key index one number until it fits
for (int i = 1; i < 999; i++)
{
sortrank++;
if (!output.Contains (sortrank))
{
output.Add (sortrank, infile);
break;
}
}
} else {
output.Add(sortrank, infile);
}
sortrank = 0; // reset for next pass
}
Jim 的代码通过一个名为“sortrank”的新变量进行了一些技巧处理,试图将文件保持在“Searcharoo 排名”顺序,但输出的 `SortedList` 中具有唯一的键。如果返回了成千上万的结果,您可能会遇到麻烦……
PagedDataSource
一旦结果进入 SortedList,它们就被分配给一个 `PagedDataSource`,然后绑定到 Searcharoo3.aspx 上的 Repeater 控件。
SortedList output =
new SortedList (finalResultsArray.Count); // empty sorted list
...
pg.DataSource = output.GetValueList();
pg.AllowPaging = true;
pg.PageSize = Preferences.ResultsPerPage; // defaults to 10 10;
pg.CurrentPageIndex = Request.QueryString["page"]==null?0:
Convert.ToInt32(Request.QueryString["page"])-1;
SearchResults.DataSource = pg;
SearchResults.DataBind();
使其更容易以任何您喜欢的方式重新格式化结果列表!
<asp:Repeater id="SearchResults" runat="server">
<HeaderTemplate>
<p><%=NumberOfMatches%> results for <%=Matches%> took
<%=DisplayTime%></p>
</HeaderTemplate>
<ItemTemplate>
<a href="<%# DataBinder.Eval(Container.DataItem, "Url") %>"><b>
<%# DataBinder.Eval(Container.DataItem, "Title") %></b></a>
<a href="<%# DataBinder.Eval(Container.DataItem, "Url") %>"
target=\"_blank\" title="open in new window"
style="font-size:x-small">↑</a>
<font color=gray>(<%# DataBinder.Eval(Container.DataItem, "Rank") %>)
</font>
<br><%# DataBinder.Eval(Container.DataItem, "Description") %>...
<br><font color=green><%# DataBinder.Eval(Container.DataItem, "Url") %>
- <%# DataBinder.Eval(Container.DataItem, "Size") %>
bytes</font>
<font color=gray>-
<%# DataBinder.Eval(Container.DataItem, "CrawledDate") %></font><p>
</ItemTemplate>
<FooterTemplate>
<p><%=CreatePagerLinks(pg, Request.Url.ToString() )%></p>
</FooterTemplate>
</asp:Repeater>
不幸的是,页面链接是通过 `CreatePagerLinks` 中的嵌入式 `Response.Write` 调用生成的……也许这将在未来的版本中进行模板化……
未来...
如果您查看下面的日期,您会注意到版本 2 和 3 之间几乎间隔了一年半,所以讨论另一个“未来”版本可能听起来很乐观 - 但您永远不知道……
不幸的是,上面许多新功能都是针对英语语言的(尽管它们可以被禁用,以确保 Searcharoo 仍可用于其他语言的网站)。但是,在未来的版本中,我想尝试让代码在处理欧洲、亚洲和其他语言时更智能一些。
用户还可以输入布尔 OR 搜索,或者用引号“ ”组合术语,就像 Google、Yahoo 等一样,那会很好。
最后,为 HTML 以外的文档类型(主要是 PDF 等其他 Web 类型)建立索引对许多网站都会很有用。
ASP.NET 2.0
Searcharoo3 几乎未经修改地运行在 ASP.NET 2.0 上 - 只需从 @Page 属性中移除 `src="Searcharoo.cs"
`,然后将 *Searcharoo.cs* 文件移到 *App_Code* 目录中。
Visual Studio.NET 内部 Web 服务器警告:Searcharoo_VirtualRoot 设置(爬虫开始查找要索引的页面)默认为 *https:///*。VS.NET 的内部 Web 服务器选择一个随机端口运行,所以如果您使用它来测试 Searcharoo,您可能需要相应地设置此 web.config 值。