爬虫实战:JS逆向实现CSDN文章导出教程
在这篇教程中,我将带领大家实现一个实用的爬虫项目:导出你在CSDN上发布的所有文章。通过分析CSDN的API请求签名机制,我们将绕过平台限制,获取自己的所有文章内容,并以Markdown格式保存到本地。## 1.基础知识:什么是JS逆向?
1.1 JS逆向的概念
JavaScript逆向工程是指分析网站的前端JavaScript代码,理解其加密、签名等机制,并用其他编程语言重新实现的过程。
1.2 为什么需要逆向?
现代网站通常会对API请求进行保护:
- 签名验证:防止恶意请求
- 频率限制:防止过度访问
- 身份验证:确保请求来源合法## 2. CSDN API分析
2.1 准备工作
在开始分析之前,让我们做好准备工作:
环境准备:
- 浏览器:推荐使用Chrome或Firefox,开发者工具功能强大
- CSDN账户:需要登录才能访问文章管理页面
- 基础知识:了解HTTP请求、JSON格式、浏览器开发者工具的基本使用获取登录Cookie:
重要提示:Cookie是你的登录凭证,相当于临时身份证,不要泄露给他人!
- 打开CSDN网站并登录
- 按F12打开开发者工具
- 切换到"网络"标签页(Network)
- 刷新页面,找到任意请求
- 在请求头中找到
Cookie
字段,复制其值
2.2 获取API和观察API请求
为了避免文章格式错乱,我是通过内容管理
页面来获取文稿的原始数据。通过浏览器开发者工具,发现文章列表的请求地址为https://bizapi.csdn.net/blog/phoenix/console/v1/article/list?page=2&pageSize=20
,请求参数主要有两个page=2
和pageSize=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-Key
、X-Ca-Nonce
、X-Ca-Signature
、X-Ca-Signature-Headers
这些请求参数的名字,这个需要根据网站灵活变动)。 经过搜索我们发现了好几个结果,这个需要根据代码来判断,加密方法到底在哪个js文件中。
点击搜索结果,我们发现 X-Ca-Nonce
是Le
函数生成,X-Ca-Signature
是通过Ee
函数生成的
断点是调试的利器,可以让代码执行暂停,观察变量值。
- 找到可能的加密函数后,在行号左侧点击设置断点
- 重新发起请求,代码会在断点处暂停
- 在控制台中输入变量名,查看其值
- 使用单步执行功能,跟踪代码执行过程
我们可以通过添加断点,来看下这些变量的具体值
注意:断点是一个很有用的功能,一定要善于使用我们可以在这个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-TypestringToSign += 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 URLaccept: 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 intif 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,}// 生成noncenonce := 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 mainconst (// 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) // 使用预设的Cookiereq.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"` // 文章IDTitle 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 ArticleListerr := json.Unmarshal([]byte(content), &firstArticleList)if err != nil {fmt.Println(err)}// 提取第一页文章IDfor _, article := range firstArticleList.Data.List {ArticleIds = append(ArticleIds, article.ArticleId)}total := firstArticleList.Data.TotalpageMax := 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 ArticleListerr := 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 Articleerr := json.Unmarshal([]byte(content), &article)if err != nil {fmt.Println(err)}title := article.Data.TitlemarkdownContent := article.Data.Markdowncontenterr = 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
程序将会:
- 获取你CSDN博客的所有文章
- 下载每篇文章的Markdown内容
- 将文章保存到Output目录下的Markdown文件中
4. 注意事项
- Cookie获取:请使用浏览器开发者工具获取自己的CSDN Cookie
- 请求频率:为避免触发CSDN的反爬机制,程序每获取一篇文章后会等待4秒
- 文件命名:文件名使用文章标题,可能需要处理标题中的特殊字符
- 合法使用:本教程仅用于导出自己的CSDN文章,请勿用于非法目的
免责声明:本教程仅用于学习和研究目的,请遵守CSDN的用户协议和相关法律法规,仅导出自己的文章内容。作者不对任何滥用行为负责。