Appium 爬取安卓版网易云音乐单曲评论(热门评论,近期热评,所有评论)

程序概述

使用appium 爬取网易云音乐歌曲评论。

该博客内容只是对使用appium 爬取网易云音乐评论的一个概述。如果您对该程序感兴趣可以关注我,欢迎大家交流经验

网易云音乐的评论可以通过网页版进行爬取。但是目前网页版的评论内容有以下问题:

1、就是当翻页到500页左右的时候,服务端就不再返回正确的数据。返回的都是重复数据,知道最后500页才正确。
2、网页端的api目前比较陈旧,热门评论显示不全,近期热评不显示。

使用 appium 爬取评论能解决以上两个问题,但是目前依旧存在的问题:

1、由于app客户端返回的评论数据是加密的(需****分析解密),无法通过抓包直接分析返回的json数据。只能在界面上查找自己所需要的元素。这样整个程序处理的速度比较慢。
2、由于需要滑动界面,滑动的界面可能会切开一个完整的评论(完整的评论包括,昵称,点赞数,内容,时间,可能包括回复内容)。这样可能获取的数据存在不完整的情况。可能丢失回复的内容。
3、依托appium 、模拟器、或者真机,程序的稳定性比较差。

环境搭建

请自行使用搜索引擎python Appium环境搭建,这里不再赘述。需要以下环境:
1、Python3.6 以上
2、JDK1.8以上
3、安卓SDK
4、Appium(可用桌面版,比较方便)
5、安卓模拟器或安卓真机。模拟器:雷电3.44,分辨率 宽:1080 高:1920 DPI:160-240 (DPI 设置很重要,可以控制页面能够显示的评论数,最好是160左右)
6、mysql
7、网易云安卓客户端,V5.8.3

代码实现

这里讲述大概的实现流程。

配置文件

包含以下主要内容
1、数据库的连接配置
2、appium启动安卓程序的配置
3、appium需要点击和查找的元素的路径和节点名称
数据库连接代码

HOST = '127.0.0.1'
USER = 'root'
PASSWD = 'root'
PROT = 3306
DBNAME = 'music_comments'
CHARSET = 'utf8mb4'

启动安卓程序配置代码(根据模拟器或真机需要调整,以下为雷电模安卓拟器的实现)

APPIUM_ADDR = "http://127.0.0.1:4723/wd/hub"
DESIRED_CAPS = {
    "platformName": "Android",
    # "platformVersion": "6.0.1", #mumu模拟器
    "platformVersion": "5.1.1",
    "appPackage": "com.netease.cloudmusic",
    "appActivity": ".activity.MainActivity",
    "deviceName": "emulator",
    # "deviceName": "127.0.0.1:7555", mumu模拟器的地址
    "noReset": True,
    "unicodeKeyboard": True,  # 输入中文。
    "resetKeyboard": True  # 输入中文。
}

查找节点元素的配置
通过xpath查找方法或id查找确定元素的内容

# # find_ele_str
SEARCH_BTN_XPATH_S = "//android.widget.TextView[@content-desc='搜索']"  # 搜索按钮路径
INPUT_BOX_ID_S = 'com.netease.cloudmusic:id/search_src_text'  # 输入框路径
SEARCH_RES_TAB_CLASSNAME_M = 'android.support.v7.app.ActionBar$Tab'  # 搜索结果的tab页
SEARCH_RES_SONG_MORE_INFO_ID_S = 'com.netease.cloudmusic:id/a'  # 搜索结果的歌曲的三个点
MORE_INFO_OF_COMMENT_XPATH_S = "//android.widget.TextView[contains(@text,'评论')]"  # 更多信息里的评论按钮

