Issue #1 - 学生加入申请数据分析

从 GitHub Issue 到结构化数据的完整流程

案例概述

本案例展示如何从非结构化的 GitHub Issue 评论中提取结构化数据,完整演示数据科学工作流程中的数据获取、清洗、验证、分析和可视化环节。

学习目标

完成本案例学习后,您将能够:

  1. 使用 GitHub API 获取数据:理解 RESTful API 的基本概念,掌握 gh 包的使用方法
  2. 解析非结构化文本:使用正则表达式从自由文本中提取结构化信息
  3. 建立数据验证机制:设计数据质量检查规则,识别异常数据
  4. 进行描述性分析:使用 dplyr 进行数据汇总和统计
  5. 创建数据可视化:使用 ggplot2 生成专业的统计图表
  6. 生成可复现报告:使用 Quarto 整合分析流程并发布

数据结构

字段 类型 说明 示例
student_id character 学号(13位数字) “2025303110116”
name character 姓名(2-4个中文字符) “张三”
interest character 感兴趣的研究方向 “农业遥感”
created_at POSIXct 评论创建时间 “2024-01-15 08:30:00”

数据来源

数据来自 GitHub Issue #1,学生在 Issue 中以评论形式提交个人信息,格式如下:

学号:2025303110116
姓名:张三
感兴趣的方向:农业遥感

数据获取:连接 GitHub API

GitHub API 简介

GitHub 提供了功能完善的 REST API,允许程序化的方式访问平台上的几乎所有数据。对于本项目,我们需要获取特定 Issue 下的所有评论。

API 端点结构:

GET /repos/{owner}/{repo}/issues/{issue_number}/comments

