简介《邮箱获取器C#源码》是一份面向C#初学者的完整示例程序核心功能是利用正则表达式从网页HTML、文本文件或其他数据源中批量提取符合格式的电子邮件地址适合希望系统掌握字符串匹配与文本抽取技能的开发者。压缩包共28个文件整体约172KB包含6个C#源文件、4个可执行程序、2个资源文件、2个调试符号文件、使用说明及正则表达式API帮助文档先运行exe即可观察提取效果再打开cs源码逐步跟踪调试。目前已有110人学习下载。源码完整演示了邮箱提取的关键流程先定义常见邮箱格式的正则模式如[\w.-][\w.-]\.\w再调用System.Text.RegularExpressions命名空间下的Regex类执行匹配遍历所有结果并去重存储同时涵盖读取网络或本地数据源、异常处理等常用操作。通过研读和修改这份源码可以加深对正则表达式元字符、量词、分组与捕获等核心概念的理解掌握C#中正则类库的实际用法并将这些技能迁移到网络爬虫、日志分析、数据清洗等真实应用场景中是一份兼具学习与参考价值的入门资料。1. 邮箱获取器C#源码不是爬虫是一台解析清洗机第一次看到“邮箱获取器C#源码”这个标题很多人下意识会想到批量爬邮箱、发营销邮件的那类脚本。实际上我把它当作一个“网页文本解析器”来用——给一批允许访问的页面把散落在各处、写法五花八门的邮箱地址统一捞出来过滤掉垃圾去重后导出成表格。这个需求常见于内部站点通讯录整理、客户公开信息汇总、报表数据补全。工作流里最花时间的根本不是发请求而是处理那些“长得像邮箱但根本不是”的数据、编码乱码、重复地址和手工复制漏掉的行。如果你正要做一个能重复运行、结果可控的C#工具这套源码的思路可以直接抄HttpClient抓页面、正则抽取、四层过滤、线程安全去重、CSV输出。2. 抓取与解析方案HttpClient怎么拿页面正则怎么不抓废拿到需求之后第一件事不是写代码而是确认页面类型。因为同一批URL里既有静态页也有动态页抓取策略完全不同全上无头浏览器只会让程序慢到没法用而且不稳定。2.1 静态页面用HttpClient动态页面另想办法常见做法是直接用HttpClient抓HTML。它的优势是异步写起来顺手、内置了 gzip 压缩和解压支持而且不用引入第三方依赖。给HttpClient设置一个默认超时避免个别页面卡死整条链路。public class HttpPageFetcher { private readonly HttpClient _client; public HttpPageFetcher(int timeoutSeconds 15) { var handler new HttpClientHandler { // 很多站点的HTML是gzip压缩传输的不开解压拿回来就是乱码 AutomaticDecompression DecompressionMethods.GZip | DecompressionMethods.Deflate }; _client new HttpClient(handler) { Timeout TimeSpan.FromSeconds(timeoutSeconds) }; _client.DefaultRequestHeaders.Add(User-Agent, Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36); _client.DefaultRequestHeaders.Add(Accept, text/html,application/xhtmlxml,*/*;q0.8); } public async Taskstring GetTextAsync(string url, CancellationToken ct default) { byte[] htmlBytes await _client.GetByteArrayAsync(url).ConfigureAwait(false); return DecodeHtml(htmlBytes); } }这段代码里最关键的是handler.AutomaticDecompression。我第一次写抓取工具时拿回来的页面中文字符全是乱码排查半天发现是没解压、不是编码问题。ConfigureAwait(false)是为了避免在UI线程上死锁控制台程序里写不写都行但写成习惯没坏处。DecodeHtml我一般简化处理先用Encoding.UTF8.GetString解再检查是否有大面积的UFFFD替换字符如果有回退到GBK重新解。正式工程里可以看Content-Type头里的charset但响应头偶尔会骗人兜底检测更稳。动态页面的判断方法很直接浏览器里打开目标页面右键查看源代码搜索有没有。如果源代码里根本没有邮箱只有一段空壳HTML加一堆JS那就是JS在运行时把数据渲染出来的。这种情况先别急着上无头浏览器优先找页面背后的JSON接口很多站点把数据放在window.__INITIAL_STATE__或者某个.json请求里。无头浏览器是最后手段内存占用大、运行慢而且每次升级浏览器都要同步改驱动版本。2.2 三种正则写法宽松、标准和防尾标页面抓下来之后解析邮箱的核心是正则。但正则不是越复杂越好一百多字符的RFC 822完整版本能读的人没几个维护成本也高。我一般用下面这张表做选型档位正则表达式误报率适用场景宽松\b[\w.-][\w.-]\.[A-Za-z]{2,}\b高先看看页面里一共多少疑似邮箱标准\b[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}\b中默认推荐严格标准正则 域名后缀白名单低只收已知常用域名实现上就用Regex.Matches注意加上RegexOptions.Compiled文本量大时能省不少时间public static class EmailParser { private static readonly Regex EmailRegex new Regex( \b[A-Za-z0-9._%-][A-Za-z0-9.-]\.[A-Za-z]{2,}\b, RegexOptions.Compiled | RegexOptions.CultureInvariant); public static IEnumerablestring ExtractEmails(string html) { foreach (Match m in EmailRegex.Matches(html)) { yield return m.Value; } } }这里有个容易被忽略的点正则里的\b边界很重要它保证匹配到的是一段独立文本否则123abcsite.com会把前面的123也卷进来。另一个坑是字符串末尾的点号mesite.com.这种写法在自然语言里是句子结束的句号正则依然会抓成mesite.com.后面接过滤链专门处理掉。还有一类页面会把邮箱里的写成at比如me at site dot com或者用 HTML 实体#64;隐藏。实体问题可以用System.Net.WebUtility.HtmlDecode解决at那种就只能写针对性的规则如果页面里大量出现才值得专门做一层转换。2.3 请求间隔、超时与重试的参数设计抓取一群页面参数设置不对要么慢死要么把目标站点压垮。我通常按下面这组参数起步参数推荐值说明单请求超时15 秒超时说明页面挂了别死等重试次数3超过3次直接记失败不拖整体重试等待500ms × 重试次数简单指数退避第2次等1秒同域名间隔200~500ms防止短时间请求太密集最大并发4~8用SemaphoreSlim限流“同域名间隔”和“最大并发”是给公共域名页面用的保守值。内网文档站可以把并发调高到16间隔降到50ms但前提是目标服务没有反爬限制。调参的判断标准是失败率跑完一批失败率高于5%优先降并发而不是加重试因为高频重试会雪上加霜。提示抓取前先确认目标站点的访问条款只处理你有权访问的页面。技术是中性的用途需要自己掂量。3. 数据入库前先过三关过滤链、布隆过滤器与并发去重正则把邮箱捞出来只是第一步。从真实页面里提取的结果废数据比例高得惊人我见过一次跑完3000个“邮箱”一大半是图片文件名和伪装的假地址。所以抓回来的每个字符串都要过一遍过滤、去重再做后续加工。3.1 过滤链把假邮箱挡在门外正则负责“宽进”过滤链负责“严出”。C#里实现一串短路判断即可public static class EmailValidator { private static readonly HashSetstring BlockedDomains new HashSetstring { example.com, example.org, test.com, domain.com }; public static bool IsAcceptable(string email) { if (string.IsNullOrWhiteSpace(email)) return false; if (email.Length 254) return false; if (email.Contains(..)) return false; if (email.StartsWith(.) || email.EndsWith(.)) return false; if (email.StartsWith(-) || email.EndsWith(-)) return false; if (email.Split().Length ! 2) return false; string domain email.Split()[1]; if (BlockedDomains.Contains(domain)) return false; if (domain.EndsWith(.png) || domain.EndsWith(.jpg) || domain.EndsWith(.gif)) return false; if (domain.All(char.IsDigit)) return false; // 纯数字域名基本是占位符 return true; } }判断email.Split().Length ! 2很关键正常邮箱只能有一个字符串里出现两个基本是把URL或者奇怪文本误抓进来了。图片域名后缀那条也是经验之谈——页面里经常出现logo2x.png这种字符串正则看到2x.png误判成域名部分过滤掉之后就不会进结果集。BlockedDomains这份黑名单不会一劳永逸。跑完一批数据手工扫一遍高频域名把明显是假地址的加进去比优化正则性价比高得多。这行当一半是玄学真正决定数据质量的不全是正则多完美而是过滤规则有没有贴合你抓的这批页面。3.2 哈希去重还是布隆过滤器按数据量选去重是最容易被低估的环节。几千条数据时HashSetstring足够代码简单、零误判。但如果一次任务要处理几十万条、上百万条邮箱地址内存就开始紧张了每条字符串平均占40字节左右再加哈希表的开销100万条轻松吃掉几十兆内存。这时布隆过滤器能大大节省内存但要接受它的特性有一定概率把没见过的地址误判为已存在从而丢弃。public sealed class EmailBloomFilter { private readonly BitArray _bits; private readonly int _k; private readonly int _m; public EmailBloomFilter(int expectedCount, double errorRate 0.01) { _m (int)Math.Ceiling(-expectedCount * Math.Log(errorRate) / Math.Pow(Math.Log(2), 2)); _k (int)Math.Ceiling(_m / (double)expectedCount * Math.Log(2)); _bits new BitArray(_m); } public void Add(string email) { foreach (int idx in HashPositions(email)) _bits[idx] true; } public bool Contains(string email) { foreach (int idx in HashPositions(email)) { if (!_bits[idx]) return false; } return true; } private IEnumerableint HashPositions(string email) { uint h1 (uint)email.GetHashCode(StringComparison.Ordinal); uint h2 (uint)(h1 17) ^ (uint)(h1 15); for (int i 0; i _k; i) { yield return (int)((h1 i * h2) % (uint)_m); } } }代码里用了双哈希派生出k个位置比单独算k次不同的哈希快。expectedCount按你预估的最大条数填errorRate默认1%。同样100万条数据布隆过滤器大概占用1.2MB左右的位数组比HashSet小一个数量级以上。这里要说清楚边界上面的实现只针对单进程、单次运行。如果任务跨天、跨进程或者要求“一个都不能丢”那就别用布隆过滤器改成走数据库的唯一索引或者把已见地址持久化到磁盘。布隆过滤器适合“预估量大、允许个别漏判”的场景取舍要提前定好。3.3 并发抓取时的去重竞争Check和Add不是一回事用了HashSet又开多线程就会撞到经典的重复问题。先检查再添加这两步在并发环境下不是原子的两个线程可能同时发现“不在集合里”然后各自添加结果重复。换成线程安全的ConcurrentDictionary解决一步到位public sealed class ConcurrentEmailStore { private readonly ConcurrentDictionarystring, byte _seen new ConcurrentDictionarystring, byte(StringComparer.OrdinalIgnoreCase); public bool TryAdd(string email) { return _seen.TryAdd(email, 0); } public int Count _seen.Count; public IEnumerablestring Snapshot() { return _seen.Keys.OrderBy(k k, StringComparer.Ordinal); } }用TryAdd的返回值判断“这条是不是新数据”语义上就是原子的。StringComparer.OrdinalIgnoreCase让MeSite.com和mesite.com不会同时保留两条这在小规模数据上看着无所谓真要给业务系统供数据时大小写重复会让下游报表数据很难看。并发去重测试时有个经验别只看最终总数要看每次运行结果的稳定度。同一批URL连续跑两遍第一遍3000条、第二遍3050条多半不是玄学是某个竞争窗口没堵上。4. 可维护的工程骨架目录分层、主流程与CSV导出工具虽小也值得按工程化的方式组织。源码里最忌讳的是把抓取、解析、去重、导出全写进一个文件里改一个正则就得在几百行代码里翻半天。4.1 四个模块的职责边界我一般按数据流切四个模块每个模块只做一件事接口定义如下public interface IPageFetcher { Taskstring GetTextAsync(string url, CancellationToken ct default); } public interface IEmailParser { IEnumerablestring ParseEmails(string html); } public interface IEmailStore { bool TryAdd(string email); int Count { get; } } public interface IEmailExporter { Task ExportAsync(IEnumerablestring emails, string outputPath, CancellationToken ct default); }接口互相不依赖谁改谁不影响别人。比如今天用HttpClient抓页面明天想换成无头浏览器只换IPageFetcher的实现类今天导出CSV后天要写Excel只换IEmailExporter。这是很多随手写的小工具最缺的东西可替换性。没有这层抽象功能迭代时会越改越乱。4.2 主流程把四段串成一条流水线主程序的职责是调度不写业务逻辑。从文件读URL列表循环抓取解析存入去重仓库最后导出static async Taskint Main(string[] args) { if (args.Length 1) { Console.WriteLine(用法: EmailHarvester url列表文件 [输出.csv]); return 1; } string outputPath args.Length 1 ? args[1] : emails.csv; string[] urls await File.ReadAllLinesAsync(args[0]); IPageFetcher fetcher new HttpPageFetcher(); IEmailStore store new ConcurrentEmailStore(); foreach (string url in urls) { if (string.IsNullOrWhiteSpace(url)) continue; try { string html await fetcher.GetTextAsync(url); int before store.Count; foreach (string email in EmailParser.ExtractEmails(html)) { store.TryAdd(email); } int added store.Count - before; Console.WriteLine(${url}: 命中 {added} 条新邮箱总计 {store.Count}); } catch (Exception ex) { Console.Error.WriteLine(${url} 失败: {ex.Message}); } } IEmailExporter exporter new CsvEmailExporter(); await exporter.ExportAsync(store.Snapshot(), outputPath); return 0; }这里刻意先把foreach串行跑把逻辑讲清楚。等确认整套流程没问题再把外层循环改成Parallel.ForEachAsync或SemaphoreSlim限流的并发版本。串行版本的好处是日志顺序和URL顺序一致出问题好定位是哪条URL导致。主流程里有三个参数值得留意读取URL列表时过滤空行避免HttpClient收到空字符串直接抛异常每次抓完先记录store.Count再统计增量能直接看到每条URL的产出数据跑完一眼看出哪些页面是空壳所有异常只写Error.WriteLine不中断整体循环一批1000个URL里挂掉几个是常态不能因为一条挂了全盘重来。4.3 CSV导出编码、转义与内存控制导出CSV看起来简单实际坑很多。常见做法是逐行写顺便加UTF-8 BOM这样Excel打开中文才不乱码public sealed class CsvEmailExporter : IEmailExporter { public async Task ExportAsync(IEnumerablestring emails, string outputPath, CancellationToken ct default) { // UTF8Encoding(true) 是带BOM的UTF-8Excel直接打开不乱码 await using var writer new StreamWriter(outputPath, false, new UTF8Encoding(true)); await writer.WriteLineAsync(email); foreach (string email in emails) { ct.ThrowIfCancellationRequested(); await writer.WriteLineAsync(CsvEscape(email)); } } private static string CsvEscape(string value) { if (value.Contains(,) || value.Contains()) { return $\{value.Replace(\, \\)}\; } return value; } }逐行写入而不是把所有行拼成一个StringBuilder再写是为了控制内存。几十万条数据拼成一个大字符串一次性写入要吃掉大量内存逐行写只是多几次IO调用StreamWriter有内部缓冲性能可以接受。CsvEscape处理的是字段里含有逗号或双引号的情况。邮箱地址理论上不会含逗号但抓取到的垃圾字符串偶尔混进来转义函数兜住这一层导出文件就不会被解析错列。如果你的下游还要加工这些数据保留原始大小写做不去重导出是有价值的这时只要换一个不区分大小写的存储实现即可。5. 避坑指南5个让获取器翻车的现场这里挑五个我实际撞过、也看别人反复踩的坑全是现象、原因和对策的三段式推演照着排查能少走很多弯路。5.1 抓回来的网页乱码邮箱全变成问号现象某些页面抓回来中文变???或锟斤拷邮箱地址里的英文字符倒还正常但夹杂的说明文本全废了。原因一类是忘了开启AutomaticDecompression服务器返回gzip压缩的HTML直接按字节解当然乱另一类是页面用的不是UTF-8而是GBK或GB2312代码里写死UTF-8解码就会炸。解决先确认HttpClientHandler开启了自动解压再在解码函数里加编码检测从Content-Type头的charset参数取值取不到就检查HTML里meta charset标签再兜底尝试GBK。5.2 废地址一大堆正则收进来一堆logo2x.png现象跑完一轮结果集里混入大量图片文件名、CSS类名拼接出来的假地址甚至还有media screen这种CSS语法被正则抓成邮箱。原因正则只认格式不认语义.png、.jpg这种尾巴在格式上完全合法所以会被录取。解决过滤链补上图片域名后缀判断同时维护一份高频假域名黑名单如果确定抓的是某个站群直接再加一层域名白名单只收目标站点后缀的邮箱。废地址的比例能从30%压到2%以内。5.3 页面明明有邮箱正则一查一个都没有现象浏览器里能看到contactxxx.com但程序跑完0命中。原因大概率是动态渲染页面初始HTML里没有邮箱文本全靠JS异步请求数据再填充也有可能是邮箱被HTML实体拆写比如me#64;site.com。解决先右键查看源代码直接搜确认初始HTML存在邮箱再谈正则不存在就找页面背后的接口浏览器开发者工具的网络面板里看XHR请求多半能找到JSON数据源。无头浏览器能解但优先级应该排在最后。实体问题用WebUtility.HtmlDecode还原再跑正则。5.4 一开多线程去重结果反而变多现象串行跑3000条改成并行8线程跑出3500条重复率不但没降反而上升。原因HashSet.ContainsHashSet.Add是两步操作并发下多个线程同时判断“不存在”然后同时Add重复就产生了。解决换用ConcurrentDictionary.TryAdd利用它的原子性一次搞定判断和写入需要排序输出时再对Keys做一次稳定排序。这里有个隐蔽的延伸如果存储层将来换成Redis或数据库也必须用SETNX这类原子命令否则同样的坑换个壳又出现。5.5 导出CSVExcel打开全乱码现象CSV文件用记事本打开正常Excel打开中文全乱邮箱列偶尔错位。原因没写BOM头的UTF-8Excel默认用ANSI解码字段内如果含逗号或双引号不做转义也会导致列错位。解决用new UTF8Encoding(true)创建StreamWriter写入字段同时给每个字段做CSV转义。顺序上先转义再写行别把顺序搞反。转义规则简单含逗号或双引号就给整个字段加双引号内部的引号再替换成双引号。6. 回灌自检每次改动后验证漏抓率的快速办法正则和过滤链改到后面最怕的一件事是修复了一个漏抓场景却引入了新的误杀。肉眼没法把几十种页面形态全部检查一遍所以我给自己写了一个回灌自检工具。思路是准备一段内置了20种不同邮箱形态的测试HTML里面故意混入CSS、图片文件名、两个的字符串、带HTML实体的邮箱每次改完解析器先跑一遍看命中率和误收率低于阈值才上线。public static class SelfTestHarness { private static readonly string TestHtml html body p联系aliceexample.com/p p带加号的boblistsite.org/p p下划线carol_wangsub.domain.cn/p p图片logo2x.png/p pCSSmedia screen { body { color: #fff; } }/p p双badsite.com/p p实体dave#64;site.com 和 eve#64;test.net/p /body /html ; public static (int hit, int total) Run() { string decoded WebUtility.HtmlDecode(TestHtml); var found EmailParser.ExtractEmails(decoded) .Where(EmailValidator.IsAcceptable) .ToHashSet(StringComparer.OrdinalIgnoreCase); string[] expected { aliceexample.com, boblistsite.org, carol_wangsub.domain.cn, davesite.com, evetest.net }; int hit expected.Count(found.Contains); return (hit, expected.Length); } }我一般给自己定两条线正常邮箱的命中率必须100%废地址的误收数必须为0。每次改动解析器dotnet run先自检一次再跑真实数据成本只有几百毫秒值得养成习惯。这个自检还能当“回归测试”长期复用随着业务形态变化把新遇到的地址形态往TestHtml里加越积越全。后来有一次我改了过滤链把“以数字结尾的域名”整个ban了自检立刻暴露出漏抓才发现伪邮箱过滤写得太宽差点误伤正常数据。从那之后这步自检就是我每次改动后的固定动作没跑过自检的代码不上正式任务。这套自己动手整理的方案最大的价值就是每一个环节都清楚了会在哪里坏希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?