在数据采集领域,爬取旅游网站的用户评论对于市场分析、舆情监测和产品优化具有重要意义。本文将以携程网的景点评论爬取为例,深入探讨如何突破现代网站的反爬虫机制,特别是针对JavaScript动态加载内容的处理。
一、项目背景与挑战
携程作为国内领先的在线旅游服务平台,其用户评论数据具有很高的商业价值。然而,现代网站普遍采用了各种反爬虫技术:
-
JavaScript动态渲染 - 评论内容通过AJAX异步加载
-
反自动化检测 - 识别Selenium等自动化工具
-
动态参数验证 - 请求需要特定的时间戳或令牌
-
元素交互障碍 - 分页按钮被其他元素遮挡
二、技术方案演进
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元素中,但实际测试发现:
-
选择器失效 :
div.scoreInfo元素在目标页面中不存在 -
多方案尝试:尝试了多种CSS选择器和XPath表达式
-
最终方案 :从评论内容中提取评分标记,如
【景色5】
经验总结:网页结构可能因A/B测试、地域差异或版本更新而变化,需要灵活的备选方案。
四、反反爬虫策略深度解析
4.1 行为模拟的真实性
为了绕过反爬虫检测,我们实施了多项措施:
-
随机延迟:在操作间添加1-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 健壮性设计
-
异常处理:每个页面操作都包含try-catch块
-
重试机制:失败操作自动重试,避免单点故障
-
进度保存:支持断点续爬,避免重复劳动
5.2 数据质量控制
-
去重机制:基于用户名和内容生成哈希值,避免重复存储
-
数据清洗:移除换行符、多余空格,统一数据格式
-
编码处理:确保中文字符正确保存为UTF-8格式
5.3 性能优化
-
资源管理:及时关闭浏览器实例,避免内存泄漏
-
并发控制:合理的请求频率,避免对目标网站造成压力
-
缓存利用:重复访问时利用浏览器缓存提升速度
六、伦理与法律考量
在实施网络爬虫项目时,必须考虑以下因素:
-
Robots协议:尊重网站的爬虫限制
-
访问频率:控制请求速率,避免影响网站正常运营
-
数据用途:确保数据使用符合相关法律法规
-
用户隐私:不收集敏感个人信息,对已收集数据妥善保管
七、总结与展望
通过这个项目,我们成功突破了携程网的反爬虫机制,实现了评论数据的自动化采集。关键技术点包括:
-
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;
}
}
}
