个性化阅读
专注于IT技术分析

R中的Web爬虫:rvest教程

本文概述

Trustpilot已成为客户浏览业务和服务的热门网站。在这个简短的教程中, 你将学习如何在R的帮助下从该网站上刮取有用的信息并从中获得一些基本见解。你会发现TrustPilot可能不如广告中所说的那样值得信赖。

R中的Web爬虫:rvest教程

更具体地说, 本教程将涵盖以下内容:

  • 首先, 你将学习如何抓取Trustpilot来收集评论;
  • 然后, 你将看到一些从页面上提取信息的基本技术:你将在子页面上提取评论文本, 等级, 作者姓名和所有评论的提交时间。
  • 有了这些工具, 你就可以准备比赛了, 并比较了两家公司的评估(你自己选择):你将了解如何结合使用ggplot2和dplyr等tidyverse软件包, 以及xts, 以进一步检查数据并提出假设, 你可以使用推断包进一步研究该包, 该包是遵循tidyverse哲学的统计推断包。

在Trustpilot上, 评论包括对服务的简短说明, 五星级评级, 用户名和发布时间。

R中的Web爬虫:rvest教程

你的目标是在R中编写一个函数, 该函数将为你选择的任何公司提取此信息。

创建爬取函数

首先, 你需要为此任务加载所有库。

    # General-purpose data wrangling
    library(tidyverse)  

    # Parsing of HTML/XML files  
    library(rvest)    

    # String manipulation
    library(stringr)   

    # Verbose regular expressions
    library(rebus)     

    # Eases DateTime manipulation
    library(lubridate)

查找所有页面

例如, 你可以选择电子商务公司亚马逊。这纯粹是出于演示目的, 与本教程后半部分将介绍的案例研究无关。

着陆页网址将是公司的标识符, 因此你将其存储为变量。

    url <-'http://www.trustpilot.com/review/www.amazon.com'

大多数大公司都有几个评论页面。在亚马逊的登录页面上, 你可以读取页面数, 这里是155。

亚马逊登陆的底部页

单击任何一个子页面都会显示一种模式, 该模式说明了如何寻址公司的各个URL。每个页面都是添加了?page = n的主URL, 其中n是评论页面的编号, 此处为1到155之间的任何数字。

这是程序应如何运行:

  1. 查找要查询的最大页面数
  2. 生成组成评论的所有子页面
  3. 刮除每个人的信息
  4. 将信息整合到一个全面的数据框架中

让我们从查找最大页面数开始。通常, 你可以使用浏览器固有的Web开发工具来检查网站的视觉元素。其背后的想法是, 网站的所有内容, 即使是动态创建的, 也都以某种方式在源代码中进行了标记。这些标记通常足以查明你要提取的数据。

由于这只是介绍, 因此你可以沿着风景优美的路线直接自己查看源代码。

HTML数据具有以下结构:

    < Tag  Attribute_1 = Value_1 Attribute_2 = Value_2 ...>
        The tagged data
        <\Tag>

要获取数据, 你将需要rvest软件包的某些功能。要将网站转换为XML对象, 请使用read_html()函数。你需要提供目标URL, 该函数将调用Web服务器, 收集数据并进行解析。要从XML对象提取相关节点, 请使用html_nodes(), 其参数是类描述符, 以开头。表示它是一类。输出将是以此方式找到的所有节点的列表。要提取标记的数据, 你需要将html_text()应用于所需的节点。对于需要提取属性的情况, 可以应用html_attrs()。这将返回属性列表, 你可以将其子集化以获取要提取的属性。

让我们在实践中应用它。右键单击亚马逊的登录页面后, 你可以选择检查源代码。你可以搜索数字” 155″以快速找到相关部分。

着陆页源的摘录

