爬蟲實戰: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的用戶協議和相關法律法規,僅導出自己的文章內容。作者不對任何濫用行為負責。