【微实验】携程评论C#爬取实战:突破JavaScript动态加载与反爬虫机制

在数据采集领域,爬取旅游网站的用户评论对于市场分析、舆情监测和产品优化具有重要意义。本文将以携程网的景点评论爬取为例,深入探讨如何突破现代网站的反爬虫机制,特别是针对JavaScript动态加载内容的处理。

一、项目背景与挑战

携程作为国内领先的在线旅游服务平台,其用户评论数据具有很高的商业价值。然而,现代网站普遍采用了各种反爬虫技术:

  1. JavaScript动态渲染 - 评论内容通过AJAX异步加载

  2. 反自动化检测 - 识别Selenium等自动化工具

  3. 动态参数验证 - 请求需要特定的时间戳或令牌

  4. 元素交互障碍 - 分页按钮被其他元素遮挡

二、技术方案演进

2.1 初始尝试:传统HTTP请求

最初我们尝试使用HttpClient直接发送请求,但很快发现携程的评论数据并非静态HTML,而是通过JavaScript动态生成的。传统的分页参数(如pagenow)在直接请求时无法获取到真实的评论数据。

关键发现:现代单页应用(SPA)大多通过API接口获取数据,但携程的API端点经过混淆和加密,直接调用困难重重。

2.2 突破方向:Selenium自动化

我们转向使用Selenium WebDriver模拟真实用户行为,但这带来了新的挑战:

cs 复制代码
// 反检测配置示例
options.AddArgument("--disable-blink-features=AutomationControlled");
options.AddExcludedArgument("enable-automation");
options.AddAdditionalOption("useAutomationExtension", false);

核心技巧

  • 隐藏WebDriver特征,避免被网站识别为自动化工具

  • 设置合理的用户代理(User-Agent)

  • 添加随机延迟,模拟人类操作间隔

2.3 分页难题的解决

携程的分页机制采用了Ant Design的分页组件,传统的元素点击方式经常失败:

html 复制代码
<!-- 分页组件结构 -->
<div class="myPagination">
  <ul class="ant-pagination">
    <li class="ant-pagination-item ant-pagination-item-1">1</li>
    <li class="ant-pagination-item ant-pagination-item-2">2</li>
    <!-- ... -->
  </ul>
</div>

解决方案:通过JavaScript直接执行分页操作,绕过元素交互问题:

javascript 复制代码
// 直接通过JavaScript点击分页元素
var element = document.querySelector('li.ant-pagination-item-2');
if (element) {
    element.click();
    return true;
}

三、数据提取的精确定位

3.1 评论结构分析

通过分析页面DOM结构,我们发现携程评论具有清晰的层次结构:

复制代码
div.commentItem
├── div.userInfo
│   └── div.userName(用户名)
├── div.contentInfo
│   ├── div.scoreInfo(评分区域)
│   ├── div.commentDetail(评论内容)
│   └── div.commentImgList(评论图片)
└── div.commentFooter
    └── div.commentTime(时间+IP信息)

3.2 数据提取策略

用户名提取 :使用CSS选择器 span.userName, div.userName
评论内容 :定位 div.commentDetail 元素
时间与IP分离:通过字符串分割处理"2025/1/28 IP属地:陕西"格式

关键洞察:选择器的精确性直接影响数据质量,过于宽泛的选择器会导致数据污染。

3.3 评分提取的曲折历程

评分提取是本项目中最具挑战性的部分。最初我们假设评分存在于scoreInfo元素中,但实际测试发现:

  1. 选择器失效div.scoreInfo 元素在目标页面中不存在

  2. 多方案尝试:尝试了多种CSS选择器和XPath表达式

  3. 最终方案 :从评论内容中提取评分标记,如【景色5】

经验总结:网页结构可能因A/B测试、地域差异或版本更新而变化,需要灵活的备选方案。

四、反反爬虫策略深度解析

4.1 行为模拟的真实性

为了绕过反爬虫检测,我们实施了多项措施:

  1. 随机延迟:在操作间添加1-3秒的随机等待时间

  2. 滚动模拟:分段滚动页面,触发懒加载机制

  3. 浏览器指纹隐藏:修改navigator.webdriver属性

4.2 网络请求监控

通过Chrome DevTools Protocol监控网络请求,我们试图找到评论数据的API端点:

cs 复制代码
// 启用网络监控
var network = session.GetVersionSpecificDomains<OpenQA.Selenium.DevTools.V125.DevToolsSessionDomains>().Network;
await network.Enable(new EnableCommandSettings());

虽然最终未能直接调用API,但这一过程帮助我们理解了数据加载机制。

五、工程化实践要点

5.1 健壮性设计

  1. 异常处理:每个页面操作都包含try-catch块

  2. 重试机制:失败操作自动重试,避免单点故障

  3. 进度保存:支持断点续爬,避免重复劳动

5.2 数据质量控制

  1. 去重机制:基于用户名和内容生成哈希值,避免重复存储

  2. 数据清洗:移除换行符、多余空格,统一数据格式

  3. 编码处理:确保中文字符正确保存为UTF-8格式

5.3 性能优化

  1. 资源管理:及时关闭浏览器实例,避免内存泄漏

  2. 并发控制:合理的请求频率,避免对目标网站造成压力

  3. 缓存利用:重复访问时利用浏览器缓存提升速度

六、伦理与法律考量

在实施网络爬虫项目时,必须考虑以下因素:

  1. Robots协议:尊重网站的爬虫限制

  2. 访问频率:控制请求速率,避免影响网站正常运营

  3. 数据用途:确保数据使用符合相关法律法规

  4. 用户隐私:不收集敏感个人信息,对已收集数据妥善保管

七、总结与展望

通过这个项目,我们成功突破了携程网的反爬虫机制,实现了评论数据的自动化采集。关键技术点包括:

  • Selenium的深度定制和反检测处理

  • JavaScript直接执行绕过交互障碍

  • 多层备选方案确保数据提取成功率

  • 完整的工程化设计和错误处理机制

未来改进方向可能包括:

  • 使用无头浏览器集群提升采集效率

  • 结合OCR技术处理验证码

  • 实现分布式爬虫架构

  • 添加数据质量自动评估机制

网络爬虫技术不断发展,唯有持续学习和适应变化,才能在数据采集领域保持竞争力。希望本文的经验分享能为同行提供有价值的参考。

附------C#全部代码

cs 复制代码
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using WebDriverManager;
using WebDriverManager.DriverConfigs.Impl;

namespace CtripCommentExtractor
{
    public class CommentModel
    {
        public string UserName { get; set; }
        public string CommentTime { get; set; }
        public string IpLocation { get; set; }
        public string Content { get; set; }
        public int PageNumber { get; set; }
        public string Rating { get; set; }
        public string CommentHash { get; set; }
    }

    class Program
    {
        private static string CsvFilePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
            $"携程阆中古城评论_{DateTime.Now:yyyyMMddHHmmss}.csv");

        private static HashSet<string> _seenCommentHashes = new HashSet<string>();

        static async Task Main(string[] args)
        {
            Console.WriteLine("=== 携程评论提取程序(评分修复版) ===");

            try
            {
                var allComments = await ExtractWithRating();

                if (allComments.Count > 0)
                {
                    SaveCommentsToCsv(allComments);
                    Console.WriteLine($"\n🎉 提取完成!共获取 {allComments.Count} 条不重复评论");
                    Console.WriteLine($"📁 CSV文件已保存至:{CsvFilePath}");
                }
                else
                {
                    Console.WriteLine("\n❌ 未提取到任何评论!");
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"💥 程序执行失败:{ex.Message}");
            }

            Console.WriteLine("\n按任意键退出...");
            Console.ReadKey();
        }