RETURN_CUR_PAGE_TOP_ID_S = "com.netease.cloudmusic:id/m_"  # 返回评论界面的置顶
VIEW2VIEW_XPATH_M = "//android.view.View[2]/*"  # 评论总数
TITLE_SONG_NAME_ID_S = "com.netease.cloudmusic:id/a8p"  # 歌曲名字
TITLE_SINGER_NAME_ID_S = "com.netease.cloudmusic:id/a8q"  # 歌手名字
LISTVIEW_XPATH_M = "//android.widget.ListView/android.widget.RelativeLayout"  # 所有评论的LISTVIEW
COMMENT_TYPE_ID_S = "com.netease.cloudmusic:id/a8y"  # 获取歌曲类别的 。数据库中commentType 0 代表最新评论,1 代表精彩评论,2 代表近期热评
NICK_NAME_ID_S = "com.netease.cloudmusic:id/a80"  # 昵称
COMMENT_TIME_ID_S = "com.netease.cloudmusic:id/a8s"  # 评论时间
LIKE_CNT_ID_S = "com.netease.cloudmusic:id/a7z"  # 点赞数
CONTENT_TEXT_ID_S = "com.netease.cloudmusic:id/a81"  # 评论内容
REPLY_LINK_ID_S = "com.netease.cloudmusic:id/aip"  # 回复内容链接
BE_REPLY_CONTENT_ID_S = "com.netease.cloudmusic:id/a8_"  # 回复内容
MORE_HOT_COMMENT_ID_S = "com.netease.cloudmusic:id/b5m"  # 更多精彩评论
FROM_MORE_RETURN_MAIN_COMMENT = "//android.view.View[2]/android.widget.ImageButton"  # 返回箭头

RELPLY_LINEARLAYOUT_XPATH_S = ".//android.widget.LinearLayout/android.widget.LinearLayout"  # 回复内容可点击的
RELPLY_LINEARLAYOUT_TEXT_XPATH_S = ".//android.widget.LinearLayout/android.widget.LinearLayout//android.widget.TextView" #回复内容文本数量该方法适用回复少于3条的内容

实现全局界面刷新的等待

因为需要实现界面的等待,但是appium的隐式等待显示等待不能满足全部需求,因为评论元素在一个view内是重复出现的。所以如果只是使用自带两个等待可能会出现数据丢失的问题。所以需要自定义一个等待方法。该方法表示当滑动屏幕完成后,当前界面如果在一定的条件下未发生变化,可认为界面整体加载完毕。一次性判断加载整体界面加载完毕的方式也可以节省大量的时间。以下是实现该方法的思路

    def wait_full_view(self, interval, comp_times, timeout):
        """
        刷新整个view可以设置刷新间隔
        :param interval: 刷新间隔-秒单位
        :param comp_times: 如果连续刷新该次数的driver.page_source相同得话则认为界面加载完毕
        :param timeout: 最长得等待时间,如果超出该时间则认为界面加载完毕-秒单位
        :return:
        """
        starttime = int(time.time() * 1000)
        comp_count = 0
        page_init = self.driver.page_source
        while True:
            page_fir = page_init
            time.sleep(interval)
            page_sec = self.driver.page_source
            if page_fir == page_sec:
                comp_count += 1
            if page_fir != page_sec:
                comp_count = 0
            page_init = page_sec
            endtime = int(time.time() * 1000)
            if comp_count >= comp_times or (endtime - starttime) >= timeout * 1000:
                break

查找评论分类的方法

如果我们要按照分类进行爬取,就必须知道评论的类型是属于热门,近期热评还是近期评论
所以需要知道评论类别的分界线,方便爬取

    def __aline_type_frame(self, app_control, type_str):  # 对齐评论类型与顶栏
        print("将尝试齐评论类型与顶栏")
        find_res = False
        self.__return_top(app_control)
        x = app_control.x * 0.5
        y = app_control.y * 0.5
        top_frame = app_control.find_one_element(By.ID, config.RETURN_CUR_PAGE_TOP_ID_S)
        swip_y_end = top_frame.location.get('y') + top_frame.size.get('height')
        while True:
            app_control.wait_full_view(0.4, 3, 5)
            # comments_type_name = app_control.find_one_element(By.ID, "com.netease.cloudmusic:id/a8u")
            try:
                maybe_hidde = app_control.driver.find_element(By.ID, config.COMMENT_TYPE_ID_S)

            except Exception:
                maybe_hidde = None
            if maybe_hidde:

                s = 3500  # 时间速度
                if maybe_hidde.location['y'] > y:
                    app_control.swipe_up_down(650, 0.75, 0.65, 1)
                    s = 6000  # 距离让子弹飞一会
                app_control.wait_full_view(0.4, 3, 5)
                comments_type_name = app_control.find_one_element(By.ID, config.COMMENT_TYPE_ID_S)
                if comments_type_name and comments_type_name.text[:4] == type_str:
                    swip_y_begin = comments_type_name.location['y']
                    app_control.driver.swipe(x, swip_y_begin, x, swip_y_end, s)
                    find_res = True
                    break
                if comments_type_name and comments_type_name.text[:4] == '最新评论':
                    break
            app_control.swipe_up_down(750, 0.75, 0.30, 1)
        return find_res