其中: - {owner}: 仓库所有者(本例为 D2RS-2026spring) - {repo}: 仓库名称(本例为 members) - {issue_number}: Issue 编号(本例为 1

为什么选择 gh 包?

R 社区提供了多种访问 GitHub API 的方式:

方式 优点 缺点
httr + 手动构建请求 灵活,完全控制 需要处理 URL 编码、分页等细节
gh 简洁,自动处理分页,智能认证 功能封装后灵活性略降
gh CLI + system() 利用现有认证 依赖外部工具

本项目主要使用 gh 包,因为它:

  1. 自动处理分页:当评论数超过 100 条时,自动获取所有页面
  2. 智能认证:自动检测 GITHUB_PAT 环境变量
  3. R 友好的输出:直接返回 R 列表结构

数据获取代码

显示/隐藏代码
# 使用 gh CLI 获取数据(避免 API 限流)
# 先检查本地是否有缓存数据,避免重复调用 API
cache_file <- "issue1_comments.json"

if (!file.exists(cache_file)) {
  # 获取 Issue 1 评论数据
  # --paginate 参数会自动处理分页,获取所有评论
  system("gh api repos/D2RS-2026spring/members/issues/1/comments --paginate > issue1_comments.json")
  message("已从 GitHub API 获取数据并缓存")
} else {
  message("使用本地缓存数据")
}

# 读取本地缓存数据
# simplifyVector = TRUE 会将 JSON 数组转换为 R 向量
all_comments <- fromJSON(cache_file, simplifyVector = TRUE)

# 显示数据概览
cat("获取到", nrow(all_comments), "条评论\n")
获取到 311 条评论
显示/隐藏代码
cat("数据包含以下列:", paste(colnames(all_comments), collapse = ", "), "\n")
数据包含以下列: url, html_url, issue_url, id, node_id, user, created_at, updated_at, body, author_association, pin, reactions, performed_via_github_app 

理解 API 返回的数据结构

让我们查看一条原始评论的结构:

显示/隐藏代码
# 查看第一条评论的结构
comment_example <- all_comments[1, ]
cat("评论 ID:", comment_example$id, "\n")
评论 ID: 3997126410 
显示/隐藏代码
cat("用户名:", comment_example$user$login, "\n")
用户名: nicek4267-max 
显示/隐藏代码
cat("创建时间:", comment_example$created_at, "\n")
创建时间: 2026-03-04T12:04:26Z 
显示/隐藏代码
cat("评论内容(前200字符):\n")
评论内容(前200字符):
显示/隐藏代码
cat(substr(comment_example$body, 1, 200), "...\n")
学号:2025303110116
姓名:于辛铠
感兴趣方向:农业遥感 ...

JSON 结构解析:

GitHub API 返回的评论对象包含以下关键字段:

字段 类型 说明
id integer 评论的唯一标识符
node_id string GraphQL 节点 ID
html_url string 评论的网页链接
issue_url string 所属 Issue 的 API 链接
user object 包含评论者信息(login, id, avatar_url 等)
body string 评论的 Markdown 内容
created_at string 创建时间(ISO 8601 格式)
updated_at string 最后更新时间

数据解析:从文本到结构化数据

解析策略

学生在 Issue 中的回复格式各异,我们需要设计灵活的解析策略:

可能的格式变体:

# 格式 1:标准格式
学号:2025303110116
姓名:张三
感兴趣的方向:农业遥感

# 格式 2:简化格式
2025303110116
张三
农业遥感

# 格式 3:包含额外信息
大家好!
学号: 2025303110116
姓名: 张三
方向: 农业遥感
请多关照!

# 格式 4:分隔符变体
学号:2025303110116
姓名: 李四(注意冒号不同)

正则表达式解析

显示/隐藏代码
#' 解析学生信息
#'
#' 从 GitHub Issue 评论正文中提取学号、姓名和感兴趣方向
#'
#' @param body 评论正文文本
#' @return 包含 student_id, name, interest 的数据框

parse_student_data <- function(body) {
  # 处理 NA 或空值
  if (is.na(body) || body == "") {
    return(data.frame(
      student_id = NA_character_,
      name = NA_character_,
      interest = NA_character_,
      stringsAsFactors = FALSE
    ))
  }

  # 提取学号 - 匹配各种格式
  student_id <- NA_character_

  # 格式1: "学号:2025303110116" 或 "学号: 2025303110116"
  # (?<=...) 是正向后行断言,匹配前面是特定模式的位置
  # [\u4e00-\u9fa5] 是中文字符范围
  if (str_detect(body, "学号[\uff1a:][\\s]*(\\d{10,13})")) {
    student_id <- str_extract(body, "学号[\uff1a:][\\s]*(\\d{10,13})") %>%
      str_extract("\\d{10,13}")
  }
  # 格式2: 直接以数字开头的行
  else if (str_detect(body, "^(\\d{10,13})")) {
    student_id <- str_extract(body, "^(\\d{10,13})")
  }

  # 提取姓名 - 匹配 "姓名:张三" 或 "姓名: 张三"
  name <- NA_character_
  if (str_detect(body, "姓名[\uff1a:][\\s]*([\\u4e00-\\u9fa5]{2,4})")) {
    name <- str_extract(body, "姓名[\uff1a:][\\s]*([\\u4e00-\\u9fa5]{2,4})") %>%
      str_replace("姓名[\uff1a:][\\s]*", "")
  }

  # 提取感兴趣方向
  interest <- NA_character_

  # 匹配 "感兴趣[的方向方向]*:xxx"
  interest_pattern <- "感兴趣[的方向方向]*[\uff1a:][\\s]*([\\u4e00-\\u9fa5a-zA-Z0-9]+)"
  if (str_detect(body, interest_pattern)) {
    interest <- str_extract(body, interest_pattern) %>%
      str_replace("感兴趣[的方向方向]*[\uff1a:][\\s]*", "") %>%
      str_trim()
  }

  # 清理兴趣字段中的常见无效值
  if (!is.na(interest) && interest %in% c("无", "NA", "暂无", "没有", "")) {
    interest <- NA_character_
  }

  data.frame(
    student_id = student_id,
    name = name,
    interest = interest,
    stringsAsFactors = FALSE
  )
}

# 测试解析函数
test_cases <- c(
  "学号:2025303110116\n姓名:张三\n感兴趣的方向:农业遥感",
  "2025303110116\n李四\n环境科学",
  "学号:2025303110116 姓名:王五"
)

for (test in test_cases) {
  cat("\n测试文本:\n", test, "\n")
  cat("解析结果:\n")
  print(parse_student_data(test))
}

测试文本:
 学号:2025303110116
姓名:张三
感兴趣的方向:农业遥感 
解析结果:
     student_id name interest
1 2025303110116 张三 农业遥感

测试文本:
 2025303110116
李四
环境科学 
解析结果:
     student_id name interest
1 2025303110116 <NA>     <NA>

测试文本:
 学号:2025303110116 姓名:王五 
解析结果:
     student_id name interest
1 2025303110116 王五     <NA>

批量解析所有评论

显示/隐藏代码
# 将数据框转换为列表以便遍历
# split() 函数按行分割数据框
comments_list <- split(all_comments, seq(nrow(all_comments)))

# 使用 lapply 批量解析每条评论
# 结果是一个列表,每个元素是一个数据框
student_data_list <- lapply(comments_list, function(x) {
  parse_student_data(x$body)
})

# 使用 bind_rows 将所有数据框合并为一个
student_data <- bind_rows(student_data_list)

# 添加原始数据中的其他字段
student_data <- student_data %>%
  mutate(
    user_login = all_comments$user$login,
    created_at = all_comments$created_at,
    html_url = all_comments$html_url
  )

# 数据质量概览
cat("解析结果概览:\n")
解析结果概览:
显示/隐藏代码
cat("总评论数:", nrow(all_comments), "\n")
总评论数: 311 
显示/隐藏代码
cat("成功解析学号:", sum(!is.na(student_data$student_id)), "\n")
成功解析学号: 296 
显示/隐藏代码
cat("成功解析姓名:", sum(!is.na(student_data$name)), "\n")
成功解析姓名: 299 
显示/隐藏代码
cat("成功解析兴趣方向:", sum(!is.na(student_data$interest)), "\n")
成功解析兴趣方向: 167 

数据验证:确保数据质量

验证规则设计

数据验证是数据分析中至关重要的一步。我们需要定义明确的规则来检查数据的完整性和正确性。

学号验证规则:

  1. 格式规则:必须以 2025 开头(华农 2025 级学生)
  2. 长度规则:必须是 13 位数字
  3. 字符规则:只能包含数字,不能有字母或符号

验证的重要性:

  • 数据完整性:确保后续分析基于正确的数据
  • 异常检测:识别可能的输入错误或恶意数据
  • 反馈机制:帮助数据提供者纠正错误

实现数据验证

显示/隐藏代码
# 添加验证字段
student_data <- student_data %>%
  mutate(
    # 去除首尾空白
    student_id = str_trim(student_id),

    # 验证学号格式:2025开头 + 9位数字 = 13位
    valid_id = str_detect(student_id, "^2025\\d{9}$"),

    # 记录验证失败原因(用于调试和反馈)
    validation_note = case_when(
      is.na(student_id) ~ "学号缺失",
      !str_detect(student_id, "^\\d+$") ~ "包含非数字字符",
      nchar(student_id) != 13 ~ paste0("长度错误(", nchar(student_id), "位,应为13位)"),
      !str_detect(student_id, "^2025") ~ "非2025开头",
      TRUE ~ NA_character_
    )
  )

# 统计验证结果
cat("=== 数据验证报告 ===\n")
=== 数据验证报告 ===
显示/隐藏代码
cat("总记录数:", nrow(student_data), "\n")
总记录数: 311 
显示/隐藏代码
cat("验证通过:", sum(student_data$valid_id, na.rm = TRUE), "\n")
验证通过: 293 
显示/隐藏代码
cat("验证失败:", sum(!student_data$valid_id | is.na(student_data$valid_id), na.rm = TRUE), "\n")
验证失败: 18 

不合规数据展示

显示/隐藏代码
# 筛选不合规记录
invalid_data <- student_data %>%
  filter(!valid_id | is.na(valid_id)) %>%
  select(student_id, name, validation_note, user_login, created_at) %>%
  arrange(created_at)

if (nrow(invalid_data) > 0) {
  cat("不合规数据明细:\n")
  knitr::kable(invalid_data,
               caption = "学号验证失败记录",
               col.names = c("学号", "姓名", "失败原因", "GitHub账号", "提交时间"))
} else {
  cat("✅ 所有学号均通过验证!\n")
}
不合规数据明细:
学号验证失败记录
学号 姓名 失败原因 GitHub账号 提交时间
NA NA 学号缺失 yisar 2026-03-04T12:05:30Z
NA NA 学号缺失 xihongshi2021 2026-03-04T12:07:17Z
NA NA 学号缺失 ZHGlory 2026-03-04T12:11:57Z
NA NA 学号缺失 xiaozz-zl 2026-03-04T12:27:22Z
2026303110120 刘小梅 非2025开头 CANGSU-mei 2026-03-04T13:34:20Z
NA 刘壮壮 学号缺失 Leo-hub123 2026-03-04T14:53:22Z
NA NA 学号缺失 cyx-coco 2026-03-04T15:50:59Z
NA NA 学号缺失 lsh1221 2026-03-05T01:11:32Z
NA 王力辉 学号缺失 dilikelei01 2026-03-05T02:25:10Z
NA 梅若男 学号缺失 Mei0416 2026-03-05T02:26:14Z
2022309210608 张子安 非2025开头 zhangzane001 2026-03-05T11:07:00Z
NA 王硕 学号缺失 wangxuan24 2026-03-05T12:38:32Z
NA NA 学号缺失 APFSDS-HE 2026-03-05T12:40:44Z
NA 沐禹廷 学号缺失 yuting-Mu 2026-03-05T12:42:24Z
NA NA 学号缺失 Yangjiahao1234 2026-03-05T13:59:34Z
NA NA 学号缺失 gaospecial 2026-03-06T02:02:40Z
2393146307 马文静 长度错误(10位,应为13位) unique520521 2026-03-06T08:12:29Z
NA 贠梦茹 学号缺失 ymr10 2026-03-06T13:47:52Z

数据保存:导出结构化数据

CSV 导出

将清洗后的数据保存为 CSV 格式,便于后续使用:

显示/隐藏代码
# 准备导出数据 - 只包含有效学号
export_data <- student_data %>%
  filter(valid_id) %>%
  select(
    学号 = student_id,
    姓名 = name,
    感兴趣方向 = interest
  )

# 保存为 CSV
# row.names = FALSE: 不添加行号列
# fileEncoding = "UTF-8": 确保中文正确编码
write.csv(export_data, "issue1_students.csv",
          row.names = FALSE,
          fileEncoding = "UTF-8")

cat("数据已保存到: issue1_students.csv\n")
数据已保存到: issue1_students.csv
显示/隐藏代码
cat("共", nrow(export_data), "条有效记录\n")
共 293 条有效记录
显示/隐藏代码
# 显示前10条记录作为预览
head(export_data, 10)
            学号   姓名                         感兴趣方向
1  2025303110116 于辛铠                           农业遥感
2  2025303110143 王培正 基于遥感影像的土壤盐渍化识别与提取
3  2025303110085   黄跃                           合成菌群
4  2025303110123 李嘉睿                               <NA>
5  2025303110115 丁红俊                               <NA>
6  2025303120081   吴蕾                               <NA>
7  2025303110076   邹毅                               <NA>
8  2025303120033 欧阳环                               <NA>
9  2025303110141 黄子凯                               <NA>
10 2025303120093 常东颖                               <NA>

数据分析:描述性统计

1. 学号前缀分析

学号的前几位通常编码了年份和专业信息。让我们分析学生的分布:

显示/隐藏代码
# 提取学号前缀(前4位)
prefix_analysis <- student_data %>%
  filter(valid_id) %>%
  mutate(
    prefix = substr(student_id, 1, 4),
    # 尝试解读前缀含义
    prefix_label = case_when(
      prefix == "2025" ~ "2025级",
      TRUE ~ prefix
    )
  )

# 统计前缀分布
prefix_count <- prefix_analysis %>%
  count(prefix, sort = TRUE) %>%
  head(10)

# 可视化
ggplot(prefix_count, aes(x = reorder(prefix, n), y = n)) +
  geom_bar(stat = "identity", fill = "steelblue", alpha = 0.8) +
  geom_text(aes(label = n), hjust = -0.3, size = 3) +
  coord_flip() +
  scale_y_continuous(expand = expansion(mult = c(0, 0.1))) +
  labs(
    title = "学号年份/专业前缀分布",
    subtitle = "基于有效的学生记录",
    x = "学号前缀",
    y = "学生人数"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 14, face = "bold"),
    text = element_text(family = "WenQuanYi")
  )