        public static async Task<List<CommentModel>> ExtractWithRating()
        {
            Console.WriteLine("🚀 初始化ChromeDriver...");

            new DriverManager().SetUpDriver(new ChromeConfig());

            var options = new ChromeOptions();
            // options.AddArgument("--headless");
            options.AddArgument("--no-sandbox");
            options.AddArgument("--disable-dev-shm-usage");
            options.AddArgument("--window-size=1400,1000");
            options.AddArgument("--disable-blink-features=AutomationControlled");
            options.AddExcludedArgument("enable-automation");

            var driver = new ChromeDriver(options);
            var allComments = new List<CommentModel>();

            try
            {
                IJavaScriptExecutor js = (IJavaScriptExecutor)driver;
                driver.ExecuteScript("Object.defineProperty(navigator, 'webdriver', {get: () => undefined})");

                Console.WriteLine("🌐 正在打开携程页面...");
                driver.Navigate().GoToUrl("https://you.ctrip.com/sight/langzhong831/74576.html");
                Console.WriteLine($"✅ 页面打开成功:{driver.Title}");

                // 等待页面加载
                Console.WriteLine("⏳ 等待页面加载...");
                await Task.Delay(8000);

                // 提取第一页评论
                var firstPageComments = ExtractCommentsWithRating(driver, 1);
                AddUniqueComments(allComments, firstPageComments);
                Console.WriteLine($"📄 第一页获取 {firstPageComments.Count} 条评论");

                // 使用JavaScript分页
                for (int page = 2; page <= 293; page++)
                {
                    Console.WriteLine($"\n🔄 尝试获取第 {page} 页...");

                    var success = await NavigateToPageWithJS(driver, page);
                    if (!success)
                    {
                        Console.WriteLine($"❌ 第 {page} 页导航失败,停止分页");
                        break;
                    }

                    // 等待新内容加载
                    await Task.Delay(4000);

                    // 提取当前页评论
                    var pageComments = ExtractCommentsWithRating(driver, page);
                    if (pageComments.Count == 0)
                    {
                        Console.WriteLine($"❌ 第 {page} 页无评论内容");
                        break;
                    }

                    int newCount = AddUniqueComments(allComments, pageComments);
                    Console.WriteLine($"✅ 第 {page} 页新增 {newCount} 条评论");

                    // 显示样本
                    foreach (var comment in pageComments.Take(2))
                    {
                        Console.WriteLine($"   👤 {comment.UserName} | 🕐 {comment.CommentTime} | 📍 {comment.IpLocation}");
                        Console.WriteLine($"   ⭐ 评分: {comment.Rating}");
                        Console.WriteLine($"   📝 {comment.Content.Substring(0, Math.Min(40, comment.Content.Length))}...");
                    }

                    // 如果连续两页没有新评论,停止
                    if (newCount == 0 && page > 2)
                    {
                        Console.WriteLine("⏹️ 连续无新评论,停止提取");
                        break;
                    }
                }

            }
            catch (Exception ex)
            {
                Console.WriteLine($"💥 提取过程中出错:{ex.Message}");
            }
            finally
            {
                driver.Quit();
                Console.WriteLine("🔚 浏览器已关闭");
            }

            return allComments;
        }

        static async Task<bool> NavigateToPageWithJS(ChromeDriver driver, int pageNumber)
        {
            try
            {
                IJavaScriptExecutor js = (IJavaScriptExecutor)driver;

                string clickScript = $@"
                    var selectors = [
                        'li.ant-pagination-item-{pageNumber}',
                        'a[href*=\""pagenow={pageNumber}\""]',
                        '.ant-pagination-item:nth-child({pageNumber + 1})',
                        '[data-page=\""{pageNumber}\""]'
                    ];
                    
                    for (var i = 0; i < selectors.length; i++) {{
                        var element = document.querySelector(selectors[i]);
                        if (element) {{
                            element.scrollIntoView({{behavior: 'smooth', block: 'center'}});
                            element.click();
                            return true;
                        }}
                    }}
                    
                    var pageElements = document.querySelectorAll('a, button, li');
                    for (var i = 0; i < pageElements.length; i++) {{
                        if (pageElements[i].textContent.trim() === '{pageNumber}' || 
                            pageElements[i].textContent.trim() === '>{pageNumber}<') {{
                            pageElements[i].scrollIntoView({{behavior: 'smooth', block: 'center'}});
                            pageElements[i].click();
                            return true;
                        }}
                    }}
                    
                    return false;
                ";

                var result = js.ExecuteScript(clickScript);
                if (result is bool boolResult && boolResult)
                {
                    Console.WriteLine($"   ✅ JavaScript成功点击第 {pageNumber} 页");
                    return true;
                }

                // 直接通过URL导航
                Console.WriteLine($"   🔗 尝试直接URL导航到第 {pageNumber} 页...");
                string url = $"https://you.ctrip.com/sight/langzhong831/74576.html?pagenow={pageNumber}";
                driver.Navigate().GoToUrl(url);
                await Task.Delay(5000);

                return true;

            }
            catch (Exception ex)
            {
                Console.WriteLine($"   ❌ 第 {pageNumber} 页导航失败: {ex.Message}");
                return false;
            }
        }

