📚 原创系列: “Go语言爬虫系列”
🔄 转载说明: 本文最初发布于"Gopher部落"微信公众号,经原作者授权转载。
🔗 关注原创: 欢迎扫描文末二维码,关注"Gopher部落"微信公众号获取第一手Go技术文章。
📑 Go语言爬虫系列导航
🚀 Go爬虫系列:共14篇本文是【Go语言爬虫系列】的第6篇,点击下方链接查看更多文章
- 爬虫入门与Colly框架基础
- HTML解析与Goquery技术详解
- 并发控制与错误处理
- 数据存储与导出
- 反爬虫策略应对技术
- 模拟登录与会话维持 👈 当前位置
- 分布式爬虫架构
- JavaScript渲染页面抓取
- 移动应用数据抓取
- 爬虫性能优化技术
- 爬虫数据分析与应用
- 爬虫系统安全与伦理
- 爬虫系统监控与运维
- 综合项目实战:新闻聚合系统 ⏳ 开发中 - 关注公众号获取发布通知!
📢 特别提示:《综合项目实战:新闻聚合系统》正在精心制作中!这将是一个完整的实战项目,带您从零构建一个多站点新闻聚合系统。扫描文末二维码关注公众号并回复「新闻聚合」,获取项目发布通知和源码下载链接!
📖 文章导读
在前五篇文章中,我们已经掌握了基本的爬虫技术和反爬策略应对方法。然而,互联网上有大量有价值的信息隐藏在登录墙后面,需要用户身份验证才能访问。本文作为系列的第六篇,将重点介绍如何使用Go语言模拟用户登录并维持会话状态,主要内容包括:
- 登录系统的基本原理与类型分析
- 表单提交与参数处理技术
- Cookie与会话管理的实现方法
- 处理各类登录验证机制(短信验证、图形验证码等)
- 多因素认证的应对策略
- 安全存储与使用凭证的最佳实践
- 实战案例:模拟登录主流网站
掌握这些技术后,您将能够开发出能够访问需要身份验证内容的高级爬虫,大大拓展爬虫的应用场景。
一、登录系统基本原理与分析
1.1 常见登录系统类型
在开始模拟登录前,我们需要了解不同类型的登录系统及其工作原理:
1. 基于表单的传统登录
最常见的登录方式,通过HTML表单提交用户名和密码:
- 特点:使用POST请求提交凭证,返回Cookie或Session ID
- 识别方法:页面中存在form元素,包含username/password等输入字段
- 处理难度:较低,主要是表单字段识别和Cookie管理
2. 基于Token的认证系统
现代Web应用常用的无状态认证方式:
- 特点:登录后获取JWT或自定义Token,后续请求通过Authorization头部传递
- 识别方法:API响应中包含token字段,或使用OAuth流程
- 处理难度:中等,需要正确解析和存储Token,并在后续请求中使用
3. 多因素认证(MFA)系统
增强安全性的认证系统,除用户名密码外还需要其他验证:
- 特点:登录过程分多步完成,可能需要短信验证码、OTP等
- 识别方法:登录后出现额外验证页面或弹窗
- 处理难度:较高,需要处理多步骤验证和额外的用户交互
4. OAuth/第三方登录
通过第三方服务提供身份验证:
- 特点:重定向到第三方服务(如Google、Facebook)进行认证
- 识别方法:存在"使用XX登录"按钮,登录过程有跨域重定向
- 处理难度:高,需要处理复杂的OAuth流程和跨域重定向
1.2 登录前的信息收集
在模拟登录前,需要收集以下关键信息:
1. 分析登录表单
使用浏览器开发者工具分析登录表单的结构:
func analyzeLoginForm(url string) (*LoginFormInfo, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 10 * time.Second,
}
// 发送GET请求获取登录页面
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// 解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, err
}
// 查找登录表单
form := doc.Find("form").FilterFunction(func(i int, s *goquery.Selection) bool {
// 寻找可能的登录表单特征
action, _ := s.Attr("action")
id, _ := s.Attr("id")
class, _ := s.Attr("class")
// 检查表单特征
isLoginForm := strings.Contains(strings.ToLower(action), "login") ||
strings.Contains(strings.ToLower(id), "login") ||
strings.Contains(strings.ToLower(class), "login") ||
s.Find("input[type=password]").Length() > 0
return isLoginForm
}).First()
if form.Length() == 0 {
return nil, errors.New("未找到登录表单")
}
// 提取表单信息
formInfo := &LoginFormInfo{
Action: form.AttrOr("action", ""),
Method: strings.ToUpper(form.AttrOr("method", "POST")),
Fields: make(map[string]string),
}
// 如果Action是相对路径,转换为绝对路径
if !strings.HasPrefix(formInfo.Action, "http") {
baseURL, _ := url.Parse(url)
actionURL, _ := url.Parse(formInfo.Action)
formInfo.Action = baseURL.ResolveReference(actionURL).String()
}
// 提取所有输入字段
form.Find("input").Each(func(i int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("value")
inputType, _ := s.Attr("type")
if name != "" {
formInfo.Fields[name] = value
// 识别用户名和密码字段
if inputType == "text" || inputType == "email" ||
strings.Contains(strings.ToLower(name), "user") ||
strings.Contains(strings.ToLower(name), "email") ||
strings.Contains(strings.ToLower(name), "account") {
formInfo.UsernameField = name
} else if inputType == "password" ||
strings.Contains(strings.ToLower(name), "pass") {
formInfo.PasswordField = name
}
}
})
// 提取隐藏字段(通常包含CSRF令牌等)
form.Find("input[type=hidden]").Each(func(i int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("value")
if name != "" {
formInfo.HiddenFields = append(formInfo.HiddenFields,
HiddenField{Name: name, Value: value})
}
})
return formInfo, nil
}
type LoginFormInfo struct {
Action string
Method string
Fields map[string]string
UsernameField string
PasswordField string
HiddenFields []HiddenField
}
type HiddenField struct {
Name string
Value string
}
2. 检查CSRF保护
许多网站使用CSRF令牌防止跨站请求伪造:
func extractCSRFToken(doc *goquery.Document) string {
// 常见的CSRF令牌字段名
csrfFieldNames := []string{
"csrf_token", "csrfToken", "CSRFToken",
"_token", "authenticity_token", "__RequestVerificationToken",
}
// 在meta标签中查找
for _, name := range csrfFieldNames {
token, exists := doc.Find(fmt.Sprintf("meta[name='%s']", name)).Attr("content")
if exists && token != "" {
return token
}
}
// 在隐藏字段中查找
for _, name := range csrfFieldNames {
token, _ := doc.Find(fmt.Sprintf("input[name='%s']", name)).Attr("value")
if token != "" {
return token
}
}
// 在自定义数据属性中查找
scriptContent := doc.Find("script").Text()
for _, name := range []string{"csrf", "token", "CSRF", "Token"} {
re := regexp.MustCompile(fmt.Sprintf(`['"]%s['"]:\s*['"]([^'"]+)['"]`, name))
matches := re.FindStringSubmatch(scriptContent)
if len(matches) > 1 {
return matches[1]
}
}
return ""
}
3. 分析JavaScript行为
现代网站可能使用JavaScript处理登录逻辑,需要分析JS代码:
func checkJavaScriptLogin(doc *goquery.Document) bool {
scripts := doc.Find("script")
// 检查是否有与登录相关的JavaScript
loginRelatedJS := false
scripts.Each(func(i int, s *goquery.Selection) {
scriptContent := s.Text()
scriptSrc, hasSrc := s.Attr("src")
// 检查内联脚本
if strings.Contains(scriptContent, "login") ||
strings.Contains(scriptContent, "submit") ||
strings.Contains(scriptContent, "auth") {
loginRelatedJS = true
}
// 检查外部脚本
if hasSrc && (strings.Contains(scriptSrc, "login") ||
strings.Contains(scriptSrc, "auth")) {
loginRelatedJS = true
}
})
// 检查是否有监听提交事件的代码
hasSubmitEvent := strings.Contains(doc.Text(), "addEventListener") &&
strings.Contains(doc.Text(), "submit")
// 检查是否有阻止默认表单提交的代码
preventsDefault := strings.Contains(doc.Text(), "preventDefault")
// 如果有登录相关JS且阻止默认提交,可能是JS控制的登录
return loginRelatedJS && (hasSubmitEvent || preventsDefault)
}
1.3 判断登录成功的标志
需要确定如何判断登录是否成功:
func isLoginSuccessful(resp *http.Response, body string) bool {
// 方法1: 检查重定向URL
if strings.Contains(resp.Request.URL.Path, "dashboard") ||
strings.Contains(resp.Request.URL.Path, "home") ||
strings.Contains(resp.Request.URL.Path, "account") {
return true
}
// 方法2: 检查响应内容中的关键词
successIndicators := []string{
"欢迎", "welcome", "dashboard", "logout", "退出",
"个人中心", "profile", "account", "我的账户",
}
for _, indicator := range successIndicators {
if strings.Contains(strings.ToLower(body), strings.ToLower(indicator)) {
return true
}
}
// 方法3: 检查Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == "session" ||
cookie.Name == "auth" ||
strings.Contains(cookie.Name, "login") {
return true
}
}
// 方法4: 检查特定元素存在与否
doc, err := goquery.NewDocumentFromReader(strings.NewReader(body))
if err == nil {
// 检查登出按钮是否存在
if doc.Find("a[href*='logout']").Length() > 0 ||
doc.Find("button:contains('登出')").Length() > 0 {
return true
}
// 检查用户名显示是否存在
if doc.Find(".user-name").Length() > 0 ||
doc.Find(".avatar").Length() > 0 {
return true
}
}
return false
}
二、模拟表单登录实现
2.1 基本表单提交
最简单的登录表单模拟实现:
func simpleFormLogin(loginURL, username, password string) (*http.Response, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 10 * time.Second,
// 启用Cookie管理
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
}
// 首先获取登录页面以获取任何必要的Cookie或令牌
resp, err := client.Get(loginURL)
if err != nil {
return nil, fmt.Errorf("获取登录页面失败: %w", err)
}
resp.Body.Close()
// 分析登录表单
formInfo, err := analyzeLoginForm(loginURL)
if err != nil {
return nil, fmt.Errorf("分析登录表单失败: %w", err)
}
// 准备表单数据
formData := url.Values{}
// 添加用户名和密码
if formInfo.UsernameField != "" {
formData.Set(formInfo.UsernameField, username)
} else {
// 尝试常用的用户名字段名
for _, field := range []string{"username", "email", "user", "account"} {
if _, exists := formInfo.Fields[field]; exists {
formData.Set(field, username)
break
}
}
}
if formInfo.PasswordField != "" {
formData.Set(formInfo.PasswordField, password)
} else {
// 尝试常用的密码字段名
for _, field := range []string{"password", "pass", "pwd"} {
if _, exists := formInfo.Fields[field]; exists {
formData.Set(field, password)
break
}
}
}
// 添加所有隐藏字段
for _, field := range formInfo.HiddenFields {
formData.Set(field.Name, field.Value)
}
// 确定提交URL
submitURL := formInfo.Action
if submitURL == "" {
submitURL = loginURL
}
// 确定提交方法
method := formInfo.Method
if method == "" {
method = "POST"
}
// 创建登录请求
req, err := http.NewRequest(method, submitURL, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("创建登录请求失败: %w", err)
}
// 设置适当的头部
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.5")
req.Header.Set("Referer", loginURL)
// 发送登录请求
loginResp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
return loginResp, nil
}
2.2 处理重定向链
登录过程中可能涉及多次重定向:
func loginWithRedirects(loginURL, username, password string) (*http.Client, error) {
// 创建HTTP客户端,启用Cookie管理,但禁用自动重定向
client := &http.Client{
Timeout: 10 * time.Second,
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
CheckRedirect: func(req *http.Request, via []*http.Request) error {
// 禁用自动重定向,以便我们手动处理
return http.ErrUseLastResponse
},
}
// 首先获取登录页面以获取任何必要的Cookie或令牌
resp, err := client.Get(loginURL)
if err != nil {
return nil, fmt.Errorf("获取登录页面失败: %w", err)
}
body, _ := ioutil.ReadAll(resp.Body)
resp.Body.Close()
// 解析HTML
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("解析登录页面失败: %w", err)
}
// 提取CSRF令牌
csrfToken := extractCSRFToken(doc)
// 分析登录表单
formInfo, err := analyzeLoginForm(loginURL)
if err != nil {
return nil, fmt.Errorf("分析登录表单失败: %w", err)
}
// 准备表单数据
formData := url.Values{}
formData.Set(formInfo.UsernameField, username)
formData.Set(formInfo.PasswordField, password)
// 添加CSRF令牌
if csrfToken != "" {
for _, field := range formInfo.HiddenFields {
if strings.Contains(strings.ToLower(field.Name), "csrf") ||
strings.Contains(strings.ToLower(field.Name), "token") {
formData.Set(field.Name, csrfToken)
break
}
}
}
// 添加所有隐藏字段
for _, field := range formInfo.HiddenFields {
if formData.Get(field.Name) == "" { // 不覆盖已设置的字段
formData.Set(field.Name, field.Value)
}
}
// 创建登录请求
req, err := http.NewRequest("POST", formInfo.Action, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("创建登录请求失败: %w", err)
}
// 设置适当的头部
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Referer", loginURL)
// 发送登录请求并手动处理重定向
currentResp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
// 处理重定向链
maxRedirects := 10
redirectCount := 0
for redirectCount < maxRedirects {
if currentResp.StatusCode < 300 || currentResp.StatusCode >= 400 {
// 不是重定向状态码,结束跟踪
break
}
// 获取重定向URL
location := currentResp.Header.Get("Location")
if location == "" {
break
}
// 关闭当前响应
currentResp.Body.Close()
// 如果Location是相对路径,转换为绝对路径
if !strings.HasPrefix(location, "http") {
baseURL, _ := url.Parse(currentResp.Request.URL.String())
locationURL, _ := url.Parse(location)
location = baseURL.ResolveReference(locationURL).String()
}
// 发送新请求
req, err = http.NewRequest("GET", location, nil)
if err != nil {
return nil, fmt.Errorf("创建重定向请求失败: %w", err)
}
currentResp, err = client.Do(req)
if err != nil {
return nil, fmt.Errorf("重定向请求失败: %w", err)
}
redirectCount++
}
// 检查最终页面是否表示登录成功
finalBody, _ := ioutil.ReadAll(currentResp.Body)
currentResp.Body.Close()
if !isLoginSuccessful(currentResp, string(finalBody)) {
return nil, errors.New("登录失败,最终页面不包含登录成功的标志")
}
// 创建新客户端,启用自动重定向,保留原客户端的Cookie
finalClient := &http.Client{
Timeout: 10 * time.Second,
Jar: client.Jar, // 使用相同的Cookie容器
}
return finalClient, nil
}
2.3 处理Ajax登录接口
许多现代网站使用Ajax API处理登录请求:
func ajaxLogin(loginURL, username, password string) (*http.Client, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 10 * time.Second,
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
}
// 访问登录页面获取必要的Cookie和令牌
resp, err := client.Get(loginURL)
if err != nil {
return nil, fmt.Errorf("获取登录页面失败: %w", err)
}
defer resp.Body.Close()
// 解析HTML查找API端点和CSRF令牌
body, _ := ioutil.ReadAll(resp.Body)
doc, _ := goquery.NewDocumentFromReader(bytes.NewReader(body))
// 提取CSRF令牌
csrfToken := extractCSRFToken(doc)
// 查找可能的API端点
// 方法1: 从表单action获取
apiEndpoint := ""
loginForm := doc.Find("form").FilterFunction(func(i int, s *goquery.Selection) bool {
return s.Find("input[type=password]").Length() > 0
}).First()
if loginForm.Length() > 0 {
if action, exists := loginForm.Attr("action"); exists && action != "" {
apiEndpoint = action
}
}
// 方法2: 从JavaScript中提取
if apiEndpoint == "" {
scriptContent := doc.Find("script").Text()
// 常见的API URL模式
patterns := []string{
`['"]?apiUrl['"]?\s*[:=]\s*['"]([^'"]+)['"]`,
`['"]?loginUrl['"]?\s*[:=]\s*['"]([^'"]+)['"]`,
`['"]?authEndpoint['"]?\s*[:=]\s*['"]([^'"]+)['"]`,
`\.post\(['"]([^'"]+)['"]`,
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindStringSubmatch(scriptContent)
if len(matches) > 1 {
apiEndpoint = matches[1]
break
}
}
}
// 默认回退到标准登录端点
if apiEndpoint == "" {
baseURL, _ := url.Parse(loginURL)
apiEndpoint = fmt.Sprintf("%s://%s/api/login", baseURL.Scheme, baseURL.Host)
}
// 如果是相对路径,转换为绝对路径
if !strings.HasPrefix(apiEndpoint, "http") {
baseURL, _ := url.Parse(loginURL)
apiURL, _ := url.Parse(apiEndpoint)
apiEndpoint = baseURL.ResolveReference(apiURL).String()
}
// 准备登录数据
// 首先尝试JSON格式,这是现代API的常见格式
loginData := map[string]interface{}{
"username": username,
"password": password,
}
// 添加CSRF令牌(如果有)
if csrfToken != "" {
loginData["csrf_token"] = csrfToken
// 同时设置为头部,因为一些API会在头部检查它
client.Transport = &csrfTokenTransport{
Transport: http.DefaultTransport,
Token: csrfToken,
}
}
// 将登录数据序列化为JSON
jsonData, _ := json.Marshal(loginData)
// 创建请求
req, err := http.NewRequest("POST", apiEndpoint, bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("创建登录请求失败: %w", err)
}
// 设置通用头部
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("X-Requested-With", "XMLHttpRequest") // 标记为Ajax请求
req.Header.Set("Referer", loginURL)
// 发送请求
resp, err = client.Do(req)
if err != nil {
// 如果JSON请求失败,尝试表单提交
return tryFormLogin(client, apiEndpoint, loginURL, username, password, csrfToken)
}
defer resp.Body.Close()
// 读取响应
respBody, _ := ioutil.ReadAll(resp.Body)
// 尝试解析响应JSON
var response map[string]interface{}
if err := json.Unmarshal(respBody, &response); err != nil {
// 如果不是有效JSON,尝试表单提交
return tryFormLogin(client, apiEndpoint, loginURL, username, password, csrfToken)
}
// 检查登录是否成功
success := false
// 通常API会返回success字段或status字段
if v, ok := response["success"]; ok && (v == true || v == "true" || v == 1) {
success = true
} else if v, ok := response["status"]; ok && (v == "success" || v == 200 || v == "200" || v == "ok") {
success = true
} else if _, ok := response["token"]; ok {
// 有token字段通常意味着登录成功
success = true
} else if v, ok := response["code"]; ok && (v == 0 || v == "0" || v == 200 || v == "200") {
success = true
}
if !success {
// 尝试表单提交作为后备选项
return tryFormLogin(client, apiEndpoint, loginURL, username, password, csrfToken)
}
// 提取可能存在的认证token
var token string
if t, ok := response["token"]; ok {
if str, isStr := t.(string); isStr {
token = str
} else if flt, isFloat := t.(float64); isFloat {
token = fmt.Sprintf("%.0f", flt)
}
} else if t, ok := response["access_token"]; ok {
if str, isStr := t.(string); isStr {
token = str
}
} else if t, ok := response["auth_token"]; ok {
if str, isStr := t.(string); isStr {
token = str
}
}
// 如果响应中包含token,保存它
if token != "" {
client.Transport = &tokenAuthTransport{
Transport: client.Transport,
Token: token,
}
}
return client, nil
}
// 在头部中添加CSRF令牌的传输层
type csrfTokenTransport struct {
Transport http.RoundTripper
Token string
}
func (t *csrfTokenTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// 克隆请求以避免修改原始请求
req2 := req.Clone(req.Context())
// 添加CSRF令牌到通用头部名称
csrfHeaderNames := []string{
"X-CSRF-Token", "X-CSRFToken", "CSRF-Token",
"X-XSRF-TOKEN", "X-Xsrf-Token",
}
for _, name := range csrfHeaderNames {
req2.Header.Set(name, t.Token)
}
return t.Transport.RoundTrip(req2)
}
// 在头部中添加认证令牌的传输层
type tokenAuthTransport struct {
Transport http.RoundTripper
Token string
}
func (t *tokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// 克隆请求以避免修改原始请求
req2 := req.Clone(req.Context())
// 添加认证令牌到头部
req2.Header.Set("Authorization", "Bearer "+t.Token)
return t.Transport.RoundTrip(req2)
}
// 作为后备选项的表单登录
func tryFormLogin(client *http.Client, apiEndpoint, loginURL, username, password, csrfToken string) (*http.Client, error) {
// 准备表单数据
formData := url.Values{}
formData.Set("username", username)
formData.Set("password", password)
if csrfToken != "" {
formData.Set("csrf_token", csrfToken)
formData.Set("_token", csrfToken)
formData.Set("authenticity_token", csrfToken)
}
// 创建请求
req, err := http.NewRequest("POST", apiEndpoint, strings.NewReader(formData.Encode()))
if err != nil {
return nil, fmt.Errorf("创建表单登录请求失败: %w", err)
}
// 设置头部
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Referer", loginURL)
// 发送请求
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("表单登录请求失败: %w", err)
}
defer resp.Body.Close()
// 检查登录是否成功
body, _ := ioutil.ReadAll(resp.Body)
if !isLoginSuccessful(resp, string(body)) {
return nil, errors.New("登录失败")
}
return client, nil
}
三、Cookie与会话管理
3.1 Cookie持久化存储
为了在多次运行之间保持登录状态,需要持久化存储Cookie:
// Cookie存储与加载
type CookieManager struct {
StoragePath string
}
func NewCookieManager(path string) *CookieManager {
return &CookieManager{
StoragePath: path,
}
}
// 保存Cookie到文件
func (cm *CookieManager) SaveCookies(client *http.Client, urlStr string) error {
// 确保客户端有Cookie jar
if client.Jar == nil {
return errors.New("HTTP客户端没有Cookie jar")
}
// 解析URL以获取Cookie
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("解析URL失败: %w", err)
}
// 获取这个URL的所有Cookie
cookies := client.Jar.Cookies(u)
if len(cookies) == 0 {
return errors.New("没有Cookie可保存")
}
// 创建存储目录
if err := os.MkdirAll(filepath.Dir(cm.StoragePath), 0755); err != nil {
return fmt.Errorf("创建存储目录失败: %w", err)
}
// 序列化Cookie
cookieData := make([]map[string]interface{}, len(cookies))
for i, cookie := range cookies {
cookieData[i] = map[string]interface{}{
"Name": cookie.Name,
"Value": cookie.Value,
"Path": cookie.Path,
"Domain": cookie.Domain,
"Secure": cookie.Secure,
"HttpOnly": cookie.HttpOnly,
"Expires": cookie.Expires.Format(time.RFC3339),
}
}
// 将Cookie数据写入文件
jsonData, err := json.MarshalIndent(cookieData, "", " ")
if err != nil {
return fmt.Errorf("序列化Cookie失败: %w", err)
}
return ioutil.WriteFile(cm.StoragePath, jsonData, 0644)
}
// 从文件加载Cookie
func (cm *CookieManager) LoadCookies(urlStr string) ([]*http.Cookie, error) {
// 检查文件是否存在
if _, err := os.Stat(cm.StoragePath); os.IsNotExist(err) {
return nil, errors.New("Cookie文件不存在")
}
// 读取文件内容
jsonData, err := ioutil.ReadFile(cm.StoragePath)
if err != nil {
return nil, fmt.Errorf("读取Cookie文件失败: %w", err)
}
// 解析JSON数据
var cookieData []map[string]interface{}
if err := json.Unmarshal(jsonData, &cookieData); err != nil {
return nil, fmt.Errorf("解析Cookie数据失败: %w", err)
}
// 重建Cookie对象
var cookies []*http.Cookie
for _, data := range cookieData {
cookie := &http.Cookie{
Name: data["Name"].(string),
Value: data["Value"].(string),
Path: data["Path"].(string),
Domain: data["Domain"].(string),
Secure: data["Secure"].(bool),
HttpOnly: data["HttpOnly"].(bool),
}
// 解析过期时间
if expiresStr, ok := data["Expires"].(string); ok && expiresStr != "" {
if expires, err := time.Parse(time.RFC3339, expiresStr); err == nil {
cookie.Expires = expires
}
}
cookies = append(cookies, cookie)
}
return cookies, nil
}
// 应用Cookie到HTTP客户端
func (cm *CookieManager) ApplyCookiesToClient(client *http.Client, urlStr string) error {
// 确保客户端有Cookie jar
if client.Jar == nil {
jar, err := cookiejar.New(nil)
if err != nil {
return fmt.Errorf("创建Cookie jar失败: %w", err)
}
client.Jar = jar
}
// 加载Cookie
cookies, err := cm.LoadCookies(urlStr)
if err != nil {
return err
}
// 解析URL
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("解析URL失败: %w", err)
}
// 设置Cookie
client.Jar.SetCookies(u, cookies)
return nil
}
// 验证Cookie是否有效
func (cm *CookieManager) ValidateCookies(client *http.Client, testURL, loginURL string) (bool, error) {
// 发送请求测试Cookie是否有效
resp, err := client.Get(testURL)
if err != nil {
return false, fmt.Errorf("测试Cookie有效性失败: %w", err)
}
defer resp.Body.Close()
// 检查是否被重定向到登录页面
if strings.Contains(resp.Request.URL.String(), "login") {
return false, nil
}
// 检查响应页面内容
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return false, fmt.Errorf("读取响应内容失败: %w", err)
}
return isLoginSuccessful(resp, string(body)), nil
}
3.2 会话维持与刷新
许多网站的会话有过期时间,需要定期刷新:
// 会话维持器
type SessionMaintainer struct {
Client *http.Client
RefreshURL string
TestURL string
LoginURL string
CookieManager *CookieManager
RefreshRate time.Duration
stopChan chan struct{}
}
func NewSessionMaintainer(client *http.Client, refreshURL, testURL, loginURL string, cookieManager *CookieManager) *SessionMaintainer {
return &SessionMaintainer{
Client: client,
RefreshURL: refreshURL,
TestURL: testURL,
LoginURL: loginURL,
CookieManager: cookieManager,
RefreshRate: 15 * time.Minute, // 默认每15分钟刷新一次
stopChan: make(chan struct{}),
}
}
// 开始会话维持
func (sm *SessionMaintainer) Start() {
ticker := time.NewTicker(sm.RefreshRate)
defer ticker.Stop()
for {
select {
case <-ticker.C:
sm.refreshSession()
case <-sm.stopChan:
return
}
}
}
// 停止会话维持
func (sm *SessionMaintainer) Stop() {
close(sm.stopChan)
}
// 刷新会话
func (sm *SessionMaintainer) refreshSession() {
// 先验证当前会话是否有效
valid, err := sm.CookieManager.ValidateCookies(sm.Client, sm.TestURL, sm.LoginURL)
if err != nil {
log.Printf("验证会话失败: %v", err)
return
}
if !valid {
log.Println("会话已过期,尝试刷新")
// 尝试访问刷新URL
resp, err := sm.Client.Get(sm.RefreshURL)
if err != nil {
log.Printf("刷新会话失败: %v", err)
return
}
resp.Body.Close()
// 再次验证
valid, err = sm.CookieManager.ValidateCookies(sm.Client, sm.TestURL, sm.LoginURL)
if err != nil {
log.Printf("验证刷新后的会话失败: %v", err)
return
}
if !valid {
log.Println("会话刷新失败,需要重新登录")
return
}
// 保存刷新后的Cookie
sm.CookieManager.SaveCookies(sm.Client, sm.TestURL)
log.Println("会话刷新成功,Cookie已更新")
} else {
log.Println("会话有效,无需刷新")
}
}
3.3 Token认证系统处理
对于使用JWT等Token认证的网站:
// Token管理器
type TokenManager struct {
StoragePath string
Token string
TokenType string
ExpiresAt time.Time
}
func NewTokenManager(path string) *TokenManager {
return &TokenManager{
StoragePath: path,
TokenType: "Bearer", // 默认为Bearer认证
}
}
// 保存Token到文件
func (tm *TokenManager) SaveToken(token, tokenType string, expiresIn int) error {
tm.Token = token
tm.TokenType = tokenType
// 计算过期时间
if expiresIn > 0 {
tm.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
}
// 创建存储目录
if err := os.MkdirAll(filepath.Dir(tm.StoragePath), 0755); err != nil {
return fmt.Errorf("创建存储目录失败: %w", err)
}
// 准备Token数据
tokenData := map[string]interface{}{
"token": token,
"token_type": tokenType,
"expires_at": tm.ExpiresAt.Format(time.RFC3339),
}
// 序列化Token数据
jsonData, err := json.MarshalIndent(tokenData, "", " ")
if err != nil {
return fmt.Errorf("序列化Token数据失败: %w", err)
}
return ioutil.WriteFile(tm.StoragePath, jsonData, 0644)
}
// 从文件加载Token
func (tm *TokenManager) LoadToken() error {
// 检查文件是否存在
if _, err := os.Stat(tm.StoragePath); os.IsNotExist(err) {
return errors.New("Token文件不存在")
}
// 读取文件内容
jsonData, err := ioutil.ReadFile(tm.StoragePath)
if err != nil {
return fmt.Errorf("读取Token文件失败: %w", err)
}
// 解析JSON数据
var tokenData map[string]interface{}
if err := json.Unmarshal(jsonData, &tokenData); err != nil {
return fmt.Errorf("解析Token数据失败: %w", err)
}
// 提取Token信息
if token, ok := tokenData["token"].(string); ok {
tm.Token = token
} else {
return errors.New("Token文件中没有有效的token字段")
}
if tokenType, ok := tokenData["token_type"].(string); ok {
tm.TokenType = tokenType
}
if expiresAtStr, ok := tokenData["expires_at"].(string); ok && expiresAtStr != "" {
if expiresAt, err := time.Parse(time.RFC3339, expiresAtStr); err == nil {
tm.ExpiresAt = expiresAt
}
}
return nil
}
// 检查Token是否过期
func (tm *TokenManager) IsTokenExpired() bool {
// 如果没有设置过期时间,返回false
if tm.ExpiresAt.IsZero() {
return false
}
// 添加5分钟缓冲时间
return time.Now().Add(5 * time.Minute).After(tm.ExpiresAt)
}
// 应用Token到HTTP客户端
func (tm *TokenManager) ApplyTokenToClient(client *http.Client) error {
// 加载Token
if err := tm.LoadToken(); err != nil {
return err
}
// 检查Token是否有效
if tm.Token == "" {
return errors.New("没有有效的Token")
}
// 检查Token是否过期
if tm.IsTokenExpired() {
return errors.New("Token已过期")
}
// 设置认证传输层
originalTransport := client.Transport
if originalTransport == nil {
originalTransport = http.DefaultTransport
}
client.Transport = &tokenAuthTransport{
Transport: originalTransport,
Token: tm.Token,
TokenType: tm.TokenType,
}
return nil
}
// 更新后的认证传输层
type tokenAuthTransport struct {
Transport http.RoundTripper
Token string
TokenType string
}
func (t *tokenAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// 克隆请求以避免修改原始请求
req2 := req.Clone(req.Context())
// 添加认证头部
req2.Header.Set("Authorization", t.TokenType+" "+t.Token)
return t.Transport.RoundTrip(req2)
}
四、处理特殊验证机制
4.1 处理短信验证码
需要用户输入短信验证码的登录流程:
func loginWithSMSVerification(loginURL, username, password string) (*http.Client, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 30 * time.Second, // 增加超时时间,因为需要等待用户输入
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
}
// 第一步:常规登录
// 分析登录表单
formInfo, err := analyzeLoginForm(loginURL)
if err != nil {
return nil, fmt.Errorf("分析登录表单失败: %w", err)
}
// 准备表单数据
formData := url.Values{}
formData.Set(formInfo.UsernameField, username)
formData.Set(formInfo.PasswordField, password)
// 添加所有隐藏字段
for _, field := range formInfo.HiddenFields {
formData.Set(field.Name, field.Value)
}
// 发送登录请求
resp, err := client.PostForm(formInfo.Action, formData)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
defer resp.Body.Close()
// 读取响应内容
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应内容失败: %w", err)
}
// 检查是否需要短信验证
doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("解析响应HTML失败: %w", err)
}
// 检查是否有短信验证码输入框
smsCodeInput := doc.Find("input[name*='sms'], input[name*='code'], input[name*='verify']").First()
if smsCodeInput.Length() == 0 {
// 没有发现短信验证码输入框,可能已经登录成功或使用了其他验证方式
if isLoginSuccessful(resp, string(body)) {
return client, nil
}
return nil, errors.New("登录失败或不支持的验证方式")
}
// 获取短信验证码表单
smsForm := smsCodeInput.Closest("form")
if smsForm.Length() == 0 {
return nil, errors.New("无法找到短信验证码表单")
}
// 提取表单信息
smsAction, _ := smsForm.Attr("action")
smsMethod := strings.ToUpper(smsForm.AttrOr("method", "POST"))
// 如果是相对路径,转换为绝对路径
if !strings.HasPrefix(smsAction, "http") {
baseURL, _ := url.Parse(resp.Request.URL.String())
actionURL, _ := url.Parse(smsAction)
smsAction = baseURL.ResolveReference(actionURL).String()
}
// 提示用户输入短信验证码
fmt.Println("请查看手机短信并输入验证码:")
var smsCode string
fmt.Scanln(&smsCode)
// 准备短信验证表单数据
smsData := url.Values{}
// 添加短信验证码
smsCodeFieldName, _ := smsCodeInput.Attr("name")
smsData.Set(smsCodeFieldName, smsCode)
// 添加其他可能的隐藏字段
smsForm.Find("input[type=hidden]").Each(func(i int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("value")
if name != "" {
smsData.Set(name, value)
}
})
// 发送短信验证请求
var smsResp *http.Response
var smsErr error
if smsMethod == "GET" {
// 构建GET URL
smsURL := smsAction
if len(smsData) > 0 {
smsURL += "?" + smsData.Encode()
}
smsResp, smsErr = client.Get(smsURL)
} else {
// POST请求
smsResp, smsErr = client.PostForm(smsAction, smsData)
}
if smsErr != nil {
return nil, fmt.Errorf("发送短信验证请求失败: %w", smsErr)
}
defer smsResp.Body.Close()
// 检查验证是否成功
smsRespBody, _ := ioutil.ReadAll(smsResp.Body)
if !isLoginSuccessful(smsResp, string(smsRespBody)) {
return nil, errors.New("短信验证失败")
}
return client, nil
}
4.2 处理图形验证码
使用OCR识别图形验证码:
func loginWithCaptcha(loginURL, username, password string) (*http.Client, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 10 * time.Second,
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
}
// 获取登录页面
resp, err := client.Get(loginURL)
if err != nil {
return nil, fmt.Errorf("获取登录页面失败: %w", err)
}
defer resp.Body.Close()
// 解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("解析HTML失败: %w", err)
}
// 寻找验证码图片
captchaImgSrc := ""
doc.Find("img").Each(func(i int, s *goquery.Selection) {
src, exists := s.Attr("src")
if !exists {
return
}
// 通过常见名称识别验证码图片
if strings.Contains(strings.ToLower(src), "captcha") ||
strings.Contains(strings.ToLower(src), "verify") ||
strings.Contains(strings.ToLower(src), "code") {
captchaImgSrc = src
}
})
if captchaImgSrc == "" {
// 没有找到验证码图片,尝试常规登录
return simpleFormLogin(loginURL, username, password)
}
// 如果是相对路径,转换为绝对路径
if !strings.HasPrefix(captchaImgSrc, "http") {
baseURL, _ := url.Parse(loginURL)
imgURL, _ := url.Parse(captchaImgSrc)
captchaImgSrc = baseURL.ResolveReference(imgURL).String()
}
// 下载验证码图片
imgResp, err := client.Get(captchaImgSrc)
if err != nil {
return nil, fmt.Errorf("获取验证码图片失败: %w", err)
}
defer imgResp.Body.Close()
imgData, err := ioutil.ReadAll(imgResp.Body)
if err != nil {
return nil, fmt.Errorf("读取验证码图片失败: %w", err)
}
// 保存图片到临时文件
tempFile, err := ioutil.TempFile("", "captcha-*.jpg")
if err != nil {
return nil, fmt.Errorf("创建临时文件失败: %w", err)
}
defer os.Remove(tempFile.Name())
defer tempFile.Close()
if _, err := tempFile.Write(imgData); err != nil {
return nil, fmt.Errorf("写入临时文件失败: %w", err)
}
// 预处理图片提高OCR识别率
processedImgPath := tempFile.Name() + "-processed.jpg"
if err := preprocessCaptchaImage(tempFile.Name(), processedImgPath); err != nil {
return nil, fmt.Errorf("预处理验证码图片失败: %w", err)
}
defer os.Remove(processedImgPath)
// 使用OCR识别验证码
captchaText, err := recognizeCaptcha(processedImgPath)
if err != nil {
// OCR失败,提示用户手动输入
fmt.Println("验证码识别失败,请手动输入验证码:")
fmt.Printf("验证码图片已保存在: %s\n", tempFile.Name())
fmt.Scanln(&captchaText)
} else {
fmt.Printf("识别到的验证码: %s\n", captchaText)
}
// 查找登录表单
formInfo, err := analyzeLoginForm(loginURL)
if err != nil {
return nil, fmt.Errorf("分析登录表单失败: %w", err)
}
// 准备表单数据
formData := url.Values{}
formData.Set(formInfo.UsernameField, username)
formData.Set(formInfo.PasswordField, password)
// 添加验证码
// 寻找可能的验证码字段名
captchaFieldName := ""
doc.Find("input").Each(func(i int, s *goquery.Selection) {
name, exists := s.Attr("name")
if !exists {
return
}
// 通过常见名称识别验证码输入字段
if strings.Contains(strings.ToLower(name), "captcha") ||
strings.Contains(strings.ToLower(name), "verify") ||
strings.Contains(strings.ToLower(name), "code") {
captchaFieldName = name
}
})
if captchaFieldName != "" {
formData.Set(captchaFieldName, captchaText)
} else {
// 尝试常见的验证码字段名
formData.Set("captcha", captchaText)
formData.Set("captcha_code", captchaText)
formData.Set("verify_code", captchaText)
formData.Set("verification", captchaText)
}
// 添加所有隐藏字段
for _, field := range formInfo.HiddenFields {
formData.Set(field.Name, field.Value)
}
// 发送登录请求
loginResp, err := client.PostForm(formInfo.Action, formData)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
defer loginResp.Body.Close()
// 检查登录是否成功
loginRespBody, _ := ioutil.ReadAll(loginResp.Body)
if !isLoginSuccessful(loginResp, string(loginRespBody)) {
return nil, errors.New("登录失败,可能验证码识别错误")
}
return client, nil
}
五、实战案例:模拟登录主流网站
5.1 Github登录实现
Github使用标准的表单登录,但有CSRF保护和可能的双因素认证:
func loginGithub(username, password string) (*http.Client, error) {
// 创建HTTP客户端
client := &http.Client{
Timeout: 30 * time.Second,
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
}
// 获取登录页面
resp, err := client.Get("https://github.com/login")
if err != nil {
return nil, fmt.Errorf("获取登录页面失败: %w", err)
}
defer resp.Body.Close()
// 解析HTML
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("解析HTML失败: %w", err)
}
// 提取authenticity_token
authenticityToken, exists := doc.Find("input[name='authenticity_token']").Attr("value")
if !exists {
return nil, errors.New("无法找到authenticity_token")
}
// 准备登录数据
loginData := url.Values{}
loginData.Set("login", username)
loginData.Set("password", password)
loginData.Set("authenticity_token", authenticityToken)
loginData.Set("commit", "Sign in")
// 发送登录请求
loginResp, err := client.PostForm("https://github.com/session", loginData)
if err != nil {
return nil, fmt.Errorf("登录请求失败: %w", err)
}
defer loginResp.Body.Close()
// 检查是否需要双因素认证
if loginResp.Request.URL.Path == "/sessions/two-factor" {
// 处理双因素认证
twoFactorDoc, err := goquery.NewDocumentFromReader(loginResp.Body)
if err != nil {
return nil, fmt.Errorf("解析双因素认证页面失败: %w", err)
}
// 提取新的authenticity_token
tfaToken, exists := twoFactorDoc.Find("input[name='authenticity_token']").Attr("value")
if !exists {
return nil, errors.New("无法找到双因素认证的authenticity_token")
}
// 提示用户输入双因素认证码
fmt.Println("需要双因素认证,请输入验证码:")
var tfaCode string
fmt.Scanln(&tfaCode)
// 准备双因素认证数据
tfaData := url.Values{}
tfaData.Set("authenticity_token", tfaToken)
tfaData.Set("otp", tfaCode)
// 发送双因素认证请求
tfaResp, err := client.PostForm("https://github.com/sessions/two-factor", tfaData)
if err != nil {
return nil, fmt.Errorf("双因素认证请求失败: %w", err)
}
defer tfaResp.Body.Close()
// 检查是否登录成功
if tfaResp.Request.URL.Path != "/" {
return nil, errors.New("双因素认证失败")
}
} else if loginResp.Request.URL.Path != "/" {
// 如果没有重定向到首页,登录可能失败
return nil, errors.New("登录失败")
}
return client, nil
}
5.2 处理OAuth登录
以Google OAuth登录为例:
func loginWithGoogleOAuth(targetURL string) (*http.Client, error) {
// 注意:自动化OAuth流程通常需要使用浏览器自动化工具如Selenium或Puppeteer
// 以下代码展示基本流程,但实际中可能需要更复杂的实现
// 创建HTTP客户端
client := &http.Client{
Timeout: 30 * time.Second,
Jar: func() *cookiejar.Jar {
jar, _ := cookiejar.New(nil)
return jar
}(),
// 禁用自动重定向以便手动处理OAuth流程
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
// 访问目标网站
resp, err := client.Get(targetURL)
if err != nil {
return nil, fmt.Errorf("访问目标网站失败: %w", err)
}
defer resp.Body.Close()
// 解析HTML寻找Google登录按钮
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("解析HTML失败: %w", err)
}
// 查找Google登录链接
var googleAuthURL string
doc.Find("a").Each(func(i int, s *goquery.Selection) {
href, exists := s.Attr("href")
if !exists {
return
}
if strings.Contains(href, "accounts.google.com") ||
strings.Contains(strings.ToLower(s.Text()), "google") {
googleAuthURL = href
}
})
if googleAuthURL == "" {
return nil, errors.New("未找到Google登录链接")
}
// 如果是相对路径,转换为绝对路径
if !strings.HasPrefix(googleAuthURL, "http") {
baseURL, _ := url.Parse(targetURL)
authURL, _ := url.Parse(googleAuthURL)
googleAuthURL = baseURL.ResolveReference(authURL).String()
}
// 访问Google认证URL
authResp, err := client.Get(googleAuthURL)
if err != nil {
return nil, fmt.Errorf("访问Google认证页面失败: %w", err)
}
defer authResp.Body.Close()
// 此时我们将到达Google登录页面
// 在实际场景中,应使用WebDriver或类似工具完成登录
fmt.Println("请在浏览器中完成Google登录,然后按Enter继续...")
fmt.Scanln()
// 在实际应用中,应实现以下步骤:
// 1. 使用WebDriver打开浏览器
// 2. 导航到googleAuthURL
// 3. 输入Google凭据并提交
// 4. 处理可能的权限确认页面
// 5. 等待重定向回目标网站
// 6. 从WebDriver中提取Cookie
// 简化起见,这里假设用户手动完成了登录,并提供了最终的Cookie
fmt.Println("请输入最终获得的Cookie字符串:")
var cookieStr string
fmt.Scanln(&cookieStr)
// 解析Cookie字符串并应用到客户端
// 注意:这是简化处理,实际场景可能需要更复杂的Cookie解析
cookies := parseCookieString(cookieStr)
u, _ := url.Parse(targetURL)
client.Jar.SetCookies(u, cookies)
// 创建新的客户端,启用自动重定向
finalClient := &http.Client{
Timeout: 30 * time.Second,
Jar: client.Jar,
}
// 访问目标网站验证登录
verifyResp, err := finalClient.Get(targetURL)
if err != nil {
return nil, fmt.Errorf("验证登录失败: %w", err)
}
defer verifyResp.Body.Close()
// 检查是否登录成功
verifyBody, _ := ioutil.ReadAll(verifyResp.Body)
if !isLoginSuccessful(verifyResp, string(verifyBody)) {
return nil, errors.New("OAuth登录失败")
}
return finalClient, nil
}
// 解析Cookie字符串
func parseCookieString(cookieStr string) []*http.Cookie {
var cookies []*http.Cookie
pairs := strings.Split(cookieStr, ";")
for _, pair := range pairs {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
parts := strings.SplitN(pair, "=", 2)
if len(parts) != 2 {
continue
}
cookie := &http.Cookie{
Name: parts[0],
Value: parts[1],
}
cookies = append(cookies, cookie)
}
return cookies
}
六、安全存储与使用凭证
6.1 凭证安全存储
安全存储用户凭证是爬虫开发的重要环节:
// 凭证管理器
type CredentialManager struct {
StoragePath string
key []byte // 加密密钥
}
func NewCredentialManager(path string, masterPassword string) (*CredentialManager, error) {
// 创建存储目录
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("创建存储目录失败: %w", err)
}
// 从主密码派生加密密钥
key := pbkdf2.Key([]byte(masterPassword), []byte("crawler-salt"), 4096, 32, sha256.New)
return &CredentialManager{
StoragePath: path,
key: key,
}, nil
}
// 加密数据
func (cm *CredentialManager) encrypt(data []byte) ([]byte, error) {
block, err := aes.NewCipher(cm.key)
if err != nil {
return nil, err
}
// 生成随机IV
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
return nil, err
}
// 创建加密器
stream := cipher.NewCFBEncrypter(block, iv)
// 加密数据
encrypted := make([]byte, len(data))
stream.XORKeyStream(encrypted, data)
// 返回IV+加密数据
return append(iv, encrypted...), nil
}
// 解密数据
func (cm *CredentialManager) decrypt(encrypted []byte) ([]byte, error) {
block, err := aes.NewCipher(cm.key)
if err != nil {
return nil, err
}
// 提取IV
if len(encrypted) < aes.BlockSize {
return nil, errors.New("加密数据太短")
}
iv := encrypted[:aes.BlockSize]
encrypted = encrypted[aes.BlockSize:]
// 创建解密器
stream := cipher.NewCFBDecrypter(block, iv)
// 解密数据
decrypted := make([]byte, len(encrypted))
stream.XORKeyStream(decrypted, encrypted)
return decrypted, nil
}
// 保存凭证
func (cm *CredentialManager) SaveCredentials(site string, username, password string) error {
// 准备凭证数据
credData := map[string]interface{}{
"site": site,
"username": username,
"password": password,
"updated": time.Now().Format(time.RFC3339),
}
// 序列化数据
jsonData, err := json.Marshal(credData)
if err != nil {
return fmt.Errorf("序列化凭证数据失败: %w", err)
}
// 加密数据
encrypted, err := cm.encrypt(jsonData)
if err != nil {
return fmt.Errorf("加密凭证数据失败: %w", err)
}
// 写入文件
siteFile := filepath.Join(cm.StoragePath, fmt.Sprintf("%s.enc", site))
return ioutil.WriteFile(siteFile, encrypted, 0600)
}
// 加载凭证
func (cm *CredentialManager) LoadCredentials(site string) (string, string, error) {
// 检查文件是否存在
siteFile := filepath.Join(cm.StoragePath, fmt.Sprintf("%s.enc", site))
if _, err := os.Stat(siteFile); os.IsNotExist(err) {
return "", "", errors.New("凭证文件不存在")
}
// 读取加密数据
encrypted, err := ioutil.ReadFile(siteFile)
if err != nil {
return "", "", fmt.Errorf("读取凭证文件失败: %w", err)
}
// 解密数据
decrypted, err := cm.decrypt(encrypted)
if err != nil {
return "", "", fmt.Errorf("解密凭证数据失败: %w", err)
}
// 解析JSON
var credData map[string]interface{}
if err := json.Unmarshal(decrypted, &credData); err != nil {
return "", "", fmt.Errorf("解析凭证数据失败: %w", err)
}
// 提取用户名和密码
username, _ := credData["username"].(string)
password, _ := credData["password"].(string)
return username, password, nil
}
6.2 环境变量与配置文件
使用环境变量存储敏感信息:
// 从环境变量或配置文件加载凭证
func loadCredentialsFromEnv(site string) (string, string, error) {
// 尝试从环境变量加载
usernameEnvVar := fmt.Sprintf("%s_USERNAME", strings.ToUpper(site))
passwordEnvVar := fmt.Sprintf("%s_PASSWORD", strings.ToUpper(site))
username := os.Getenv(usernameEnvVar)
password := os.Getenv(passwordEnvVar)
if username != "" && password != "" {
return username, password, nil
}
// 如果环境变量不存在,尝试从配置文件加载
configPath := filepath.Join(os.Getenv("HOME"), ".crawler", "config.json")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return "", "", errors.New("未找到凭证")
}
// 读取配置文件
configData, err := ioutil.ReadFile(configPath)
if err != nil {
return "", "", fmt.Errorf("读取配置文件失败: %w", err)
}
// 解析JSON
var config map[string]map[string]string
if err := json.Unmarshal(configData, &config); err != nil {
return "", "", fmt.Errorf("解析配置文件失败: %w", err)
}
// 查找站点凭证
if siteCred, ok := config[site]; ok {
username = siteCred["username"]
password = siteCred["password"]
if username != "" && password != "" {
return username, password, nil
}
}
return "", "", errors.New("未找到凭证")
}
6.3 避免凭证硬编码
在代码中安全使用凭证的最佳实践:
// 示例:如何在爬虫中安全使用凭证
func createSecureCrawler(site string) (*colly.Collector, error) {
// 创建Colly收集器
c := colly.NewCollector()
// 从安全源加载凭证
username, password, err := loadCredentialsFromEnv(site)
if err != nil {
// 尝试从加密存储加载
credManager, err := NewCredentialManager(
filepath.Join(os.Getenv("HOME"), ".crawler", "credentials"),
os.Getenv("CRAWLER_MASTER_PASSWORD"),
)
if err != nil {
return nil, err
}
username, password, err = credManager.LoadCredentials(site)
if err != nil {
return nil, err
}
}
// 设置登录回调
c.OnHTML("form[action*='login']", func(e *colly.HTMLElement) {
// 查找用户名和密码字段
var usernameField, passwordField string
e.ForEach("input", func(_ int, el *colly.HTMLElement) {
inputType := el.Attr("type")
inputName := el.Attr("name")
if inputType == "text" || inputType == "email" {
usernameField = inputName
} else if inputType == "password" {
passwordField = inputName
}
})
if usernameField != "" && passwordField != "" {
// 提取表单动作URL
action := e.Attr("action")
// 准备登录数据
loginData := map[string]string{
usernameField: username,
passwordField: password,
}
// 提交登录表单
err := e.Request.Post(action, loginData)
if err != nil {
log.Printf("登录失败: %v", err)
}
}
})
return c, nil
}
📝 练习与思考
-
基础练习:实现一个简单的凭证管理器,支持加密存储和读取凭证。
-
进阶练习:使用前面学到的内容,实现一个模块化的登录处理系统,能够自动判断登录类型并选择合适的登录方法。
-
思考题:
- 如何处理同一网站在不同国家/地区可能存在的不同登录机制?
- 对于需要验证码的网站,如何提高自动登录的成功率同时避免账号被锁定?
- 在爬虫系统中,如何平衡凭证安全性和使用便利性?何时应该使用自动登录,何时应该要求用户手动操作?
💡 小结
在本文中,我们深入探讨了模拟登录与会话维持的各种技术,包括:
- 不同登录系统的原理与识别方法
- 表单提交与参数处理的实现
- Cookie管理与会话维持策略
- 处理短信验证码、图形验证码等特殊验证机制
- 实现OAuth登录和处理多因素认证
- 安全存储与使用凭证的最佳实践
掌握这些技术后,您的爬虫将能够访问需要身份验证的内容,大大扩展了数据采集的范围。同时,我们也强调了在使用这些技术时遵守网站条款和法律法规的重要性,以及保护用户凭证安全的必要性。
下篇预告
在下一篇文章中,我们将深入探讨分布式爬虫架构,学习如何设计分布式爬虫系统,以及应对大规模数据采集的挑战。敬请期待!
👨💻 关于作者与Gopher部落
"Gopher部落"专注于Go语言技术分享,提供从入门到精通的完整学习路线。
🌟 为什么关注我们?
- 系统化学习路径:本系列14篇文章循序渐进,带你完整掌握Go爬虫开发
- 实战驱动教学:理论结合实践,每篇文章都有可操作的代码示例
- 持续更新内容:定期分享最新Go生态技术动态与大厂实践经验
- 专业技术社区:加入我们的技术交流群,与众多Go开发者共同成长
📱 关注方式
- 微信公众号:搜索 “Gopher部落” 或 “GopherTribe”
- 优快云专栏:点击页面右上角"关注"按钮
💡 读者福利
关注公众号回复 “Go爬虫” 即可获取:
- 完整Go爬虫学习资料
- 本系列示例代码
- 项目实战源码
1万+