2. 感兴趣方向分析

分析学生的研究兴趣分布:

显示/隐藏代码
# 清洗和统计感兴趣方向
interest_analysis <- student_data %>%
  filter(valid_id, !is.na(interest)) %>%
  mutate(
    # 标准化方向名称(处理大小写、空格等)
    interest_clean = interest %>%
      str_trim() %>%
      str_to_lower() %>%
      str_replace_all("[[:space:]]+", " ")
  )

# 统计方向频次
interest_count <- interest_analysis %>%
  count(interest, sort = TRUE) %>%
  head(20)

# 可视化
ggplot(interest_count, aes(x = reorder(interest, n), y = n)) +
  geom_bar(stat = "identity", fill = "coral", alpha = 0.8) +
  geom_text(aes(label = n), hjust = -0.3, size = 3) +
  coord_flip() +
  scale_y_continuous(expand = expansion(mult = c(0, 0.1))) +
  labs(
    title = "学生感兴趣方向分布 (Top 20)",
    subtitle = paste0("共分析 ", nrow(interest_analysis), " 条有效兴趣数据"),
    x = "研究方向",
    y = "学生人数"
  ) +
  theme_minimal() +
  theme(
    plot.title = element_text(size = 14, face = "bold"),
    text = element_text(family = "WenQuanYi")
  )