爬取评论的方法

    def __get_commmet(self, app_control, comment_type=None, entry_reply_link=False):
        """
        因为滑屏的原因,将一个完整的评论,时间,昵称,内容等有可能进行了切分。虽然提供了简单的去重逻辑但是,
        不保证完全去除成功,并且有很小的概率删除一个人评论的内容相同的。该方法应该放在对齐方法的后面
        :param app_control: app_control类
        :param comment_type: 评论类型
        :param entry_reply_link: 是否进入回复评论连接
        :return:
        """

        dbctl = OperateDB()
        swipe_count = 0
        distinct_pool = []  # 初始值定为50 为滑动的两页的数量
        driver_source_pool = []  # 存放整体页面。用来跳出循环
        type_flag_list = []  # 用来存放找到的评论分类
        while True:
            perhaps_flag = False  # 判断两个页面是不是衔接失败。如果driver_source_pool中不存在页面的第一个元素则认为衔接失败反向滑屏
            if len(driver_source_pool) >= 5:
                driver_source_pool.append(utils.get_str_md5(app_control.driver.page_source))
                driver_source_pool.pop(0)
            else:
                driver_source_pool.append(utils.get_str_md5(app_control.driver.page_source))
            if len(driver_source_pool) >= 5 and len(set(driver_source_pool)) == 1:
                print('滑动屏幕多次,界面未发生变化,不再继续滑动')
                break
            relativelayout_list = app_control.find_all_elements(By.XPATH, config.LISTVIEW_XPATH_M)
            destination_el = relativelayout_list[2]  # 用于scoll 这个为1 能保证数据的完整性。滑动到第二个元素的距离
            origin_el = relativelayout_list[-2]  # 从最后一个元素开始滑动
            relativelayout_index = 0
            for relativeLayout in relativelayout_list:
                # 找到第一个类别分界线。一般是指对其后的第一个
                one_type = app_control.get_element_text(relativeLayout, By.ID, config.COMMENT_TYPE_ID_S)
                if one_type:
                    type_flag_list.append(one_type)
                if len(type_flag_list) >= 2:  # 如果找到第二个的话则中断循环
                    break

                nick_name = app_control.get_element_text(relativeLayout, By.ID, config.NICK_NAME_ID_S)
                comment_time = app_control.get_element_text(relativeLayout, By.ID, config.COMMENT_TIME_ID_S)
                like_cnt = app_control.get_element_text(relativeLayout, By.ID, config.LIKE_CNT_ID_S)
                content_text = app_control.get_element_text(relativeLayout, By.ID, config.CONTENT_TEXT_ID_S)
                be_reply = app_control.get_element_text(relativeLayout, By.ID, config.BE_REPLY_CONTENT_ID_S)
                be_reply_count = app_control.get_element_text(relativeLayout, By.ID, config.REPLY_LINK_ID_S)
                # print(be_reply_count)
                be_reply_count = utils.turn_reply_cnt(be_reply_count)
                if be_reply_count is None:
                    reply_layout = app_control.elements_exist(relativeLayout, By.XPATH,
                                                              config.RELPLY_LINEARLAYOUT_TEXT_XPATH_S)
                    if reply_layout is not None:
                        be_reply_count = len(reply_layout)
                like_cnt = utils.trun_likecount_str(like_cnt)

                if nick_name is None or comment_time is None or content_text is None:
                    continue
                one_full_comment = utils.str_join_md5(nick_name, comment_time, like_cnt, content_text,
                                                      be_reply)  # 将字符串结果组合然后进行MD5
                if len(distinct_pool) >= config.DISTINCT_POOL and relativelayout_index == 1:
                    if one_full_comment not in distinct_pool:
                        perhaps_flag = True
                        break
                # print(len(distinct_pool),relativelayout_index,perhaps_flag)
                if one_full_comment in distinct_pool:
                    distinct_pool.append(one_full_comment)
                    print('该条记录存在缓存池,不再放入到数据库中')
                else:
                    distinct_pool.append(one_full_comment)
                    print('昵称:%s; 时间:%s; 点赞数:%s; 内容:%s...' % (nick_name, comment_time, like_cnt, content_text[:20]))
                    sql = "INSERT INTO `music_comments`.`comment_content`(`COMMENT_ID`, `TASK_ID`, `NICK_NAME`, " \
                          "`COMMENT_TIME`, `LIKE_COUNT`, `COMMENT_TEXT`, `BE_REPLIED_TEXT`, `BE_REPLIED_COUNT`, " \
                          "`COMMENT_TIME_PROC`, `COMMENT_TYPE`, `SWIPE_COUNT`, `CREATE_TIME`) " \
                          "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s);"
                    comment_id = int(time.time() * 1000)
                    comment_info_list = (
                        comment_id, self.task_id, nick_name, comment_time, like_cnt, content_text, be_reply,
                        be_reply_count, utils.turn_time(comment_time), comment_type, swipe_count,
                        datetime.datetime.now())
                    dbctl.add_data(sql, comment_info_list)
                    if entry_reply_link:  # 进入回复的评论
                        self.__entry_reply_comment(dbctl, app_control, relativeLayout, comment_id)
                if len(distinct_pool) > config.DISTINCT_POOL:
                    distinct_pool.pop(0)
                relativelayout_index += 1
            if len(type_flag_list) >= 2:
                break
            elif not perhaps_flag:
                app_control.el_scroll(origin_el, destination_el, 4000)
                # app_control.swipe_up_down(450, 0.75, 0.30, 1)  # 这个不太好用,先弃用吧
                swipe_count += 1
            else:
                app_control.swipe_up_down(random.randint(650, 800), 0.30, 0.60, 1)
                print('可能衔接中断,开始上滑')
            app_control.wait_full_view(0.35, 3, 5)  # 大概每次网络请求会返回20条评论,在之后界面有一段需要加载的的时间,
            # 就是界面显示正在加载,这时无法使用显示等待或者隐式等待,通过判断整体界面在特定条件下是否有变化来判断界面是否加载完毕