        static List<CommentModel> ExtractCommentsWithRating(ChromeDriver driver, int pageNumber)
        {
            var comments = new List<CommentModel>();

            try
            {
                var commentElements = driver.FindElements(By.CssSelector("div.commentItem"));
                Console.WriteLine($"   🔍 找到 {commentElements.Count} 个评论元素");

                foreach (var element in commentElements)
                {
                    try
                    {
                        var comment = ParseCommentWithRating(element, pageNumber);
                        if (comment != null && !string.IsNullOrEmpty(comment.Content) && comment.Content != "无内容")
                        {
                            comments.Add(comment);
                        }
                    }
                    catch (Exception ex)
                    {
                        Console.WriteLine($"   ⚠️ 解析评论失败: {ex.Message}");
                    }
                }

            }
            catch (Exception ex)
            {
                Console.WriteLine($"💥 提取第 {pageNumber} 页评论失败: {ex.Message}");
            }

            return comments;
        }

        static CommentModel ParseCommentWithRating(IWebElement element, int pageNumber)
        {
            var comment = new CommentModel { PageNumber = pageNumber };

            try
            {
                // 用户名
                comment.UserName = element.FindElement(By.CssSelector("span.userName, div.userName"))?.Text.Trim() ?? "匿名用户";

                // 评论时间和IP
                var timeAndIpElement = element.FindElement(By.CssSelector("div.commentTime, span.commentTime"));
                if (timeAndIpElement != null)
                {
                    var timeIpText = timeAndIpElement.Text.Trim();
                    var parts = timeIpText.Split(new[] { "IP属地:" }, StringSplitOptions.RemoveEmptyEntries);
                    if (parts.Length >= 1) comment.CommentTime = parts[0].Trim();
                    if (parts.Length >= 2) comment.IpLocation = parts[1].Trim();
                    else comment.IpLocation = "未知属地";
                }
                else
                {
                    comment.CommentTime = "未知时间";
                    comment.IpLocation = "未知属地";
                }

                // 评论内容
                comment.Content = element.FindElement(By.CssSelector("div.commentDetail"))?.Text.Trim() ?? "无内容";
                comment.Content = comment.Content.Replace("\n", " ").Replace("\r", "").Trim();

                // 评分 - 使用多种选择器尝试
                comment.Rating = ExtractRatingWithMultipleSelectors(element);

                // 生成哈希值
                comment.CommentHash = $"{comment.UserName}_{comment.Content.GetHashCode()}";

            }
            catch (Exception ex)
            {
                Console.WriteLine($"   ⚠️ 解析评论元素时出错:{ex.Message}");
            }

            return comment;
        }

        static string ExtractRatingWithMultipleSelectors(IWebElement commentElement)
        {
            try
            {
                // 尝试多种评分元素选择器
                var ratingSelectors = new[]
                {
                    "div.scoreInfo span.averageScore",
                    "div.scoreInfo",
                    ".averageScore",
                    "[class*='score']",
                    "[class*='rating']",
                    "[class*='star']",
                    ".ant-rate",
                    ".score",
                    ".rating"
                };

                foreach (var selector in ratingSelectors)
                {
                    try
                    {
                        var ratingElement = commentElement.FindElement(By.CssSelector(selector));
                        if (ratingElement != null)
                        {
                            var ratingText = ratingElement.Text.Trim();
                            if (!string.IsNullOrEmpty(ratingText) && ratingText != ">><<")
                            {
                                // 检查是否是数字评分
                                if (IsValidRating(ratingText))
                                {
                                    return ratingText;
                                }
                            }

                            // 检查属性
                            var textContent = ratingElement.GetAttribute("textContent")?.Trim();
                            var innerText = ratingElement.GetAttribute("innerText")?.Trim();
                            var dataScore = ratingElement.GetAttribute("data-score");
                            var dataRating = ratingElement.GetAttribute("data-rating");

                            if (IsValidRating(textContent)) return textContent;
                            if (IsValidRating(innerText)) return innerText;
                            if (IsValidRating(dataScore)) return dataScore;
                            if (IsValidRating(dataRating)) return dataRating;
                        }
                    }
                    catch
                    {
                        // 继续尝试下一个选择器
                    }
                }

                // 如果找不到评分元素,尝试从评论内容中提取
                return ExtractRatingFromContent(commentElement);
            }
            catch
            {
                return "无评分";
            }
        }

