Ammonite/Статистика відвідувань вікісторінок

Є сервіс який повертає статистику відвідуваності - топ-1000 найвідвідуваніших статей. До нього також є користувацький веб інтерйфейс. Проте хотілося б згрупувати сторінки за підручником та об'єднати за місяцями для згладжування (або дослідження) перепадів.

Статистика одного місяця

ред.

Для отримання навідвідуваніших сторінок Вікіпідручника, наприклад, за 2015-12 потрібно звернутися за url https://wikimedia.org/api/rest_v1/metrics/pageviews/top/uk.wikibooks.org/all-access/2016/12/all-days

Напишемо функцію, яка створює url для запиту для вказаного проекту, року і місяця:

def url(project: String, year: Int, month: Int) = s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$month/all-days"

і перевіримо її роботу (@ - це символ запрошення командного рядка Ammonite, його не треба вводити, рядки без @ — вивід)

@ def url(project: String, year: Int, month: Int) = s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$month/all-days" 
defined function url
@ url("uk.wikibooks.org", 2016, 1) 
res2: String = "https://wikimedia.org/api/rest_v1/metrics/pageviews/top/uk.wikibooks.org/all-access/2016/1/all-days"

Так само як і в попередньому прикладі пробуємо отримати дані

import scala.io.Source 
val text = Source.fromURL(url("uk.wikibooks.org", 2016, 1)).mkString

І отримуємо помилку

java.io.FileNotFoundException: https://wikimedia.org/api/rest_v1/metrics/pageviews/top/uk.wikibooks.org/all-access/2016/1/all-days
  sun.net.www.protocol.http.HttpURLConnection.getInputStream0(HttpURLConnection.java:1872)
  sun.net.www.protocol.http.HttpURLConnection.getInputStream(HttpURLConnection.java:1474)
  sun.net.www.protocol.https.HttpsURLConnectionImpl.getInputStream(HttpsURLConnectionImpl.java:254)
  java.net.URL.openStream(URL.java:1045)
  scala.io.Source$.fromURL(Source.scala:141)
  scala.io.Source$.fromURL(Source.scala:131)
  $sess.cmd13$.monthlyViews(cmd13.sc:3)
...

Виявляється сервіс статистики переглядів не розуміє номер місяця без початкового нуля, і потрібно вказати 01, а не 1. Тому, додаємо у функцію створення url початковий 0:

def url(project: String, year: Int, month: Int) = {
  val monthStr = (if (month < 10) "0" else "") + month
  s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$monthStr/all-days"
}

Парсимо повернутий JSON

import $ivy.`io.circe::circe-parser:0.6.1`, $ivy.`io.circe::circe-optics:0.6.1`
import io.circe._, io.circe.parser._ , io.circe.optics.JsonPath._ 
val doc = parse(text).getOrElse(Json.Null)

Розпарсений json:

doc: Json = {
  "items" : [
    {
      "project" : "uk.wikibooks",
      "access" : "all-access",
      "year" : "2016",
      "month" : "12",
      "day" : "all-days",
      "articles" : [
        {
          "article" : "Головна_сторінка",
          "views" : 2238,
          "rank" : 1
        },
        {
          "article" : "SQL/Типи_даних_MySQL",
          "views" : 1262,
          "rank" : 2
        },
        {
          "article" : "Pascal/Математичні_операції",
          "views" : 1079,
          "rank" : 3
        },
        {
          "article" : "Активні_дієприкметники_і_віддієслівні_прикметники",
          "views" : 1047,
          "rank" : 4
        },        
...

Одержуємо список найвідвідуваніших сторінок:

val articles = root.items.each.articles.each.article.string.getAll(doc)

Вивід:

articles: List[String] = List(
  "Головна_сторінка",
  "SQL/Типи_даних_MySQL",
  "Pascal/Математичні_операції",
  "Активні_дієприкметники_і_віддієслівні_прикметники",
...

Та кількість їх відвідувань:

val views = root.items.each.articles.each.views.int.getAll(doc)

Вивід:

views: List[Int] = List(
  2238,
  1262,
  1079,
  1047,
...

Поєднаємо у пари назви статей та їх відвідуваність:

val pairs = articles.zip(views)

Вивід:

pairs: List[(String, Int)] = List(
  ("Головна_сторінка", 2238),
  ("SQL/Типи_даних_MySQL", 1262),
  ("Pascal/Математичні_операції", 1079),
  ("Активні_дієприкметники_і_віддієслівні_прикметники", 1047),

Підсумуємо той код, що ми покроково написали:

import $ivy.`io.circe::circe-parser:0.6.1`, $ivy.`io.circe::circe-optics:0.6.1`
import io.circe._, io.circe.parser._ , io.circe.optics.JsonPath._ 
import scala.io.Source 

// функція, яка створює url для запиту для вказаного проекту, року і місяця:
def url(project: String, year: Int, month: Int) = {
  val monthStr = (if (month < 10) "0" else "") + month
  s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$monthStr/all-days"
}
// отримуємо дані
val text = Source.fromURL(url("uk.wikibooks.org", 2016, 12)).mkString

// парсимо JSON
val doc = parse(text).getOrElse(Json.Null)

// список найвідвідуваніших сторінок:
val articles = root.items.each.articles.each.article.string.getAll(doc)

// кількість їх відвідувань:
val views = root.items.each.articles.each.views.int.getAll(doc) 

// поєднаємо у пари назви статей та їх відвідуваність:
val pairs = articles.zip(views)

Обробка статистики

ред.

Ми отримали статистику у вигляді списку (List) пар назви сторінки та кількості відвідувань (List[(String, Int)])

pairs: List[(String, Int)] = List(
  ("Головна_сторінка", 2238),
  ("SQL/Типи_даних_MySQL", 1262),
  ("Pascal/Математичні_операції", 1079),
  ("Активні_дієприкметники_і_віддієслівні_прикметники", 1047),

Із парами можна працювати за номером елементу в парі.

Наприклад, можна відсортувати список за першим елементом — назвою статті:

val byName = pairs.sortBy(_._1)

або відсортувати список за другим елементом — кількістю переглядів у зворотному (тобто спадному — від більшої до меншої кількості) порядку.

val byViews = pairs.sortBy(- _._2)

Хоча такий код доволі лаконічний, для його розуміння треба бачити з контексту, що першим елементом є назва статті, а другим — кількість переглядів. Допомагають зрозуміти також назви змінних byName і byViews. Однак, коли програми стають більшими, це може стати незручним і при обробці пари можна також давати назву її елементам:

val byName = pairs.sortBy{ case (name, views) => name }
val byViews = pairs.sortBy{ case (name, views) => - views }

Так дещо зрозуміліше, але багатослівніше. Крім того, ці назви елементам пари треба заново вказувати при кожному звертанні.

Тому краще створити клас із двома полями name і views:

case class PageViews(name: String, views: Int)

і перетворити список пар у список елементів класу PageViews

val pageViews = pairs.map{ case (name, views) => new PageViews(name, views) }

Тепер код для роботи з даними про кількість переглядів виглядає і лаконічно і зрозуміло:

val byName = pageViews.sortBy(_.name)
val byViews = pageViews.sortBy(- _.views)

Річна статистика

ред.

Винесемо код одержання статистики за місяць у окрему функцію

case class PageViews(name: String, views: Int)

def getMonthlyViews(project: String, year: Int, month: Int): List[PageViews] = {
  def url: String = {
    val monthStr = (if (month < 10) "0" else "") + month
    s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$monthStr/all-days"
  } 
  val text = Source.fromURL(url).mkString
  val doc = parse(text).getOrElse(Json.Null)
  val articles = root.items.each.articles.each.article.string.getAll(doc)
  val views = root.items.each.articles.each.views.int.getAll(doc) 
  articles.zip(views).map{ case (name, views) => new PageViews(name, views) }
}

І отримаємо статису за кожним місяцем:

val monthly = (1 to 12).map(month => getMonthlyViews("uk.wikibooks.org", 2016, month))

Групування за назвою статті

ред.

Змінна monthly тепер містить список із 12 списків статей для кожного місяця

@ monthly.size 
res19: Int = 12


Кожен із 12 списків містить топ-1000 статей, які переглядались цього місяця.

@ monthly.map(_.size) 
res22: collection.immutable.IndexedSeq[Int] = Vector(916, 741, 868, 984, 997, 993, 759, 736, 703, 709, 750, 784)

Але жодного місяця не переглянули 1000 різних статей, тому маємо не топ-1000, а топ-(скільки цього місяця переглянуто різних статей)

зробимо із 12 списків статей по місяцях 1 річний

val yearlySeq = monthly.flatten

перевіримо, що кількість елементів списку по місяцях і об'єднаного списку однакова:

@ yearlySeq.size 
res21: Int = 9940
@ monthly.map(_.size).sum 
res23: Int = 9940

Згрупуємо за назвою статті, та просумуємо за місяцями:

val groupedByArticle = yearlySeq.groupBy(_.page} 
val articleToViews = groupedByArticle.mapValues(_.map(_.views).sum}

І відсортуємо за кількістю переглядів:

val ordered = articleToViews.toSeq.sortBy(- _.views) 
ordered: Seq[PageViews] = Vector(
  PageViews("Спеціальна:Вхід", 54454),
  PageViews("Головна_сторінка", 32206),
  PageViews("Спрощення_у_групах_приголосних", 19109),
  PageViews("Освоюємо_Java", 13888),
  PageViews("Закінчення_іменників_другої_відміни_чоловічого_роду_в_родовому_відмінку_однини", 10701),
  PageViews("Освоюємо_Java/Основи", 10266),

Згрупована за підручником

ред.
val yearlyByBookSeq = ordered.map(pv => pv.copy(name =  pv.name))
val groupedByBook = yearlyByBookSeq.groupBy(_.name) 
val bookToViews = groupedByBook.mapValues(_.map(_.views).sum) 
val orderedBookViews = bookToViews.toSeq.sortBy(-_.views)

Вивід (очевидно треба ще відфільтрувати за простором назв статей):

orderedBookViews: Seq[PageViews] = Vector(
  PageViews("Освоюємо_Java", 79686),
  PageViews("Спеціальна:Вхід", 54521),
  PageViews("Pascal", 35106),
  PageViews("Головна_сторінка", 32206),
  PageViews("Мова_людства", 23700),
  PageViews("Спрощення_у_групах_приголосних", 19109),
  PageViews("Пориньте_у_Python_3", 16847),
  PageViews("Спеціальна:Посилання_сюди", 15723),
  PageViews("Закінчення_іменників_другої_відміни_чоловічого_роду_в_родовому_відмінку_однини", 10701),
  PageViews("Основні_виробничі_засоби", 10059),
  PageViews("C++", 10038),
...

Остаточний скрипт

ред.
import $ivy.`io.circe::circe-parser:0.6.1`, $ivy.`io.circe::circe-optics:0.6.1`
import io.circe._, io.circe.parser._ , io.circe.optics.JsonPath._ 
import scala.io.Source 

case class PageViews(name: String, views: Int)

def getMonthlyViews(project: String, year: Int, month: Int): List[PageViews] = {
  def url: String = {
    val monthStr = (if (month < 10) "0" else "") + month
    s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$monthStr/all-days"
  } 
  val text = Source.fromURL(url).mkString
  val doc = parse(text).getOrElse(Json.Null)
  val articles = root.items.each.articles.each.article.string.getAll(doc)
  val views = root.items.each.articles.each.views.int.getAll(doc) 
  articles.zip(views).map{ case (name, views) => new PageViews(name, views) }
}

val monthly = (1 to 12).map(month => getMonthlyViews("uk.wikibooks.org", 2016, month))

val groupedByArticle = monthly.flatten.groupBy(_.name) 
val articleToViews = groupedByArticle.mapValues(_.map(_.views).sum)

val yearlyByBookSeq = articleToViews.toSeq.map(pv => pv.copy(name = pv.name.split("/").head)) 

val groupedByBook = yearlyByBookSeq.groupBy(_.name) 
val bookToViews = groupedByBook.mapValues(_.map(_.views).sum) 

val orderedBookViews = bookToViews.toSeq.sortBy(- _.views)

Ще можна винести в окрему функцію однаковий код групування за статтею та за підручником, який зараз продубльований. Загалом остання версія виглядає так:

import $ivy.`io.circe::circe-parser:0.6.1`, $ivy.`io.circe::circe-generic:0.6.1`
import io.circe._, io.circe.parser._, io.circe.generic.auto._
import $ivy.`org.typelevel::cats:0.8.1`, cats.implicits._
import scala.io.Source

case class PageViews(article: String, views: Int)

def getMonthlyViews(project: String, year: Int, month: Int): Map[String, Int] = {
  def url: String = {
    val monthStr = (if (month < 10) "0" else "") + month
    s"https://wikimedia.org/api/rest_v1/metrics/pageviews/top/$project/all-access/$year/$monthStr/all-days"
  }

  val text = Source.fromURL(url).mkString
  val doc = parse(text).getOrElse(Json.Null)

  (doc \\ "articles")
    .flatMap(_.as[List[PageViews]].getOrElse(Nil))
    .map(PageViews.unapply(_).get)
    .toMap
}

def sumMapValues(maps: Seq[Map[String, Int]]) = maps.foldLeft(Map.empty[String, Int])(_ |+| _)

val monthly = (1 to 12).map { month => getMonthlyViews("uk.wikibooks.org", 2016, month) }

val yearly = sumMapValues(monthly)

val byBook = sumMapValues(
  yearly.toSeq.map { case (name, views) => Map(name.split("/").head -> views) }
)

val wikiTable = byBook.toSeq
  .filterNot(_._1.startsWith("Спеціальна:"))
  .sortBy(-_._2)
  .zipWithIndex.map {
    case ((name, views), i) => s"| ${i + 1} || [[$name]] || $views"
  }.mkString("{| class = \"wikitable\"\n|-\n", "\n|-\n", "\n|}")

println(wikiTable)