你可以看到所有页面按钮信息都标记为” pagination-page”类。提取登录页面原始HTML并提取分页页面类的倒数第二项的函数如下所示:

    get_last_page <- function(html){

      pages_data <- html %>% 
                      # The '.' indicates the class
                      html_nodes('.pagination-page') %>% 
                      # Extract the raw text as a list
                      html_text()                   

      # The second to last of the buttons is the one
      pages_data[(length(pages_data)-1)] %>%            
        # Take the raw string
        unname() %>%                                     
        # Convert to number
        as.numeric()                                     
    }

该函数特定的步骤是html_nodes()函数的应用, 该函数提取分页类的所有节点。函数的最后一部分只是获取列表的正确项目, 倒数第二个倒数, 然后将其转换为数字值。

要测试该功能, 你可以使用read_html()函数加载起始页面并应用你刚编写的函数:

    first_page <- read_html(url)
    (latest_page_number <- get_last_page(first_page))
    [1] 155

现在有了这个数字, 你可以生成所有相关URL的列表

    list_of_pages <- str_c(url, '?page=', 1:latest_page_number)

你可以手动检查list_of_pages的条目确实有效, 并且可以通过Web浏览器调用。

提取一页信息

你要在子页面上提取评论文本, 等级, 作者姓名和所有评论的提交时间。你可以对每个要查找的字段重复前面的步骤。

一则评论,突出显示相关信息(作者ID除外)

对于每个数据字段, 你都使用观察到的标签编写了一个提取函数。在这一点上, 需要一些反复试验才能获得所需的确切数据。有时你会发现其他项目已被标记, 因此你必须手动减少输出。

    get_reviews <- function(html){
      html %>% 
        # The relevant tag
        html_nodes('.review-body') %>%      
        html_text() %>% 
        # Trim additional white space
        str_trim() %>%                       
        # Convert the list into a vector
        unlist()                             
    }

    get_reviewer_names <- function(html){
      html %>% 
        html_nodes('.user-review-name-link') %>% 
        html_text() %>% 
        str_trim() %>% 
        unlist()
    }

日期时间信息有些麻烦, 因为它存储为属性。

通常, 你会寻找最广泛的描述, 然后尝试删除所有多余的信息。因为时间信息不仅出现在评论中, 所以你还必须提取相关的状态信息并按正确的条目进行过滤。

    get_review_dates <- function(html){

      status <- html %>% 
                  html_nodes('time') %>% 
                  # The status information is this time a tag attribute
                  html_attrs() %>%             
                  # Extract the second element
                  map(2) %>%                    
                  unlist() 

      dates <- html %>% 
                  html_nodes('time') %>% 
                  html_attrs() %>% 
                  map(1) %>% 
                  # Parse the string into a datetime object with lubridate
                  ymd_hms() %>%                 
                  unlist()

      # Combine the status and the date information to filter one via the other
      return_dates <- tibble(status = status, dates = dates) %>%   
                        # Only these are actual reviews
                        filter(status == 'ndate') %>%              
                        # Select and convert to vector
                        pull(dates) %>%                            
                        # Convert DateTimes to POSIX objects
                        as.POSIXct(origin = '1970-01-01 00:00:00') 

      # The lengths still occasionally do not lign up. You then arbitrarily crop the dates to fit
      # This can cause data imperfections, however reviews on one page are generally close in time)

      length_reviews <- length(get_reviews(html))

      return_reviews <- if (length(return_dates)> length_reviews){
          return_dates[1:length_reviews]
        } else{
          return_dates
        }
      return_reviews
    }

