golang+elasticsearch 爬取网易云音乐评论(整理版本 1)

本贴最后更新于 2211 天前,其中的信息可能已经斗转星移

稍微整理了一下的爬取,在文件 V2.0 中

入口文件 main.go

package main

import (
	"go-wyy/models"
	"go-wyy/v2.0/engin"
	"io"
	"github.com/PuerkitoBio/goquery"
	"sync"
	"go-wyy/v2.0/fetcher"
	"go-wyy/v2.0/parse"
)

func main() {
	//ticker := time.Tick(200 * time.Millisecond)

	engin.Run(
		engin.Request{
			Url:           "",
			ParserComment: engin.NilParser,
			ParserSong: func(reader io.Reader) engin.ParseResult {
				doc, err := goquery.NewDocumentFromReader(reader)

				if err != nil {
					panic(err)
				}

				wg := &sync.WaitGroup{}
				result := engin.ParseResult{}
				doc.Find("ul[class=f-hide] a").Each(func(i int, selection *goquery.Selection) {
					/*开启协程插入数据库,并且开启协程请求每首歌的评论*/
					songIdUrl, _ := selection.Attr("href")
					title := selection.Text()
					var song models.Song
					//歌曲id
					songId := songIdUrl[9:len(songIdUrl)]
					song.SongId = songId

					///song?id=歌曲id
					song.SongUrlId = songIdUrl

					//歌曲标题
					song.Title = title
					//fmt.Printf("歌曲题目:%s\n", title)
					result.Items = append(result.Items, "歌曲信息:", song)
					offset := 0
					songComment := make(chan [][]byte, 100)
					go func(offset int) {
						for {
							fetcher.GetComments(songId, offset, offset+40, songComment, wg)
							offset += 40
						}

					}(offset)
					go parse.ReceiveComment(songComment, wg)
					wg.Add(2)
				})
				wg.Wait()
				return result
			},
		})
}

这里写的很不简洁

本来 ParseSong 后面的闭包函数应该写在 parse 文件中的,进行抽象化

这里很明显的是在 engine 中传入了用户 id

然后获取到页面 reader 然后解析进行 goquery 遍历

获取歌曲 id

最后 fetcher 评论数据

关于网易云的其他逻辑知乎上一抓一大把的最难部分还是加密破解那一块,

现在仍然存在请求过多 ip 被封的情况

v2.0 文件夹中直接运行 main.go 就能看到测试获取数据情况,如果需要存入数据库则需要在 receivecomment 函数中进行处理

参考简书部分


Github

---------------------------------分割线(以下是整理版本 1)----------------------------------------------------

目的和准备

目的:

为了知道自己歌单中每首歌的评论,然后通过歌曲 id 来检索这首歌的所有评论,并且想熟悉运用 golang 中的 channel 以及整个爬虫架构

准备:

语言:golang

编辑器: goland

处理:goquery/正则

存储:mysql(elasticsearch)

V1 版本(单机版,mysql 存储)

models

评论 struct[comment.go]

