☰
C#实现多平台搜索与AI整合:从HTML到Markdown的自动化调研管道
2026/9/30 15:49:07 网站建设 项目流程

这几周在做技术方案调研的时候,我发现自己陷入了一个很原始的循环:开十几个浏览器标签页,挨个搜索、复制正文、清理广告和页脚、再手动整理出一份可读的纪要。数据一多,格式混乱,引用来源丢失,最后写出来的东西跟流水账一样。于是我用C#搭了一条“AI搜索增强”的完整流水线:多平台联网搜索 → 抓取HTML → 清洗并转换为结构化的Markdown → 调用AI整合输出最终结果。标题里的这些关键词正好对应这条链路里的每个环节,写这篇文章就是想把整条链路的实现细节和踩坑过程完整复盘一遍,给同样在做信息聚合、本地知识库、AI辅助调研的C#开发者一条可以照着落地的路线。

如果你是做.NET的,或者正想把手动查资料的流程自动化,又不想被一堆前端框架绑架,这篇文章应该能对得上胃口。下面每个环节我都给出可运行的C#思路、配置参数和实际效果,也包含了几个我最初没预料到的坑。

1. 从“查资料两小时”到“一条命令出报告”:这个项目的出发点

先说动机。我做技术选型和竞品分析时,信息来源通常分散在搜索引擎结果、官网文档、论坛讨论、GitHub README这些地方。每个页面都有大量干扰信息,尤其是导航栏、推荐阅读、cookie弹窗、页脚声明。如果直接拿这些HTML喂给大模型,效果很差,浪费token不说,AI还容易被噪声带偏。

所以我想要的东西很明确:输入一个主题词,程序自动完成搜索、抓取、正文提取、格式统一、AI总结,最后吐出一份结构化Markdown报告。这里面搜索是数据入口,HTML转Markdown是数据预处理,AI是最终的内容生成器。C#在整个链路里承担的是“编排者”和“数据管道”的角色。

有人可能会问:为什么不用Python?Python在爬虫和AI生态确实更强,但我的场景是给公司内部工具链做集成,团队主力栈就是.NET,桌面端、服务端、甚至一部分上位机系统都是C#。选C#可以让我们把这条搜索增强能力直接嵌入现有系统,不用额外维护一个Python微服务。另一个实际考量是.NET 8之后跨平台能力很成熟,HttpClient、System.Text.Json、后台任务调度都是现成的,写一个控制台应用或后台服务都很顺手。

整个系统的边界我也划得很清楚:

  • 搜索层:负责调用不同搜索源,规范化返回结果(标题、链接、摘要)。
  • 抓取层:负责下载目标页面HTML,处理编码、超时、重试。
  • 转换层:负责把HTML清洗成结构化Markdown。
  • 整合层:负责把多篇Markdown分块、去重、摘要,再交给AI生成最终报告。
  • 输出层:写文件、打印控制台、或通过Web API吐出结果。

每一层只做一件事,层与层之间用标准的数据类传递。这为后续换搜索源、换模型、加向量检索都留好了扩展位。下面我就按照这个分层结构,把每一层的实现细节和关键参数拆开讲。

2. 搜索层:C#怎么同时对接Bing、百度这些搜索源还不被踢下线

2.1 搜索源选型与结果规范化

多平台联网搜索的核心不在于“调用多少个搜索API”,而在于你定义了一套统一的结果模型,然后每个搜索源各自适配。我定义的工作类大致是这样的:

public sealed class SearchResultItem { public string Title { get; set; } = ""; public string Url { get; set; } = ""; public string Snippet { get; set; } = ""; public string Source { get; set; } = ""; // bing / baidu / custom }

有了这个模型之后,Bing、百度甚至内部站点搜索都只是不同实现而已。实际开发中我给每个搜索源写了一个Provider类,实现同一个ISearchProvider接口:

public interface ISearchProvider { string Name { get; } Task<IReadOnlyList<SearchResultItem>> SearchAsync(string query, int count, CancellationToken ct); }

Bing是我目前用得最顺的源。没有商用API Key的情况下,直接请求Bing的网页搜索地址然后用HtmlAgilityPack解析结果即可。Bing的HTML结构相对规整,结果项大多能通过li.b_algo选择器命中,标题、URL、摘要分别对应h2 a和.b_caption p。

百度的结构稍微复杂一些,但也不至于要上浏览器渲染。它的问题主要在链接是跳转链接,标题和URL分布在不同层级的容器里。我的做法是先用XPath定位到结果容器,再从中提取标题文本和真实跳转地址。注意,百度的跳转地址需要解码,里面有个url=参数,取出后再做一次URL解码,才能得到原始地址。这块很容易漏,漏了之后抓取层就会拿到一堆百度跳转中间页。

2.2 请求参数与反爬策略的实际调校

不管是哪个搜索源,直接用默认HttpClient去请求,大概率会被拦。我第一次跑的时候就吃了闭门羹,返回的页面里全是安全验证。后来总结出几个关键参数:

  • User-Agent必须伪装成真实浏览器,只设一个Mozilla/5.0是不够的,建议带上完整UA,包括Chrome/Safari版本。实测下来带完整UA的请求通过率明显更高。
  • Accept-Language要设置,至少填zh-CN,zh;q=0.9,en;q=0.8,不然有些搜索源会返回繁体或英文版页面。
  • 请求之间必须加延迟。我试过并发请求Bing,10个并发没事,50个并发直接触发验证码。最终的方案是每个搜索源独立配置每秒请求数,Bing控制在2-3个每秒,百度控制在1-2个每秒。慢一点但稳定。

真正让我折腾了一阵的是连接被重置的问题。当时用了一个开源REST客户端封装库,请求一多就抛异常,提示“无法将数据写入传输连接: 远程主机强迫关闭了”,这个字样在热搜词里也出现了。根因有两层:一是底层复用了同一个HttpClient连接,连接池里的连接被对端关闭后没有及时清理;二是单IP高频请求被服务端识别后主动断连。

解决方法是改动三层配置:

var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(3), PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30), MaxConnectionsPerServer = 10, ConnectTimeout = TimeSpan.FromSeconds(15), }; var client = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };

PooledConnectionLifetime尤其重要。默认情况下连接池会无限复用TCP连接,但对端可能早把这连接关了,客户端不知道,于是就会出现“远程主机强迫关闭”这类异常。给连接设置一个3分钟的寿命,过期自动重建,基本就稳定了。

另一个实战技巧是重试策略。我写了一个带指数退避的重试包裹器,遇到408、429、5xx时分别处理。429必须在重试时读取Retry-After响应头,否则怎么重试都白搭。以下是简化版:

private static async Task<HttpResponseMessage> SendWithRetryAsync( HttpClient client, HttpRequestMessage request, int maxRetries = 3) { HttpResponseMessage? response = null; for (var attempt = 0; attempt < maxRetries; attempt++) { response?.Dispose(); response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); if ((int)response.StatusCode != 429 && !response.IsSuccessStatusCode) { // 只有429才需要按Retry-After退避,其它错误直接返回让上层判断 return response; } if ((int)response.StatusCode == 429) { var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt)); await Task.Delay(retryAfter); } else { await Task.Delay(TimeSpan.FromMilliseconds(300 * (attempt + 1))); } } return response!; }

最后补充一个合规提醒:不管用什么搜索源,抓取频次必须控制,尽量不要拿这个管道去做大规模数据采集。我目前的使用场景是自己调研和内部知识库建设,请求量很小。合规这件事不值得赌。

3. HTML转结构化Markdown:解析、清洗、正文识别一条龙

3.1 为什么不能直接用现成的HTML转Markdown库

NuGet上确实有几个现成的HTML转Markdown库,比如ReverseMarkdown。用它做一个简单转换很轻松,几行代码就完事。但我在实际项目里很快就放弃了直接拿来用,原因是搜索结果页面的HTML质量参差不齐,直接转换会得到一堆Markdown格式的广告、导航、页脚、cookie提示,正文反而淹没在里面。

所以我的方案是“清洗 + 正文识别 + 转换”三层结构。现成库负责最后一层,前两层自己控制。这话说起来轻松,做的时候有不少门道。

清洗层做的是暴力移除和语义识别结合。协议上,多数HTML页面里script、style、noscript、iframe、svg、form这些标签的内容对正文毫无贡献,直接删。然后针对常见CMS主题,再按class或id的命名规则过滤。比如nav、footer、header、aside、breadcrumb、pagination、cookie-consent、newsletter等关键词,命中就整块移除。

这个阶段用HtmlAgilityPack非常合适。它不要求HTML完全合法,解析器容错能力强,而且支持XPath和LINQ双重操作。示例代码大致是这种风格:

var htmlDoc = new HtmlDocument(); htmlDoc.LoadHtml(rawHtml); var noiseTags = new[] { "script", "style", "noscript", "iframe", "svg", "form", "nav", "footer", "header", "aside" }; foreach (var tag in noiseTags) { var nodes = htmlDoc.DocumentNode.SelectNodes($"//{tag}"); if (nodes != null) { foreach (var node in nodes.ToList()) { node.Remove(); } } }

3.2 正文识别:用“文本密度”而不是“标签规则”

清洗完噪音之后,页面里仍然会有侧边栏、相关推荐、评论区这类东西。它们既不是广告也不是页脚,但也不是正文。这时候就需要做正文识别。业内比较经典的做法是文本密度打分——遍历页面里的块级节点,统计节点内的文本长度、链接数量,用文本字符数除以链接字符数得到一个密度值。正文节点通常文本密度高、链接密度低;而侧边栏推荐内容恰恰相反。

我用了一个简化但效果不错的版本:

  • 遍历所有p、div、article、section节点。
  • 统计每个节点的文本总长度textLength和包含的链接文本长度linkTextLength。
  • 计算得分:score = textLength - linkTextLength * 2,链接文本有惩罚系数。
  • 得分大于某个阈值(经验值在200-500之间)视为候选正文节点。
  • 对候选节点做聚合,把相邻或嵌套的节点合并成正文区块。

这套逻辑在博客、文档站、新闻页上的表现很好,能稳定去掉“推荐文章”和“相关阅读”。但面对单页应用或需要JS渲染的站点会失灵,这类站点返回的HTML本身就几乎没有正文文本,HTML转Markdown这一步无论怎么优化都拿不到内容。遇到这种情况我后续有两条路,一是接Playwright做浏览器渲染,二是干脆跳过这个结果源。多数情况下选择后者,因为做调研时来源有很多,不需要为某一个动态页面破坏整体架构。

3.3 表格、代码块、标题层级的转换细节

正文区块提取出来之后才是真正转换的阶段。这一步我建议写自己的转换器,而不是把所有工作都交给ReverseMarkdown,因为有几个格式细节必须自己控制。

第一个细节是表格。HTML表格结构复杂,特别是带colspan、rowspan的,直接转Markdown很容易错位。Markdown的表格本身只支持二维矩阵,不支持单元格合并。我的做法是先解析出二维矩阵,如果是规则矩阵就直接输出管道表格,如果存在合并单元格,就把合并信息降级成单元格内的文字说明。对于搜索结果里常见的参数对比表格,规则矩阵占大多数,实际效果挺满意。

管道表格里有一个容易踩的坑:单元格内容如果包含竖线|,会导致表格列错乱。所以我在拼接表格前会把单元格里的英文竖线统一替换成全角竖线|或者转义成\|。同理,表格单元格里的换行符需要替换成<br>或空格,否则Markdown渲染器会直接断行。

第二个细节是代码块。pre > code结构在HTML里很常见,转成Markdown时我要识别语言类型。很多Markdown阅读器都支持代码高亮,语言标注往往能从class属性里挖出来。比如class="language-csharp"或class="brush: csharp;",解析出来填入围栏代码块的起始标记:

var langMatch = Regex.Match(codeClass, "(?:language-|brush:)\\s*(\\w+)"); var lang = langMatch.Success ? langMatch.Groups[1].Value : ""; builder.AppendLine("```" + lang);

如果识别不到语言,就只输出```不带标注,这并不影响阅读。

第三个细节是标题层级降级。搜索结果里的页面常见问题是标题跳跃,一会儿是h1,一会儿直接跳到h3,中间缺h2。Markdown不需要严格遵守层级递进,但从可读性出发,我写了一个简单的标题归一化:采集页面里出现的最大标题级别,统一往上平移,保证h1始终作为文档大标题,h2作为二级章节。平移之后文档结构明显清爽很多。

第四个细节是相对路径和HTML实体。页面里的图片和链接很多是相对路径,必须基于baseUri拼出绝对地址,不然Markdown里的链接是坏的。HTML实体也要统一解码,比如&amp;要还原成&,&#39;还原成'。用System.Net.WebUtility.HtmlDecode()处理即可,这个坑不大,但漏掉之后会出现满屏乱码。

最终转换器输出的格式我做了个对照表:

HTML原始内容转换后的Markdown
<h2>标题</h2>## 标题
<table><tr><td>A</td></tr></table>| A |
<pre><code>var x=1;</code></pre>``` \nvar x=1; \n
<blockquote>引言</blockquote>> 引言
<a href="/doc">说明</a>[说明](绝对地址)

做完这三层之后,原来杂乱的HTML页面变成了干净且可读的Markdown,这一步直接决定了后面AI整合的质量,值得花最多时间打磨。

4. AI整合:怎么让大模型把十几页内容拧成一份报告

4.1 上下文窗口限制下的分块策略

把多篇Markdown一股脑拼起来塞给大模型是最直接的方案,但也是最蠢的。即便模型支持128K上下文,十几个页面加起来也可能超过窗口长度;就算不超,长文本里混着大量重复信息也会稀释注意力,导致最终输出泛泛而谈。

我的做法是两阶段摘要:第一阶段,对每一篇Markdown单独做“页面级要点提取”;第二阶段,把提取出的要点汇总,再做一次整合。第二阶段用的是结构化Markdown输出,这样AI只跟高质量内容打交道,上下文占用小很多,输出质量也稳定。

页面级要点提取的提示词,我反复调整过几个版本,核心思路是让它当资料整理员而不是写作文。目前的版本是这样:

你是一位资深资料整理员。下面是一篇网页转成的Markdown内容。 请提取出与主题相关的关键信息,要求: 1. 保留数据、参数、结论、代码片段,删除营销、寒暄、废话; 2. 每条要点控制在80字以内; 3. 如果原文包含表格,转换为简洁的列表描述; 4. 用无序列表输出,保持客观,不要改写原文含义。

这里有个至关重要的参数:temperature要调低,我通常设置为0.2到0.3。高温会让模型自由发挥,把原文的“推荐算法服务”改写成“AI赋能个性化推荐系统”,这完全违背了资料摘要的初衷。低温度下输出更贴原文,也更稳定。

4.2 去重与来源保留

多平台搜索的结果里,同一个信息点经常出现在两三个页面里。AI整合时如果不去重,报告里就会反复出现相似描述,显得很啰嗦。我在喂给AI之前先做一层粗去重,方法是提取每一段内容的前50个字符做哈希,然后计算两两相似度。简单说,如果两个页面在开头文字和关键数据上高度相似,就只保留来源权威性较高的那一个。

这里“权威性”我用一个简单的评分模型:官方域名(.gov、.edu、.org、以及大厂官网)加1分;搜索结果里排序靠前的加0.5分;GitHub文档加1分;其余内容不加分。这个评分不严谨,但在工程上完全够用,诗歌级排序逻辑反而会增加维护成本。

最终输出时,我会在报告里为每条核心结论附带来源URL。这个信息来自搜索层和抓取层的传递,到AI整合层时作为元数据跟在段落后面。实际生成的报告文件里你会看到类似这样的结构:

## 2. 核心功能与架构 - 支持多平台联网搜索,统一搜索结果模型(来源:https://...) - 内置HTML清洗与Markdown转换管道(来源:https://...)

这样做的好处非常明显,后续想验证某条信息时,点开链接就能直达原文,不用重新搜索。

4.3 输出格式约束与成本控制

为了让AI输出的报告格式统一,我在整合阶段的提示词里插入了一段输出结构要求:

请将以下多篇网页摘要整合成一份Markdown调研报告,包含: - 开头用一段话概括主题核心结论; - 正文按主题分二级标题,每个章节用无序列表罗列要点; - 关键数据、参数、代码尽量保留; - 每条要点后面用"(来源:URL)"标注来源; - 不要输出寒暄语,不要输出"以下是..."之类的开场白。

之所以明确要求“不要输出寒暄语”,是因为模型经常自作主张加“好的,这是你的报告”这类废话。把约束写进提示词之后,输出干净很多。

成本控制这块,我的经验是两级模型策略:页面级要点提取用便宜的小模型就够,比如带上长上下文的轻量模型;最终的整合阶段再用强一点的大模型。有时候页面比较多,小模型摘要会丢细节,所以我在页面级阶段会把摘要限制放宽一些,宁可多输出几行,也不能让关键参数丢了。

如果对成本特别敏感,还有一条替代路径:先用本地模型做页面级摘要,再把摘要交给在线大模型整合。本地模型跑摘要不需要花钱,而且页面级提取对语义理解要求不算高,开源模型完全能胜任。这条方案我测试下来只是速度慢一些,质量上差距不大。

5. 跑通全流程之后:实测效果、翻车记录和可以继续扩展的方向

5.1 一次真实搜索的产出示例

我用“C# 多平台搜索 HTML转Markdown”这个主题跑了一次完整流程,四个搜索源各自搜索,去重后拿到大概6个有效页面。经过清洗转换后,每个页面变成平均3000字左右的Markdown,再经过两级AI整合,最终报告成型。整个过程从发起搜索到生成文件约40秒,其中大头在网络请求和AI调用上。

输出报告的核心部分长这样:

> 核心结论:在当前技术栈下,用C#实现多平台联网搜索并整合AI生成结构化Markdown是可行的, > 关键在于搜索请求的频率控制、HTML正文识别、以及两级摘要策略。 ## 1. 搜索层要点 - 多平台搜索需抽象统一结果模型,Bing、百度等分别适配(来源:...) - HttpClient必须配置连接池生命周期,否则高频请求出现连接中断(来源:...) - 请求延迟控制在1-3 QPS,防止触发验证码(来源:...) ## 2. HTML转换要点 - HtmlAgilityPack可用于HTML解析与噪音移除(来源:...) - 文本密度算法可有效识别正文区块(来源:...) - 表格转换需处理竖线转义和合并单元格降级(来源:...)

这份报告甚至可以直接拿去做团队内部的技术评审,这是手动整理很难达到的效率。

5.2 我踩过的几个具体坑

编码识别是第一个坑。很多老站点页面是GB2312编码,HttpClient默认按UTF-8解码,结果HTML里中文全是乱码,正文识别自然完全失败。解决方式是在.NET里注册代码页编码提供程序:

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);

然后在读取响应内容时先从ContentType头或HTML里的charset声明里解析编码,解析不到就按UTF-8处理。只加这一行RegisterProvider就能解决绝大多数中文站点的编码问题。

第二个坑是动态渲染站点。我一开始把Bing结果里的链接全部无脑抓取,结果有五分之一左右的网页返回的HTML几乎没有任何正文,全是空容器和初始化脚本。后来我用一个简单的检测规则:清洗标签后,如果正文文本长度小于200字符,就丢弃这个页面。这个规则救了很多次。如果你确实需要抓取这类动态页面,只能上Playwright做无头浏览器渲染,但那是另一个量级的复杂度,除非目标站点非用不可,否则不建议一上来就引这个依赖。

第三个坑是“Access violation c0000005”这类原生互操作崩溃。这个热搜词看起来跟我的项目无关,但如果你后续和我一样打算把这条管道嵌到桌面工具里,并且接入一些C++原生库做本地向量检索或OCR预处理,就会碰到。C#调用C++库时的Access Violation,绝大多数情况是P/Invoke签名不对,比如结构体布局没对齐、委托回调生命周期被GC回收。我的建议是尽量用LibraryImport取代旧的DllImport,并且所有跨边界的结构体都要标记[StructLayout(LayoutKind.Sequential)]。这类崩溃不会出现在托管代码的异常堆栈里,非常难排查,我把它们挡在架构层面,尽量不让C#和原生代码深度耦合。

5.3 现在的扩展方向

这条流水线做完以后,我并没有把它当成一个一次性工具,而是继续往几个方向扩展。

第一个方向是加向量检索。把每篇页面的Markdown切块后做Embedding入库,下次搜索时先向量召回再AI整合。这样就把“每次都重新搜索网页”变成了“先查本地库再补网络”,速度和成本都会下降很多。C#这边可以用Microsoft.ML或接入现成的向量数据库SDK。

第二个方向是输出格式扩展。现在已经能用AI生成Markdown报告,热搜词里有“Markdown表格转换Excel”“Markdown转Word”这类需求,顺着这个思路加一个导出层,把Markdown表格解析出来写入Excel文件,或者调用Pandoc把Markdown转成Word,就能直接当交付文档用。这个扩展跟现有架构衔接得很自然,只要在输出层加渲染器即可。

第三个方向是接入更多的垂直搜索源。像Stack Overflow、GitHub、官方文档站都可以做成单独的Provider,它们有各自的搜索接口,返回的是JSON或结构化结果,抓取质量比通用搜索引擎高得多。多平台的价值就在这里,同一个主题在不同源里得到的角度差异很大,AI整合时能写出更全面的结论。这个架构里增加Provider的成本不高,收益却很直接。

最后再说一点个人体会:这类工具的价值不在于把某个环节做到极致,而在于“整条链路跑稳定”。搜索反爬、HTML垃圾信息、模型输出不稳定,任何一环抽风都可能导致最终报告没法用。我在开发过程中花时间最多的不是AI调用,而是HTML清洗和正文识别那层。数据干净了,后面所有环节都省心;数据脏,AI再强也救不回来。所以如果你也想搭这么一套东西,我建议优先把“HTML转结构化Markdown”这个环节砸实,它会成为整个系统最值得的一笔投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询