Go语言爬虫系列教程 实战项目JS逆向实现CSDN文章导出教程

发布于:2025-05-16 ⋅ 阅读:(13) ⋅ 点赞:(0)

爬虫实战:JS逆向实现CSDN文章导出教程

在这篇教程中,我将带领大家实现一个实用的爬虫项目:导出你在CSDN上发布的所有文章。通过分析CSDN的API请求签名机制,我们将绕过平台限制,获取自己的所有文章内容,并以Markdown格式保存到本地。## 1.基础知识:什么是JS逆向?

1.1 JS逆向的概念

JavaScript逆向工程是指分析网站的前端JavaScript代码,理解其加密、签名等机制,并用其他编程语言重新实现的过程。

1.2 为什么需要逆向?

现代网站通常会对API请求进行保护:

  • 签名验证:防止恶意请求
  • 频率限制:防止过度访问
  • 身份验证:确保请求来源合法## 2. CSDN API分析

2.1 准备工作

在开始分析之前,让我们做好准备工作:

环境准备:

  1. 浏览器:推荐使用Chrome或Firefox,开发者工具功能强大
  2. CSDN账户:需要登录才能访问文章管理页面
  3. 基础知识:了解HTTP请求、JSON格式、浏览器开发者工具的基本使用获取登录Cookie:

重要提示:Cookie是你的登录凭证,相当于临时身份证,不要泄露给他人!

  1. 打开CSDN网站并登录
  2. 按F12打开开发者工具
  3. 切换到"网络"标签页(Network)
  4. 刷新页面,找到任意请求
  5. 在请求头中找到Cookie字段,复制其值

2.2 获取API和观察API请求

为了避免文章格式错乱,我是通过内容管理页面来获取文稿的原始数据。通过浏览器开发者工具,发现文章列表的请求地址为https://bizapi.csdn.net/blog/phoenix/console/v1/article/list?page=2&pageSize=20 ,请求参数主要有两个page=2pageSize=20

在这里插入图片描述

接着我们看下请求头,发现请求包含以下特殊的请求头:

  • X-Ca-Key:API密钥
  • X-Ca-Nonce:随机字符串
  • X-Ca-Signature:请求签名
  • X-Ca-Signature-Headers:用于签名的请求头字段
    在这里插入图片描述

2.3 使用Postman模拟请求

我们先使用Postman模拟请求,看下那些值是必须的,经过测试,发现这四个值是不能缺少的
在这里插入图片描述

2.4 定位加密算法

经过多次刷新请求 ,发现 X-Ca-Key 为固定值203803574 , x-ca-signature-headers 也是固定值x-ca-key,x-ca-nonce ,这是一个初步分析的过程。这些请求参数一般都是通过JS生成,所以我们现在需要去分析下Js , 使用浏览器开发者工具,搜索关键词
在这里插入图片描述

因为进过测试已经知道X-Ca-Key的值是固定的203803574 ,我们搜索这个值,(当然也可以搜索 X-Ca-KeyX-Ca-NonceX-Ca-SignatureX-Ca-Signature-Headers 这些请求参数的名字,这个需要根据网站灵活变动)。 经过搜索我们发现了好几个结果,这个需要根据代码来判断,加密方法到底在哪个js文件中。
在这里插入图片描述

点击搜索结果,我们发现 X-Ca-NonceLe函数生成,X-Ca-Signature 是通过Ee函数生成的
在这里插入图片描述
断点是调试的利器,可以让代码执行暂停,观察变量值。

  1. 找到可能的加密函数后,在行号左侧点击设置断点
  2. 重新发起请求,代码会在断点处暂停
  3. 在控制台中输入变量名,查看其值
  4. 使用单步执行功能,跟踪代码执行过程

我们可以通过添加断点,来看下这些变量的具体值
在这里插入图片描述

注意:断点是一个很有用的功能,一定要善于使用我们可以在这个JS文件中去寻找Le函数和Ee函数,当然如果你没有耐心,因为我们已经添加了断点,可以使用浏览器的控制台去输入函数名查看函数的具体实现和具体位置。

在这里插入图片描述

直接点击输出结果,会自动跳转到代码的位置,
在这里插入图片描述

通过分析网站的JavaScript代码,我们发现了签名生成的核心代码:

const Pe = e => {
    let t = {};
    for (let a in e) {
        let c = a.toLowerCase();
        c.startsWith("x-ca-") && ("x-ca-signature" !== c && "x-ca-signature-headers" !== c && "x-ca-key" !== c && "x-ca-nonce" !== c || (t[c] = e[a]))
    }
    return t
}

// ... 其他代码 ...

const Ee = ({method, url, appSecret, accept, date, contentType, params, headers}) => {
    let stringToSign = "";
    
    // 构建待签名字符串
    stringToSign += method + "\n";           // HTTP方法
    stringToSign += accept + "\n";           // Accept头
    stringToSign += "\n";                    // 空行
    stringToSign += contentType + "\n";      // Content-Type
    stringToSign += date + "\n";            // 日期
    
    // 添加特定头部
    // ... 处理headers ...
    
    // 添加URL和参数
    // ... 处理URL和查询参数 ...
    
    // 使用HMAC-SHA256计算签名
    const signature = CryptoJS.HmacSHA256(stringToSign, appSecret);
    return signature.toString(CryptoJS.enc.Base64);
}

2.5 分析加密函数

通过分析提供的JavaScript代码,可以看2个关键头部字段的生成方式: X-Ca-Nonce是一个随机生成的UUID,主要依赖于Ue函数,该函数使用随机数替换UUID模板中的’x’和’y’字符。Le函数会检查是否已有nonce,如果没有则生成一个新的。X-Ca-Signature是请求内容的HMAC-SHA256签名,生成过程如下:

1.构建待签名的字符串,包含:

method: s,  //请求方法(method)
url: r,   //API URL
 accept: t,  //Accept头
 params: n,  //请求参数
 date: a,   //日期
 contentType: o, 
 headers: e.headers,  //请求头
 appSecret: l  //加密Secret(固定值)

2.提取特定的请求头
3.对URL参数进行排序
4.使用HMAC-SHA256算法对构建的字符串进行加密
5.将结果转为Base64编码## 2. Go语言实现签名算法

根据分析的签名生成逻辑,我们用Go语言实现相同的功能:

2.1 UUID生成

首先实现一个生成UUID格式随机字符串的函数,用于X-Ca-Nonce:

// 生成UUID格式的随机字符串
func generateUUID() string {
	rand.Seed(time.Now().UnixNano())
	uuid := "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx"
	result := ""
	
	for _, char := range uuid {
		if char == 'x' || char == 'y' {
			randomInt := rand.Intn(16)
			var value int
			if char == 'x' {
				value = randomInt
			} else {// char == 'y'
                // y的值必须是8、9、A、B中的一个
				value = (randomInt & 0x3) | 0x8
			}
			result += fmt.Sprintf("%x", value)
		} else {
			result += string(char)
		}
	}
	
	return result
}

// 生成X-Ca-Nonce
func generateNonce() string {
	return generateUUID()
}

2.2 签名生成

然后实现签名生成算法:

// 生成CSDN API签名
func generateSignature(method, requestURL, appSecret, accept string, headers map[string]string) string {
	
	// 1. 解析URL,分离路径和查询参数
	parsedURL, _ := url.Parse(requestURL)
	path := parsedURL.Path   // 获取路径部分
	query := parsedURL.Query()     // 获取查询参数

    // 2. 构建待签名的字符串(严格按照顺序)
	stringToSign := strings.ToUpper(method) + "\n" // HTTP方法,必须大写
	stringToSign += accept + "\n"    // Accept头
	stringToSign += "\n" // 空行

	// 签名时Content-Type需要设为空
	stringToSign += "\n"

	stringToSign += "\n" // 日期为空

	// 添加特定请求头
	stringToSign += "x-ca-key:" + headers["x-ca-key"] + "\n"
	stringToSign += "x-ca-nonce:" + headers["x-ca-nonce"] + "\n"

	// 添加路径
	stringToSign += path

	// 如果有查询参数,添加排序后的查询参数
	if len(query) > 0 {
		queryKeys := make([]string, 0, len(query))
		for k := range query {
			queryKeys = append(queryKeys, k)
		}
		sort.Strings(queryKeys)

		queryParts := []string{}
		for _, key := range queryKeys {
			values := query[key]
			for _, value := range values {
				queryParts = append(queryParts, key+"="+value)
			}
		}

		queryString := strings.Join(queryParts, "&")

		fmt.Println(queryString)

		stringToSign += "?" + queryString
	}

	// 使用HMAC-SHA256计算签名
	h := hmac.New(sha256.New, []byte(appSecret))
	h.Write([]byte(stringToSign))
	signature := base64.StdEncoding.EncodeToString(h.Sum(nil))

	return signature
}