程序实现功能

1 、对爬取所有评论时出现中断实现断点续爬
2、单独爬取不同类别的评论,爬取所有网易云音乐评论的所有热门评论,近期热评,近期评论,所有评论
3、爬取评论被回复的内容。可以爬取评论的被回复的所有内容
4、将爬取下来的数据存储到mysql 数据库

爬取方法和数据样例:

举例:爬取歌曲《后来》热门评论,不爬取评论回复内容,python run.py -n 城南花已开
参数说明:
-n:必须参数,歌曲名字,可以用“-”追加歌手名等内容但是不能有空格
-t:可选参数,任务类型,默认爬取热门评论。0 爬取最新评论,1爬取热门评论,2爬取近期热评,3近期热评+热门评论,4全部爬取
-i:可选参数,任务断点续爬,后面跟任务号。仅支持任务类型包括最新评论的重新爬取,该参数指定后再指定-t参数会无效
-e:可选参数,是否进入回复链接爬取回复内容,默认不爬取。1爬取,0不爬取
数据样例:
任务说明表信息
Appium 爬取安卓版网易云音乐单曲评论(热门评论,近期热评,所有评论)
爬取的数据 城南花已开这首音乐,目前已经存在2000多条热门评论(如果仔细分析,这首歌和背后的故事的确鼓舞了很多人。),样例数据如下:
Appium 爬取安卓版网易云音乐单曲评论(热门评论,近期热评,所有评论)

以上只是对使用appium 爬取网易云音乐评论的一个概述。如果您对该程序感兴趣可以关注我,欢迎大家交流经验