3. 数据完整性分析

显示/隐藏代码
# 计算各项数据的完整率
completeness <- student_data %>%
  filter(valid_id) %>%
  summarise(
    总记录数 = n(),
    有学号 = sum(!is.na(student_id)),
    有姓名 = sum(!is.na(name)),
    有兴趣方向 = sum(!is.na(interest)),
    全部完整 = sum(!is.na(student_id) & !is.na(name) & !is.na(interest))
  ) %>%
  mutate(
    学号完整率 = round(有学号 / 总记录数 * 100, 1),
    姓名完整率 = round(有姓名 / 总记录数 * 100, 1),
    兴趣完整率 = round(有兴趣方向 / 总记录数 * 100, 1),
    全部完整率 = round(全部完整 / 总记录数 * 100, 1)
  )

knitr::kable(completeness, caption = "数据完整性统计")
数据完整性统计
总记录数 有学号 有姓名 有兴趣方向 全部完整 学号完整率 姓名完整率 兴趣完整率 全部完整率
293 293 290 161 160 100 99 54.9 54.6

完整数据质量报告

========================================
       Issue #1 数据质量报告
========================================
【数据获取】
- 总评论数: 311 
- 数据获取时间: 2026-04-03 20:28:32 
【数据解析】
- 成功解析学号: 296 
- 成功解析姓名: 299 
- 成功解析兴趣: 167 
【数据验证】
- 有效学号: 293 
- 无效学号: 3 
- 验证通过率: 94.2 %
【导出数据】
- 有效记录已保存至: issue1_students.csv
========================================