你需要的最后一个功能是评级的提取器。你将使用正则表达式进行模式匹配。等级被放置为标签的属性。它不仅是数字, 而且是字符串count-X的一部分, 其中X是你想要的数字。正则表达式可能有点笨拙, 但是rebus包允许以一种易于阅读的形式编写它们。此外, 通过%R%运算符, rebus的管道功能允许将复杂的模式分解为更简单的子模式, 以构造更复杂的正则表达式。

    get_star_rating <- function(html){

      # The pattern you look for: the first digit after `count-`
      pattern = 'count-'%R% capture(DIGIT)    

      ratings <-  html %>% 
        html_nodes('.star-rating') %>% 
        html_attrs() %>% 
        # Apply the pattern match to all attributes
        map(str_match, pattern = pattern) %>%
        # str_match[1] is the fully matched string, the second entry
        # is the part you extract with the capture in your pattern  
        map(2) %>%                             

        unlist()

      # Leave out the first instance, as it is not part of a review
      ratings[2:length(ratings)]               
    }

在测试了各个提取器功能可以在单个URL上运行之后, 可以将它们组合起来以创建整个页面的小标题, 该标题本质上是一个数据框。因为你可能将此功能应用于多个公司, 所以将添加一个带有公司名称的字段。当你想比较不同的公司时, 这对以后的分析很有帮助。

    get_data_table <- function(html, company_name){

      # Extract the Basic information from the HTML
      reviews <- get_reviews(html)
      reviewer_names <- get_reviewer_names(html)
      dates <- get_review_dates(html)
      ratings <- get_star_rating(html)

      # Combine into a tibble
      combined_data <- tibble(reviewer = reviewer_names, date = dates, rating = ratings, review = reviews) 

      # Tag the individual data with the company name
      combined_data %>% 
        mutate(company = company_name) %>% 
        select(company, reviewer, date, rating, review)
    }

你可以将此函数包装在从URL中提取HTML的命令中, 从而使处理变得更加方便。

    get_data_from_url <- function(url, company_name){
      html <- read_html(url)
      get_data_table(html, company_name)
    }

在最后一步中, 将此功能应用于你先前生成的URL列表。为此, 你可以使用tidyverse一部分purrr包中的map()函数。它对列表项应用相同的功能。你之前已经使用过该函数, 但是, 你传递了数字n, 这是提取列表的第n个子项的简写。

最后, 你编写了一个方便的函数, 该函数将公司目标页面的URL和你要给公司的标签作为输入。它提取所有评论, 并将它们绑定为一个小标题。这也是优化代码的良好起点。 map函数按顺序应用get_data_from_url()函数, 但不必这样做。一个人可以在此处应用并行化, 这样几个CPU可以分别获取页面子集的评论, 并且仅在最后合并。

    scrape_write_table <- function(url, company_name){

      # Read first page
      first_page <- read_html(url)

      # Extract the number of pages that have to be queried
      latest_page_number <- get_last_page(first_page)

      # Generate the target URLs
      list_of_pages <- str_c(url, '?page=', 1:latest_page_number)

      # Apply the extraction and bind the individual results back into one table, # which is then written as a tsv file into the working directory
      list_of_pages %>% 
        # Apply to all URLs
        map(get_data_from_url, company_name) %>%  
        # Combine the tibbles into one tibble
        bind_rows() %>%                           
        # Write a tab-separated file
        write_tsv(str_c(company_name, '.tsv'))     
    }

你可以使用制表符分隔的文件(而不是常用的逗号分隔的文件(CSV))将结果保存到磁盘, 因为评论通常包含逗号, 这可能会使解析器感到困惑。

例如, 你可以将该功能应用于亚马逊:

    scrape_write_table(url, 'amazon')

    amz_tbl <- read_tsv('amazon.tsv')
    tail(amz_tbl, 5)
    # A tibble: 5 x 5
      company            reviewer                date rating
        <chr>               <chr>              <dttm>  <int>
    1  amazon            Anders T 2009-03-22 13:14:12      5
    2  amazon             David E 2008-12-31 18:57:31      5
    3  amazon      Joseph Harding 2008-09-16 13:05:05      3
    4  amazon   "Mads D\u00f8rup" 2008-04-28 11:09:05      5
    5  amazon Kim Fuglsang Kramer 2007-08-27 17:25:01      4
    # ... with 1 more variables: review <chr>

