Как изучить студию с помощью Scratch API

Материал из Поле цифровой дидактики
Описание Мы хотим изучить деятельность участников, которые разместили свои проекты внутри конкретной студии Scratch
Область знаний Информатика, Статистика, Моделирование
Область использования (ISTE)
Возрастная категория 14


Поясняющее видео
Близкие рецепту понятия Социограмма, Сетевой анализ
Среды и средства для приготовления рецепта: R, Scratch API, VOSviewer, NetLogo

История

В среде Scratch есть студии, которые объединяют проекты разных скретчеров. Мы хотим изучить отношения авторов этой студии, используя возможности Scrtatch API


Последовательность исследования

Логика сбора данных

Логика исследования

В результате мы получаем два слоя данных — внутренний срез конкурсной студии (кто и с какими проектами участвует) и внешний слой окружения этих авторов (в какие ещё студии они выкладывают свои проекты). Это позволяет видеть не только «жизнь внутри конкурса», но и более широкие траектории участия скретчеров в сообществе.

R скрипт сбора данных

###############################################
# Анализ авторов и студий Scratch на R
# (пример на студии 4789981 — Collab Challenge)
###############################################

# Пакеты ----
library(httr)
library(jsonlite)
library(dplyr)
library(purrr)
library(tidyr)
library(igraph)

###############################################
# 1. Функция для получения проектов студии
###############################################
# Использует API:
#   https://api.scratch.mit.edu/studios/<studio_id>/projects?limit=&offset=
# Возвращает data.frame:
#   project_id, title, creator_id, username

get_studio_projects <- function(studio_id, limit = 40, verbose = TRUE) {
  offset <- 0
  all_projects <- list()
  page <- 1
  
  repeat {
    url <- paste0(
      "https://api.scratch.mit.edu/studios/",
      studio_id,
      "/projects?limit=", limit,
      "&offset=", offset
    )
    
    if (verbose) message("Studio ", studio_id,
                         ": page ", page,
                         " (offset = ", offset, ")")
    
    resp <- GET(url)
    if (status_code(resp) != 200) {
      warning("Non-200 status code: ", status_code(resp),
              " at offset ", offset)
      break
    }
    
    txt <- content(resp, as = "text", encoding = "UTF-8")
    dat <- fromJSON(txt, flatten = TRUE)
    
    if (length(dat) == 0) break
    
    all_projects[[length(all_projects) + 1]] <- dat
    
    if (nrow(dat) < limit) break
    
    offset <- offset + limit
    page   <- page + 1
  }
  
  if (length(all_projects) == 0) return(NULL)
  
  bind_rows(all_projects) |>
    transmute(
      project_id = id,
      title      = title,
      creator_id = creator_id,
      username   = username
    ) |>
    distinct()
}

###############################################
# 2. Проекты пользователя
###############################################
# API:
#   https://api.scratch.mit.edu/users/<username>/projects?limit=&offset=
# Возвращает data.frame:
#   project_id, title, username

get_user_projects <- function(username, limit = 40, max_pages = 10, verbose = TRUE) {
  offset <- 0
  all <- list()
  page <- 1
  
  repeat {
    if (page > max_pages) break
    
    url <- paste0(
      "https://api.scratch.mit.edu/users/",
      username,
      "/projects?limit=", limit,
      "&offset=", offset
    )
    
    if (verbose) message("User ", username,
                         ": page ", page,
                         " (offset = ", offset, ")")
    
    resp <- GET(url)
    if (status_code(resp) != 200) break
    
    txt <- content(resp, as = "text", encoding = "UTF-8")
    dat <- fromJSON(txt, flatten = TRUE)
    
    if (length(dat) == 0) break
    
    all[[length(all) + 1]] <- dat
    
    if (nrow(dat) < limit) break
    offset <- offset + limit
    page   <- page + 1
  }
  
  if (length(all) == 0) return(NULL)
  
  res <- bind_rows(all)
  
  res |>
    transmute(
      project_id = id,
      title      = title,
      username   = username   # аргумент функции
    ) |>
    distinct()
}

###############################################
# 3. Студии одного проекта пользователя
###############################################
# ВАЖНО: используем путь:
#   https://api.scratch.mit.edu/users/<username>/projects/<project_id>/studios
# а не /projects/<id>/studios, потому что второй вариант
# часто не работает.
#
# Возвращает data.frame:
#   studio_id, studio_name
###############################################

get_project_studios <- function(username, project_id,
                                limit = 40, max_pages = 10,
                                verbose = TRUE) {
  user_name <- as.character(username)[1]
  offset <- 0
  all <- list()
  page <- 1
  
  repeat {
    if (page > max_pages) break
    
    url <- paste0(
      "https://api.scratch.mit.edu/users/",
      user_name,
      "/projects/",
      project_id,
      "/studios?limit=", limit,
      "&offset=", offset
    )
    
    if (verbose) {
      message("User ", user_name,
              ", project ", project_id,
              ": page ", page,
              " (offset = ", offset, ")")
    }
    
    resp <- GET(url)
    if (status_code(resp) != 200) {
      return(NULL)
    }
    
    txt <- content(resp, as = "text", encoding = "UTF-8")
    dat <- fromJSON(txt, flatten = TRUE)
    
    if (length(dat) == 0) break
    
    all[[length(all) + 1]] <- dat
    
    if (nrow(dat) < limit) break
    offset <- offset + limit
    page   <- page + 1
  }
  
  if (length(all) == 0) return(NULL)
  
  res <- bind_rows(all)
  if (!("id" %in% names(res)) || !("title" %in% names(res))) return(NULL)
  
  res |>
    transmute(
      studio_id   = id,
      studio_name = title
    ) |>
    distinct()
}