教学要点总结

1. API 数据获取的最佳实践

  • 缓存策略:将 API 响应缓存到本地文件,避免重复请求
  • 错误处理:检查响应状态码,优雅处理网络错误
  • 分页处理:使用 --paginate.limit = Inf 获取完整数据

2. 正则表达式的应用技巧

  • 多模式匹配:为常见的格式变体准备多个正则表达式
  • 非贪婪匹配:使用 *?+? 进行最小匹配
  • 后行断言:使用 (?<=...) 提取特定位置之后的内容

3. 数据验证的重要性

  • 前置验证:在分析开始前检查数据质量
  • 规则明确:验证规则应该清晰、可测试
  • 反馈机制:向数据提供者提供清晰的错误信息

4. dplyr 管道操作的工作流

data %>%
  filter(条件) %>%      # 筛选有效数据
  mutate(新列 = 计算) %>%  # 添加派生列
  group_by(分组变量) %>%   # 设置分组
  summarise(汇总统计) %>%  # 计算汇总值
  arrange(排序变量)        # 排序结果

5. ggplot2 的可视化原则

  • 数据映射:明确将数据变量映射到视觉属性
  • 图层叠加:使用 + 连接多个图层
  • 主题定制:使用 theme_*()theme() 调整外观

扩展思考

  1. 如何处理更复杂的文本格式?
    • 考虑使用专门的解析包如 readr::read_delim() 处理表格格式文本
    • 对于非结构化文本,可以探索 NLP 技术
  2. 如何自动化数据验证?
    • 使用 assertr 包进行管道式数据验证
    • 设置持续集成(CI)自动检查数据质量
  3. 如何处理数据更新?
    • 使用时间戳跟踪数据变化
    • 实现增量更新机制,只处理新评论
  4. 如何保护学生隐私?
    • 学号脱敏处理(如本例中的中间位替换)
    • 控制数据访问权限
    • 遵守数据保护法规(如 GDPR)

本案例分析展示了从原始数据到结构化输出的完整流程,体现了数据科学工作中数据获取、清洗、验证、分析和可视化的核心技能。