首先通过查看某一首音乐的请求获取到 ajax((eg: https://music.163.com/weapi/v1/resource/comments/R_SO_4_340394?csrf_token=)
`)

请求的 json, 通过返回的 json 数据构造出 struct

//每首歌的评论
type Commentt struct {
	Id         int64
	IsMusician bool  `json:"isMusician"`
	UserId     int32 `json:"userId"`
	//TopComments []string       `json:"topComments";gorm:"-"`
	MoreHot     bool           `json:"moreHot"`
	HotComments []*HotComments `json:"hotComments"`
	Code        int            `json:"code"`
	Comments    []*Comments    `json:"comments"`
	Total       int64          `json:"total"`
	More        bool           `json:"more"`
	SongId      string
}



//具体到每条评论(普通评论)
type Comments struct {
	Id                 int64
	User               *User        `json:"user"`
	BeReplied          []*BeReplied `json:"-"`
	Time               int64        `json:"time"`
	LikedCount         int          `json:"likedCount"`
	Liked              bool         `json:"liked"`
	CommentId          int64        `json:"commentId"`
	Content            string       `json:"content";gorm:"type:longtext"`
	IsRemoveHotComment bool         `json:"isRemoveHotComment"`
	Commentt           *Commentt
	CommenttID         int64
}

//热门评论
type HotComments struct {
	Id         int64
	User       *User        `json:"user"`
	BeReplied  []*BeReplied `json:"-"`
	Time       int64        `json:"time"`
	LikedCount int          `json:"likedCount"`
	Liked      bool         `json:"liked"`
	CommentId  int64        `json:"commentId"`
	Content    string       `json:"content";gorm:"type:longtext"`
	Commentt   *Commentt
	CommenttID int64
}

//评论的用户信息
type User struct {
	Id           int64
	LocationInfo *LocationInfo `json:"-"`
	UserType     int           `json:"userType"`
	ExpertTags   *ExpertTag    `json:"-"`
	UserId       int64         `json:"userId"`
	NickName     string        `json:"nickName"`
	Experts      *Expert       `json:"-"`
	AuthStatus   int           `json:"authStatus"`
	RemarkName   *RemarkName   `json:"-"`
	AvatarUrl    string        `json:"avatarUrl"`
	VipType      int           `json:"vipType"`
	Comments     *Comments
	CommentsID   int64
}


//答复用户的评论信息
type BeReplied struct {
	Id            int64
	User          *User  `json:"-"`
	UserID        int64
	Content       string `json:"content"`
	Status        int    `json:"status"`
	CommentsID    int64
	HotCommentsID int64
}

type LocationInfo struct {
}

type ExpertTag struct {
}

type Expert struct {
}

type RemarkName struct {
}


然后又查看了歌单信息看一了下网页 Dom 结构构造出如下 struct

歌曲列表 struct[songList.go]


type Song struct {
	Id          int64
	SongUrlId   string
	SongId      string
	Title       string
	DownloadUrl string
	UserId      string
}

type PlayList struct {
	Id     int64
	UserId string
	Songs  []Song
}

然后又找了一下歌曲下载的接口发现只能在 pc 端下载,于是在一篇博客中找到了一些关于 golang 爬取网易云音乐的具体实现点击此处

歌曲下载 struct[download.go]
type DownloadData struct {
	Data []*Data `json:"data"`
	Code int     `json:"code"`
}

type Data struct {
	Id        int64   `json:"id"`
	Url       string  `json:"url"`
	Br        int64   `json:"br"`
	Md5       string  `json:"md_5"`
	Code      int     `json:"code"`
	Expi      int     `json:"expi"`
	Type      string  `json:"type"`
	Gain      float64 `json:"gain"`
	Fee       int     `json:"fee"`
	Uf        *Uf     `json:"-"`
	Payed     int     `json:"payed"`
	Flag      int     `json:"flag"`
	CanExtend bool    `json:"can_extend"`
}

type Uf struct {
}


推荐一个很好用的 jsonstruct工具

以上就是所有数据库表

连接数据库[base.go]

我选择了 gorm 来操作数据库


/**如果缺少某些包则需要自己去go get*/

/**
    "fmt"
	"net/url"
	"github.com/jinzhu/gorm"(需要goget)
	"os"
	"time"
	"log"
	"crypto/md5"
	"encoding/hex"
	"database/sql"
*/

var DB *gorm.DB



var dbconf *conf.DbConf
var db_host,db_port,db_name,db_user,db_pass string
//初始化加载配置文件
func init() {

	dbconf, err := dbconf.Load("database.json")
	if err != nil {
		fmt.Println(err)
	}
	db_host = dbconf.DbHost
	db_port = dbconf.DbPort
	db_name = dbconf.DbName
	db_user = dbconf.DbUser
	db_pass = dbconf.DbPass
}

//自动建表
func SyncDB() {
	createDB()
	Connect()
	DB.
		Set("gorm:table_options", "ENGINE=InnoDB").
		AutoMigrate(
		&AdminUser{},
		&Commentt{},
		&Comments{},
		&HotComments{},
		&Song{},
		&User{},
	)
}

//添加后台admin_user(可以不用)
func AddAdmin() {
	var user AdminUser
	fmt.Println("please input username for system administrator")
	var name string
	fmt.Scanf("%s", &name)
	fmt.Println("please input password for system administrator")
	var password string
	fmt.Scanf("%s", &password)
	user.Username = name
	h := md5.New()
	h.Write([]byte(password))
	user.Password = hex.EncodeToString(h.Sum(nil))
	if err := DB.Create(&user).Error; err != nil {
		fmt.Println("admin create error,please run this application again")
		os.Exit(0)
	} else {
		fmt.Println("admin create finished")
	}
}

//连接数据库
func Connect() {
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&loc=%s&parseTime=true",
		db_user,
		db_pass,
		db_host,
		db_port,
		db_name,
		url.QueryEscape("Asia/Shanghai"))

	var err error

	DB, err = gorm.Open("mysql", dsn)
	if err != nil {
		log.Print("master detabase connect error:", err)
		os.Exit(0)
	}

	DB.SingularTable(true)
	DB.DB().SetMaxOpenConns(2000)
	DB.DB().SetMaxIdleConns(100)
	DB.DB().SetConnMaxLifetime(100 * time.Nanosecond)
}

//创建数据库
func createDB() {

	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/?charset=utf8mb4&loc=%s&parseTime=true", db_user, db_pass, db_host, db_port, url.QueryEscape("Asia/Shanghai"))
	sqlstring := fmt.Sprintf("CREATE DATABASE  if not exists `%s` CHARSET utf8mb4 COLLATE utf8mb4_general_ci", db_name)
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		panic(err.Error())
	}
	r, err := db.Exec(sqlstring)
	if err != nil {
		log.Println(err)
		log.Println(r)
	} else {
		log.Println("Database ", db_name, " created")
	}
	defer db.Close()

}

main.goinitArgs() 函数初始化(通过命令行添加后台用户,同步数据表)

eg:go run main.go -syncdb(这样就可以同步数据库表了)

func main() {
	initArgs()
}
func initArgs() {
	args := os.Args

	for _, v := range args {
		if v == "-syncdb" {
			models.SyncDB()
			os.Exit(0)
		}
		if v == "-admin" {
			models.Connect()
			models.AddAdmin()
			os.Exit(0)
		}
	}
}


service

encrypt[关于爬取网易云音乐评论的关键,已经有大佬在知乎解密]

首先在看完知乎的分析之后,于是去了解一下 aes 加密。
参考博客

总结来说:就是获得两个加密参数 paramsencSecKey

// EncParams 传入参数 得到加密后的参数和一个也被加密的秘钥
func EncParams(param string) (string, string, error) {
	// 创建 key
	secKey := createSecretKey(16)

	aes1, err1 := aesEncrypt(param, nonce)

	// 第一次加密 使用固定的 nonce
	if err1 != nil {
		return "", "", err1
	}
	aes2, err2 := aesEncrypt(aes1, secKey)
	// 第二次加密 使用创建的 key
	if err2 != nil {
		return "", "", err2
	}
	// 得到 加密好的 param 以及 加密好的key
	return aes2, rsaEncrypt(secKey, pubKey, modulus), nil
}

params 获取步骤:

1、构造一个特定 json 参数 param

eg: `{"rid": "` + rid + `","offset": "` + strOffset + `","total": "` + total + `","limit": "` + strLimit + `","csrf_token": ""}`

2、对 param 进行两次 aes 加密得到 params

keys = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/"
  • 从该 keys 中先获得一个 16 位的随机字符串 key 密钥 aesKey
// 创建指定长度的key(size=16)
func createSecretKey(size int) string {
	// 也就是从 a~9 以及 +/ 中随机拿出指定数量的字符拼成一个 size长的key
	rs := ""
	for i := 0; i < size; i++ {
		pos := rand.Intn(len([]rune(keys)))
		rs += keys[pos: pos+1]
	}
	return rs
}


nonce = "0CoJUm6Qyw8W8jud"
  • 第一次 aes 加密得到 aes1(param 参数与固定的 nonce 密钥)
  • 第二次 aes 加密(第一次加密 param 后得到的 aes1 与之前得到的 16 位 aesKey)

下面是具体的 aes 加密过程

// 通过 CBC模式的AES加密 用 sKey 加密 sSrc
func aesEncrypt(sSrc string, sKey string) (string, error) {
	iv := []byte(iv)
	block, err := aes.NewCipher([]byte(sKey))
	if err != nil {
		return "", err
	}
	//需要padding的数目
	padding := block.BlockSize() - len([]byte(sSrc))%block.BlockSize()
	//只要少于256就能放到一个byte中,默认的blockSize=16(即采用16*8=128, AES-128长的密钥)
	//最少填充1个byte,如果原文刚好是blocksize的整数倍,则再填充一个blocksize

	src := append([]byte(sSrc), bytes.Repeat([]byte{byte(padding)}, padding)...)


	model := cipher.NewCBCEncrypter(block, iv)
	cipherText := make([]byte, len(src))
	model.CryptBlocks(cipherText, src)
	// 最后使用base64编码输出
	return base64.StdEncoding.EncodeToString(cipherText), nil
}

  • 获取 encKey(模仿 python 的思路做了一遍)
python:
def rsaEncrypt(text, pubKey, modulus):

    text = text[::-1]

    rs = int(text.encode('hex'), 16)**int(pubKey, 16) % int(modulus, 16)

    return format(rs, 'x').zfill(256)



// 将 key 也加密
func rsaEncrypt(key string, pubKey string, modulus string) string {
	// 倒序 key
	rKey := ""
	for i := len(key) - 1; i >= 0; i-- {
		rKey += key[i: i+1]
	}
	// 将 key 转 ascii 编码 然后转成 16 进制字符串
	hexRKey := ""
	for _, char := range []rune(rKey) {
		hexRKey += fmt.Sprintf("%x", int(char))
	}
	// 将 16进制 的 三个参数 转为10进制的 bigint
	bigRKey, _ := big.NewInt(0).SetString(hexRKey, 16)
	bigPubKey, _ := big.NewInt(0).SetString(pubKey, 16)
	bigModulus, _ := big.NewInt(0).SetString(modulus, 16)
	// 执行幂乘取模运算得到最终的bigint结果
	bigRs := bigRKey.Exp(bigRKey, bigPubKey, bigModulus)
	// 将结果转为 16进制字符串
	hexRs := fmt.Sprintf("%x", bigRs)
	// 可能存在不满256位的情况,要在前面补0补满256位
	return addPadding(hexRs, modulus)
}

// 补0步骤
func addPadding(encText string, modulus string) string {
	ml := len(modulus)
	for i := 0; ml > 0 && modulus[i:i+1] == "0"; i++ {
		ml--
	}
	num := ml - len(encText)
	prefix := ""
	for i := 0; i < num; i++ {
		prefix += "0"
	}
	return prefix + encText
}

最后得到了 paramsencKey
最重要的一步就做完了[参考了知乎里许多大佬的代码和分析过程,收获颇多]

comment(获取评论)
/**
	id:歌曲id
	limit:每次请求条数
	offset:请求起始点
 */
func GetComments(id string, offset int, limit int) (comment *models.Commentt, err error) {

	rid := ""
	strOffset := strconv.Itoa(offset)
	strLimit := strconv.Itoa(limit)
	total := "true"
	initStr1 := `{"rid": "` + rid + `","offset": "` + strOffset + `","total": "` + total + `","limit": "` + strLimit + `","csrf_token": ""}`
	params1, key1, err := encrypt.EncParams(initStr1)
	if err != nil {
		panic(err)
	}
	// 发送POST请求得到最后包含url的结果
	comment, err = Comments(params1, key1, id)

	if err != nil {
		fmt.Println(err)
		return comment, err
	}
	return comment, err
}

//获取某首歌的评论
func Comments(params string, encSecKey string, id string) (comment *models.Commentt, err error) {
	client := &http.Client{}
	form := url.Values{}
	form.Set("params", params)
	form.Set("encSecKey", encSecKey)
	body := strings.NewReader(form.Encode())
	request, _ := http.NewRequest("POST", "http://music.163.com/weapi/v1/resource/comments/R_SO_4_"+id+"?csrf_token=", body)
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	request.Header.Set("Referer", "http://music.163.com")
	request.Header.Set("Content-Length", (string)(body.Len()))
	request.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.101 Safari/537.36")
	request.Header.Set("Cookie","_ntes_nnid=f2c441d1440900d6daa9611bab3dc027,1515122355101; _ntes_nuid=f2c441d1440900d6daa9611bab3dc027; __utmz=94650624.1515122355.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none); _iuqxldmzr_=32; __remember_me=true; JSESSIONID-WYYY=YXZtk7tOBJ4b3gOVrX2hl5%2BBriZyYVR5kNX3D3G5oWFRcY3J1cvGnMJRZx6JXgVSRNhFKO3O%5CmRiRACwWjrhBnkmK3dgGyTawDSAAmF%2Fct5T%2BhYVRy1BnxCgx%5CYrAUrjnQ8jEJQ1VHJTdNhqS4p9jVxHdRcc7iv5cQn649a%5CsBTc46WR%3A1515402120148; __utma=94650624.753127024.1515122355.1515218466.1515400320.9; __utmc=94650624; MUSIC_U=0120a3f48157438f759f2034b3925668ae731f8ae462a842927650798e0d663c97d1b459676c0cc693e926b3c390b8ba205ba14613b02d6c02d1ccf53040f6087d9739a0cccfd7eebf122d59fa1ed6a2; __csrf=5aa926378397ed694496ebf6486c5dfc; __utmb=94650624.5.10.1515400320")
	// 发起请求
	response, reqErr := client.Do(request)
	// 错误处理
	if reqErr != nil {
		fmt.Println("Fatal error ", reqErr.Error())
		return comment, reqErr
	}
	defer response.Body.Close()

	if response.StatusCode!=http.StatusOK{
		fmt.Println("Error:status code", response.StatusCode)
		return nil, fmt.Errorf("wrong status code:%d", response.StatusCode)
	}


	resBody, _ := ioutil.ReadAll(response.Body)

	err = json.Unmarshal(resBody, &comment)
	if err != nil {
		fmt.Println(err)
		return comment, err
	}
	return comment, nil

}


conf(加载数据库配置文件)
type DbConf struct {
	DbHost string `json:"db_host"`
	DbPort string `json:"db_port"`
	DbUser string `json:"db_user"`
	DbPass string `json:"db_pass"`
	DbName string `json:"db_name"`
}

func (this *DbConf) Load(filename string,) (dbconf *DbConf,err error) {
	var dbconff *DbConf
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		fmt.Println("read file error")
		return dbconf,err
	}
	datajson := []byte(data)

	err = json.Unmarshal(datajson, &dbconff)

	if err != nil {

		return dbconff, err

	}
	return dbconff,nil
}

songs(歌曲下载和获取用户歌单)
/**
ids: [歌曲id]
rate :320000 普通品质
	  640000 高级品质
      160000 低级品质
*/
func GetDownloadUrl(id string, rate string) (data *models.DownloadData, err error) {
	var DownloadData *models.DownloadData
	initStr := `{"ids": "` +"["+ id +"]"+ `", "br": "` + rate + `", "csrf_token": ""}`
	params, key, err := encrypt.EncParams(initStr)
	if err != nil {
		panic(err)
	}
	DownloadData, err = Download(params, key)
	return DownloadData, err
}

// Download 根据传入id返回生成的mp3地址
func Download(params string, encSecKey string) (data *models.DownloadData, err error) {
	var DownloadData *models.DownloadData
	client := &http.Client{}
	form := url.Values{}
	form.Set("params", params)
	form.Set("encSecKey", encSecKey)
	body := strings.NewReader(form.Encode())
	time.Sleep(500*time.Microsecond)
	request, _ := http.NewRequest("POST", "http://music.163.com/weapi/song/enhance/player/url?csrf_token=", body)
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	request.Header.Set("Content-Length", (string)(body.Len()))
	request.Header.Set("Referer", "http://music.163.com")
	// 发起请求
	response, reqErr := client.Do(request)
	// 错误处理
	if reqErr != nil {
		fmt.Println("Fatal error ", reqErr.Error())
		return DownloadData, err
	}
	defer response.Body.Close()
	resBody, _ := ioutil.ReadAll(response.Body)
	err = json.Unmarshal(resBody, &DownloadData)
	if err != nil {
		panic(err)
	}
	return DownloadData, err
}






/**
	userId 用户Id 该函数用于获取到用户歌单后
	获取每首歌的评论
*/


func Songs(userId string) {
	req, err := http.NewRequest("GET", "http://music.163.com/playlist?id="+userId, nil)

	if err != nil {
		panic(err)
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36")
	req.Header.Set("Referer", "http://music.163.com/")
	req.Header.Set("Host", "music.163.com")

	c := &http.Client{}
	res, err := c.Do(req)
	if err != nil {
		panic(err)
	}

	doc, err := goquery.NewDocumentFromReader(res.Body)

	if err != nil {
		panic(err)
	}

	g := 0
	wg := &sync.WaitGroup{}

	doc.Find("ul[class=f-hide] a").Each(func(i int, selection *goquery.Selection) {
		/*开启协程插入数据库,并且开启协程请求每首歌的评论*/
		songIdUrl, _ := selection.Attr("href")
		title := selection.Text()
		var song models.Song
		//歌曲id
		songId := songIdUrl[9:len(songIdUrl)]
		song.SongId = songId

		///song?id=歌曲id
		song.SongUrlId = songIdUrl

		//歌曲标题
		song.Title = title


		song.UserId = userId

		go comment.GetAllComment(songId, wg)

		g++
		wg.Add(1)
	})
	wg.Wait()
}

以上是之前写爬去网易云音乐评论的第一个版本
项目目录结构为

go-wyy
    |-models
        admin_user.go
        base.go     (连接数据库,建立数据库,自动建表)
        comment.go(评论的struct)
        download.go(下载的struct)
        songList.go(歌单的struct)
    |-service
        |-comment
            comment.go(获取评论的api)
        |-conf
            load.go(加载配置数据库文件)
        |-encrypt
            encrypt.go(params和encKe y的加密)
        |-songs
            download.go(下载歌曲的api)
            songs.go(获取歌单列表每首歌id)
    main.go (自定义逻辑,如果想要存入数据库必须要有models.connect(),以及initArgs()自动建表或者自己手动建表)
  • 爬虫

    网络爬虫(Spider、Crawler),是一种按照一定的规则,自动地抓取万维网信息的程序。

    106 引用 • 275 回帖
  • golang

    Go 语言是 Google 推出的一种全新的编程语言,可以在不损失应用程序性能的情况下降低代码的复杂性。谷歌首席软件工程师罗布派克(Rob Pike)说:我们之所以开发 Go,是因为过去 10 多年间软件开发的难度令人沮丧。Go 是谷歌 2009 发布的第二款编程语言。

    497 引用 • 1387 回帖 • 283 关注

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...