Как изучить студию с помощью Scratch API: различия между версиями
Материал из Поле цифровой дидактики
Patarakin (обсуждение | вклад) |
Patarakin (обсуждение | вклад) |
||
| Строка 111: | Строка 111: | ||
В результате мы получаем два слоя данных — внутренний срез конкурсной студии (кто и с какими проектами участвует) и внешний слой окружения этих авторов (в какие ещё студии они выкладывают свои проекты). Это позволяет видеть не только «жизнь внутри конкурса», но и более широкие траектории участия [[скретчер]]ов в сообществе. | В результате мы получаем два слоя данных — внутренний срез конкурсной студии (кто и с какими проектами участвует) и внешний слой окружения этих авторов (в какие ещё студии они выкладывают свои проекты). Это позволяет видеть не только «жизнь внутри конкурса», но и более широкие траектории участия [[скретчер]]ов в сообществе. | ||
== R скрипт сбора данных == | |||
<syntaxhighlight lang="R" line> | |||
############################################### | |||
# Анализ авторов и студий 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") | |||
</syntaxhighlight> | |||
== Данные студии == | == Данные студии == | ||
Версия от 08:20, 20 марта 2026
| Описание | Мы хотим изучить деятельность участников, которые разместили свои проекты внутри конкретной студии Scratch |
|---|---|
| Область знаний | Информатика, Статистика, Моделирование |
| Область использования (ISTE) | |
| Возрастная категория | 14
|
| Поясняющее видео | |
| Близкие рецепту понятия | SNA, Социограмма |
| Среды и средства для приготовления рецепта: | 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")