###############################################
# 4. Автор → его проекты → студии этих проектов
###############################################
# Возвращает data.frame:
#   username, project_id, project_title, studio_id, studio_name

get_user_project_studios <- function(username, verbose = TRUE) {
  user_projects <- get_user_projects(username, verbose = verbose)
  if (is.null(user_projects) || nrow(user_projects) == 0) return(NULL)
  
  proj_studios <- user_projects |>
    mutate(
      studios = map(
        project_id,
        ~ get_project_studios(username, .x, verbose = verbose)
      )
    )
  
  proj_studios_nonempty <- proj_studios |>
    filter(!map_lgl(studios, is.null))
  
  if (nrow(proj_studios_nonempty) == 0) return(NULL)
  
  proj_studios_nonempty |>
    tidyr::unnest(cols = studios) |>
    transmute(
      username,
      project_id,
      project_title = title,
      studio_id,
      studio_name
    ) |>
    distinct()
}

###############################################
# 5. От студии → к авторам → к студиям авторов
###############################################
# Возвращает список:
#   $studio_projects — проекты внутри исходной студии
#   $author_studios  — студии, где встречаются проекты её авторов

get_studio_author_studios <- function(studio_id, verbose = TRUE) {
  # Проекты студии
  studio_projects <- get_studio_projects(studio_id, verbose = verbose)
  if (is.null(studio_projects) || nrow(studio_projects) == 0) return(NULL)
  
  # Авторы этих проектов
  authors <- unique(studio_projects$username)
  
  # Для каждого автора — его проекты и студии этих проектов
  author_studios <- purrr::map_df(
    authors,
    ~ {
      if (verbose) message("Author: ", .x)
      get_user_project_studios(.x, verbose = verbose)
    }
  )
  
  list(
    studio_projects = studio_projects,
    author_studios  = author_studios
  )
}

###############################################
# 6. Пример: анализ студии 4789981
###############################################

# 6.1. Собираем данные
res_4789981 <- get_studio_author_studios(4789981, verbose = TRUE)

studio_projects <- res_4789981$studio_projects
author_studios  <- res_4789981$author_studios

# 6.2. Активность авторов внутри студии:
authors_in_4789981 <- studio_projects |>
  count(username, name = "projects_in_studio") |>
  arrange(desc(projects_in_studio))

head(authors_in_4789981)

# 6.3. Пересечения студий по авторам и проектам:
studio_author_counts <- author_studios |>
  group_by(studio_id, studio_name) |>
  summarise(
    authors_from_4789981  = n_distinct(username),
    projects_from_4789981 = n_distinct(project_id),
    .groups = "drop"
  ) |>
  arrange(desc(authors_from_4789981))

head(studio_author_counts, 20)

###############################################
# 7. Построение двудольного графа автор–студия
###############################################
# Узлы: авторы и студии.
# Рёбра: автор ↔ студия (если хотя бы один проект автора в студии).

author_studios_simple <- author_studios |>
  distinct(username, studio_id, studio_name)

# Узлы-авторы
author_nodes <- author_studios_simple |>
  distinct(name = username) |>
  mutate(
    type  = "author",
    label = name
  )

# Узлы-студии
studio_nodes <- author_studios_simple |>
  distinct(
    studio_id,
    studio_name
  ) |>
  transmute(
    name  = as.character(studio_id),
    type  = "studio",
    label = studio_name
  )

nodes <- bind_rows(author_nodes, studio_nodes)

# Рёбра: автор → студия
edges <- author_studios_simple |>
  transmute(
    from = username,
    to   = as.character(studio_id)
  )

# Граф
g_bip <- graph_from_data_frame(
  d = edges,
  vertices = nodes,
  directed = FALSE
)

# Простейший просмотр структуры:
g_bip
vcount(g_bip)
ecount(g_bip)
table(V(g_bip)$type)

###############################################
# 8. Экспорт nodes и edges в CSV
#    (для VOSviewer, NetLogo, Gephi и др.)
###############################################

nodes_export <- tibble(
  id    = V(g_bip)$name,
  type  = V(g_bip)$type,
  label = V(g_bip)$label
)

edges_export <- as_data_frame(g_bip, what = "edges") |>
  as_tibble() |>
  rename(
    from = from,
    to   = to
  )

write.csv(nodes_export,
          "scratch_studios_nodes.csv",
          row.names = FALSE,
          fileEncoding = "UTF-8")

write.csv(edges_export,
          "scratch_studios_edges.csv",
          row.names = FALSE,
          fileEncoding = "UTF-8")

###############################################
# 9. Простая визуализация в R 
###############################################

# Быстрый "черновой" рисунок:
plot(g_bip,
     vertex.size  = ifelse(V(g_bip)$type == "studio", 6, 3),
     vertex.color = ifelse(V(g_bip)$type == "studio", "tomato", "skyblue"),
     vertex.label = NA,
     edge.color   = "grey80")


Данные студии

Ребра


Узлы