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)