案例研究:两家公司的故事

使用上一部分中的网络抓取功能, 你可以快速获取大量数据。使用此数据, 可以进行许多不同的分析。

在本案例研究中, 你将仅使用评论的元数据, 即评论的等级和评论时间。

首先, 你加载其他库。

    # For working with time series
    library(xts)      

    # For hypothesis testing
    library(infer)

你感兴趣的公司都是同一行业中的佼佼者。他们俩都将自己的TrustPilot分数用作他们网站上的卖点。

你已经抓取了他们的数据, 只需要加载它:

    data_company_a <- read_tsv('company_A.tsv')
    data_company_b <- read_tsv('company_B.tsv')

你可以在dplyr包中的group_by()和summarise()函数的帮助下总结它们的总数:

    full_data <- rbind(data_company_a, data_company_b)

    full_data%>%
      group_by(company) %>% 
      summarise(count = n(), mean_rating = mean(rating))
公司 计数 均值
company_A 3628 4.908214
company_B 2615 4.852773

两家公司的平均评分看起来相当。营业时间较长的公司B似乎也有较低的评级。

比较时间序列

进行进一步分析的一个很好的起点是, 查看每家公司的按月排名表现。首先, 你从数据中提取时间序列, 然后将它们子集到两家公司都在营业并产生足够的审核活动的程度。这可以通过可视化时间序列来找到。如果连续几个月的数据差距很大, 那么从数据得出的结论就不太可靠。

    company_a_ts <- xts(data_company_a$rating, data_company_a$date)
    colnames(company_a_ts) <- 'rating'
    company_b_ts <- xts(data_company_b$rating, data_company_b$date)
    colnames(company_b_ts) <- 'rating'

    open_ended_interval <- '2016-01-01/'

    # Subsetting the time series
    company_a_sts <- company_a_ts[open_ended_interval] 
    company_b_sts <- company_b_ts[open_ended_interval]

现在, 你可以使用xts包中的apply.monthly()函数应用每月平均。首先, 收集每个公司的平均每月评级。对于同一索引, 时间序列可以具有多个观察值。要指定平均值是针对一个字段(此处是评级)取的, 我们必须通过colMeans。

接下来, 不要忘记传递FUN参数以获取月度计数。这是因为时间序列可以看作是向量。每个评论使该向量的长度增加一, 长度函数实质上是对评论进行计数。

    company_a_month_avg <-  apply.monthly(company_a_sts, colMeans, na.rm = T)
    company_a_month_count  <-  apply.monthly(company_a_sts, FUN = length)

    company_b_month_avg <-  apply.monthly(company_b_sts, colMeans, na.rm = T)
    company_b_month_count  <-  apply.monthly(company_b_sts, FUN = length)

接下来, 你可以比较每月的评分和计数。通过绘制每个公司的月平均评级以及每个公司的这些评级的计数在单独的图中, 然后将它们排列在网格中, 可以很容易地做到这一点:

R中的Web爬取:rvest教程6

看来公司A的评级更高。不仅如此, 对于B公司而言, 每月的评论数量显示出非常明显的峰值, 尤其是在经过一系列平庸的评论之后。

会有犯规的机会吗?

进一步汇总数据

既然你已经看到, 数据(尤其是B公司的数据)会随着时间的推移而发生巨大变化, 这是一个自然的问题, 可以询问审查活动在一周或一天之内如何分配。

    full_data <- full_data %>%  
      filter(date >= start_date) %>% 
      mutate(weekday = weekdays(date, abbreviate = T), hour = hour(date))

    # Treat the weekdays as factor. 
    # The order is for the plotting only
    full_data$weekday <-  factor(full_data$weekday, levels = c('Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'))

如果按星期几和一周中的小时来绘制评论, 则还会发现明显的差异:

R中的Web爬取:rvest教程7

按一天中的时间进行划分也会显示出两个竞争对手之间的显着差异:

R中的Web爬取:rvest教程8

看来白天撰写的评论多于夜间。但是, 公司B在下午撰写的评论中显示出明显的高峰。

零假设

这些模式似乎表明B公司正在发生一些混乱的情况。也许某些评论不是由用户撰写的, 而是由专业人士撰写的。你可能希望这些评论平均比普通人撰写的评论更好。由于公司B的评论活动在工作日中要高得多, 因此专业人员似乎可能会在其中一天写评论。现在, 你可以制定一个零假设, 你可以尝试使用数据中的证据来进行反驳。

在工作日撰写的评论与在周末撰写的评论之间没有系统上的区别。

为了对此进行测试, 你将公司B的评论分为在工作日和周末写的评论, 并检查它们的平均评分

    hypothesis_data <- full_data %>% 
      mutate(is_weekend = ifelse(weekday %in% c('Sat', 'Sun'), 1, 0)) %>% 
      select(company, is_weekend, rating)

    hypothesis_data %>% 
      group_by(company, is_weekend) %>% 
      summarise(avg_rating = mean(rating)) %>% 
      spread(key = is_weekend, value = avg_rating) %>% 
      rename(weekday = '0', weekend = '1)
公司 平日 周末
company_A 4.898103 4.912176
company_B 4.864545 4.666667

当然, 看起来相差很小, 但这是纯粹的机会吗?

你可以提取平均值差:

    weekend_rating <- hypothesis_data %>% 
      filter(company == 'company_B') %>% 
      filter(is_weekend == 1) %>% 
      summarise(mean(rating)) %>% 
      pull()

    workday_rating <- hypothesis_data %>% 
      filter(company == 'company_B') %>% 
      filter(is_weekend == 0) %>% 
      summarise(mean(rating)) %>% 
      pull()

    (diff_work_we <- workday_rating - weekend_rating)
    [1] 0.1978784

如果工作日和周末之间确实没有差异, 那么你可以简单地将is_weekend标签置换, 平均值之间的差异应该在相同的数量级上。推断包提供了执行此类”黑客统计信息”的便捷方法:

    permutation_tests <- hypothesis_data %>% 
      filter(company == 'company_B') %>% 
      specify(rating ~ is_weekend ) %>% 
      hypothesize(null = 'independence') %>% 
      generate(reps = 10000, type = 'permute') %>% 
      calculate(stat = 'diff in means', order = c(0, 1))

在这里, 你指定了要测试的关系, 独立性假设, 并告诉该函数生成10000个排列, 计算每个排列的平均评分之差。现在, 你可以计算排列的频率, 偶然地产生平均评级的这种差异:

    permutation_tests %>% 
      summarise(p = mean(abs(stat)>= diff_work_we))
    # A tibble: 1 x 1
          p
      <dbl>
    1 7e-04

实际上, 观察到的结果是纯机会的机会非常小。这不会证明有任何不当行为, 但是非常可疑。例如, 对公司A进行相同的实验, 得出p值为0.561, 这意味着其极有可能获得与所观察到的值一样高的值, 这当然不会使你的零假设无效。

结论:不要相信评论(盲目的)

在本教程中, 你编写了一个简单的程序, 该程序可让你从TrustPilot网站上抓取数据。数据被组织在整齐的数据表中, 并提供了进行大量进一步分析的机会。

例如, 你抓取了从事同一行业的两家公司的信息。你分析了他们的元数据, 并发现了其中的可疑模式。你使用假设检验表明, 工作日对一家公司的评级有系统的影响。这表明审查已被操纵, 因为没有其他很好的解释为什么应该有这样的区别。你无法验证对另一家公司的影响, 但这并不意味着他们的评论一定是诚实的。

有关被假评论困扰的评论网站的更多信息, 例如可以在《卫报》上找到。

赞(0)
未经允许不得转载:srcmini » R中的Web爬虫:rvest教程

评论 抢沙发

评论前必须登录!