        static bool IsValidRating(string rating)
        {
            if (string.IsNullOrEmpty(rating)) return false;
            if (rating == ">><<") return false;

            // 检查是否是数字评分(如4.5、5等)
            if (double.TryParse(rating, out double score))
            {
                return score >= 1 && score <= 5;
            }

            // 检查是否是星级(如★★★★★)
            if (rating.Contains("★") || rating.Contains("☆"))
            {
                return true;
            }

            return rating.Length <= 10; // 评分通常很短
        }

        static string ExtractRatingFromContent(IWebElement commentElement)
        {
            try
            {
                var content = commentElement.FindElement(By.CssSelector("div.commentDetail"))?.Text.Trim() ?? "";

                // 在内容中查找评分模式
                if (content.Contains("【"))
                {
                    var startIndex = content.IndexOf("【");
                    var endIndex = content.IndexOf("】", startIndex);
                    if (startIndex >= 0 && endIndex > startIndex)
                    {
                        var ratingPart = content.Substring(startIndex, endIndex - startIndex + 1);
                        // 只返回包含数字的评分标记
                        if (ratingPart.Any(char.IsDigit))
                        {
                            return ratingPart;
                        }
                    }
                }

                return "无评分";
            }
            catch
            {
                return "无评分";
            }
        }

        static int AddUniqueComments(List<CommentModel> allComments, List<CommentModel> newComments)
        {
            int addedCount = 0;
            foreach (var comment in newComments)
            {
                if (!_seenCommentHashes.Contains(comment.CommentHash))
                {
                    _seenCommentHashes.Add(comment.CommentHash);
                    allComments.Add(comment);
                    addedCount++;
                }
            }
            return addedCount;
        }

        static void SaveCommentsToCsv(List<CommentModel> comments)
        {
            try
            {
                using (var sw = new StreamWriter(CsvFilePath, false, Encoding.UTF8))
                {
                    sw.WriteLine("页码,用户名,评论时间,IP属地,评分,评论内容");
                    foreach (var comment in comments)
                    {
                        string userName = EscapeCsvField(comment.UserName);
                        string commentTime = EscapeCsvField(comment.CommentTime);
                        string ipLocation = EscapeCsvField(comment.IpLocation);
                        string rating = EscapeCsvField(comment.Rating);
                        string content = EscapeCsvField(comment.Content);
                        sw.WriteLine($"{comment.PageNumber},{userName},{commentTime},{ipLocation},{rating},{content}");
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"💥 保存CSV失败:{ex.Message}");
            }
        }

        static string EscapeCsvField(string field)
        {
            if (string.IsNullOrEmpty(field)) return "";
            if (field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r"))
            {
                return $"\"{field.Replace("\"", "\"\"")}\"";
            }
            return field;
        }
    }
}
相关推荐
WongKyunban42 分钟前
使用Valgrind检测内存问题(C语言)
c语言·开发语言
Bin二叉42 分钟前
南京大学cpp复习——第二部分(继承)
开发语言·c++·笔记·学习
Zfox_42 分钟前
【Go】环境搭建与基本使用
开发语言·后端·golang
raoxiaoya44 分钟前
golang本地开发多版本切换,golang多版本管理,vscode切换多版本golang
开发语言·vscode·golang
wjs20241 小时前
R Excel 文件:高效数据处理的利器
开发语言
晓得迷路了1 小时前
栗子前端技术周刊第 108 期 - npm 沙虫攻击 2.0、Ant Design 6.0、Playwright 1.57...
前端·javascript·css
蒋士峰DBA修行之路1 小时前
红帽练习环境介绍
linux·开发语言·bash
wjs20241 小时前
PHP FTP 完全指南
开发语言
涤生大数据1 小时前
Spark分桶表实战:如何用分桶减少 40%+ 计算时间
大数据·sql·spark·分桶表·大数据校招·大数据八股