2.3 生成完整请求头

接下来实现生成完整请求头的函数:

func generateRequestHeaders(method, requestURL, appKey, appSecret, accept string) map[string]string {
	// 创建基本头部
	headers := map[string]string{
		"Accept":   accept,
		"x-ca-key": appKey,
	}

	// 生成nonce
	nonce := generateNonce()
	headers["x-ca-nonce"] = nonce

	// 生成签名
	signature := generateSignature(method, requestURL, appSecret, accept, headers)
	headers["x-ca-signature"] = signature

	// 添加签名头列表
	headers["x-ca-signature-headers"] = "x-ca-key,x-ca-nonce"

	return headers
}
```## 3.项目具体实现实现步骤:

1. 通过文章列表API`https://bizapi.csdn.net/blog/phoenix/console/v1/article/list` 获取到文章id
2. 通过文章ID, 访问文章详情API`https://bizapi.csdn.net/blog-console-api/v3/editor/getArticle?id=[id] `
3. 通过API获取到文章的 title、markdowncontent、content  ,把文章保存到本地
### 3.1 常量定义

```go
package main

const (
    // CSDN API相关常量
    APP_KEY    = "203803574"                                    // 固定的API密钥
    APP_SECRET = "9znpamsyl2c7cdrr9sas0le9vbc3r6ba"            // 用于签名的密钥
    ACCEPT     = "application/json, text/plain, */*"           // Accept头
    OUTPUT_DIR = "Output/"                                      // 输出目录
    
    // 请替换为你自己的Cookie!
    // 获取方法:登录CSDN后,在开发者工具的Network标签中找到任意请求的Cookie头
    COOKIE = `你的CSDN_Cookie_这里`
 
)

3.2 实现HTTP请求函数

现在,我们需要创建一个函数来发送带有正确签名的HTTP请求:

func CSDNGetHttp(requestURL string) string {
	method := "GET"

	headers := generateRequestHeaders(method, requestURL, APP_KEY, APP_SECRET, ACCEPT)

	client := &http.Client{}
	req, err := http.NewRequest(method, requestURL, nil)

	if err != nil {
		fmt.Println(err)
		return ""
	}
	
	// 添加所有必要的请求头
	req.Header.Add("accept", "application/json, text/plain, */*")
	req.Header.Add("accept-encoding", "gzip, deflate, br, zstd")
	req.Header.Add("accept-language", "zh-CN,zh;q=0.9")
	req.Header.Add("cookie", Cookie) // 使用预设的Cookie
	req.Header.Add("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
	req.Header.Add("x-ca-key", "203803574")
	req.Header.Add("x-ca-nonce", headers["x-ca-nonce"])
	req.Header.Add("x-ca-signature", headers["x-ca-signature"])
	req.Header.Add("x-ca-signature-headers", "x-ca-key,x-ca-nonce")

	res, err := client.Do(req)
	if err != nil {
		fmt.Println(err)
		return ""
	}
	defer res.Body.Close()

	body, err := io.ReadAll(res.Body)
	if err != nil {
		fmt.Println(err)
		return ""
	}

	return string(body)
}

3.3 定义数据结构

为了方便解析API返回的JSON数据,我们定义了两个结构体:

// 文章列表结构
type ArticleList struct {
	Code int `json:"code"`
	Data struct {
		List []struct {
			ArticleId string `json:"articleId"` // 文章ID
			Title     string `json:"title"`     // 文章标题
		} `json:"list"`
		Page  int `json:"page"`  // 页码
		Size  int `json:"size"`  // 每页大小
		Total int `json:"total"` // 总记录数
	} `json:"data"`
}

// 文章详情结构
type Article struct {
	Code    int    `json:"code"`
	TraceId string `json:"traceId"`
	Data    struct {
		ArticleId       string `json:"article_id"`
		Title           string `json:"title"`           // 文章标题
		Content         string `json:"content"`         // HTML内容
		Markdowncontent string `json:"markdowncontent"` // Markdown内容
	} `json:"data"`
	Msg string `json:"msg"`
}

3.4 实现文章导出功能

最后,我们实现完整的文章导出功能:

func main() {
	ArticleIds := make([]string, 0)

	// 获取第一页文章列表
	content := CSDNGetHttp("https://bizapi.csdn.net/blog/phoenix/console/v1/article/list?page=1&pageSize=20")

	var firstArticleList ArticleList
	err := json.Unmarshal([]byte(content), &firstArticleList)
	if err != nil {
		fmt.Println(err)
	}

	// 提取第一页文章ID
	for _, article := range firstArticleList.Data.List {
		ArticleIds = append(ArticleIds, article.ArticleId)
	}

	total := firstArticleList.Data.Total
	pageMax := 0

	// 计算总页数
	if total > 20 {
		if total%20 == 0 {
			pageMax = total / 20
		} else {
			pageMax = total/20 + 1
		}

		// 获取剩余页的文章
		for page := 2; page <= pageMax; page++ {
			requestURL := fmt.Sprintf("https://bizapi.csdn.net/blog/phoenix/console/v1/article/list?page=%d&pageSize=20", page)
			content1 := CSDNGetHttp(requestURL)

			var articleList ArticleList
			err := json.Unmarshal([]byte(content1), &articleList)
			if err != nil {
				fmt.Println(err)
			}

			for _, article := range articleList.Data.List {
				fmt.Println(article.Title)
				ArticleIds = append(ArticleIds, article.ArticleId)
			}
		}
	}

	fmt.Println("总文章数:", len(ArticleIds))

	// 创建输出目录
	os.MkdirAll(Output, os.ModePerm)

	// 获取每篇文章详情并保存
	for _, articleId := range ArticleIds {
		rawUrl := fmt.Sprintf("https://bizapi.csdn.net/blog-console-api/v3/editor/getArticle?id=%s", articleId)
		content := CSDNGetHttp(rawUrl)

		var article Article
		err := json.Unmarshal([]byte(content), &article)
		if err != nil {
			fmt.Println(err)
		}
		
		title := article.Data.Title
		markdownContent := article.Data.Markdowncontent

		err = savetoMdfile(Output+title, markdownContent)
		if err != nil {
			fmt.Println(err)
		}

		// 避免请求频率过高
		time.Sleep(4 * time.Second)
	}
}

// 将文章保存为Markdown文件
func savetoMdfile(title, markdownContent string) error {
	// 构建文件名
	filename := fmt.Sprintf("%s.md", title)

	// 创建或打开文件
	file, err := os.Create(filename)
	if err != nil {
		return fmt.Errorf("创建文件失败: %w", err)
	}
	defer file.Close()

	// 写入Markdown内容
	_, err = file.WriteString(markdownContent)
	if err != nil {
		return fmt.Errorf("写入文件失败: %w", err)
	}

	fmt.Printf("文件 %s 保存成功\n", filename)
	return nil
}

3.5 运行程序

将以上代码整合成一个完整的Go程序,设置好以下常量:

const (
	APP_KEY    = "203803574"
	APP_SECRET = "9znpamsyl2c7cdrr9sas0le9vbc3r6ba"
	ACCEPT     = "application/json, text/plain, */*"
	Output     = "Output/"
	Cookie     = `你的CSDN Cookie` // 替换为你的CSDN登录Cookie
)

然后编译并运行程序:

go build -o csdn_exporter
./csdn_exporter

程序将会:

  1. 获取你CSDN博客的所有文章
  2. 下载每篇文章的Markdown内容
  3. 将文章保存到Output目录下的Markdown文件中

4. 注意事项

  1. Cookie获取:请使用浏览器开发者工具获取自己的CSDN Cookie
  2. 请求频率:为避免触发CSDN的反爬机制,程序每获取一篇文章后会等待4秒
  3. 文件命名:文件名使用文章标题,可能需要处理标题中的特殊字符
  4. 合法使用:本教程仅用于导出自己的CSDN文章,请勿用于非法目的

免责声明:本教程仅用于学习和研究目的,请遵守CSDN的用户协议和相关法律法规,仅导出自己的文章内容。作者不对任何滥用行为负责。


网站公告

今日签到

点亮在社区的每一天
去签到