Campus Reference Table
csu_campuses <- tibble(
campus = c(
"Cal Poly Humboldt",
"Cal Poly Pomona",
"Cal Poly San Luis Obispo",
"CSU Bakersfield",
"CSU Channel Islands",
"CSU Chico",
"CSU Dominguez Hills",
"CSU East Bay",
"CSU Fresno",
"CSU Fullerton",
"CSU Long Beach",
"CSU Los Angeles",
"CSU Maritime Academy",
"CSU Monterey Bay",
"CSU Northridge",
"CSU Sacramento",
"CSU San Bernardino",
"CSU San Marcos",
"CSU Stanislaus",
"San Diego State",
"San Francisco State",
"San Jose State",
"Sonoma State"
),
url = c(
"https://www.humboldt.edu",
"https://www.cpp.edu",
"https://www.calpoly.edu",
"https://www.csub.edu",
"https://www.csuci.edu",
"https://www.csuchico.edu",
"https://www.csudh.edu",
"https://www.csueastbay.edu",
"https://www.fresnostate.edu",
"https://www.fullerton.edu",
"https://www.csulb.edu",
"https://www.calstatela.edu",
"https://www.csum.edu",
"https://www.csumb.edu",
"https://www.csun.edu",
"https://www.csus.edu",
"https://www.csusb.edu",
"https://www.csusm.edu",
"https://www.csustan.edu",
"https://www.sdsu.edu",
"https://www.sfsu.edu",
"https://www.sjsu.edu",
"https://www.sonoma.edu"
),
search_term = c(
"Cal Poly Humboldt",
"Cal Poly Pomona CPP",
"Cal Poly SLO",
"CSU Bakersfield CSUB",
"CSUCI \"Channel Islands\"",
"CSU Chico",
"CSU Dominguez Hills CSUDH",
"CSU East Bay CSUEB",
"Fresno State",
"Cal State Fullerton CSUF",
"Cal State Long Beach CSULB",
"Cal State LA CSULA",
"Cal Maritime CSUM",
"CSU Monterey Bay CSUMB",
"CSUN Northridge",
"Sacramento State CSUS",
"Cal State San Bernardino CSUSB",
"Cal State San Marcos CSUSM",
"CSU Stanislaus",
"SDSU San Diego State",
"SFSU San Francisco State",
"SJSU San Jose State",
"Sonoma State SSU"
)
)
kable(csu_campuses |> select(campus, url), caption = "All 23 CSU Campuses")
Part 1: SEO Scraping
Each campus homepage is scraped for its title tag, meta description,
meta keywords, and H1–H3 headings — the core on-page SEO elements.
(Answers BQ 7 & 8 when joined with enrollment data.)
scrape_seo <- function(campus, url) {
message("Scraping SEO: ", campus)
Sys.sleep(1.5)
tryCatch({
page <- read_html(
GET(url,
user_agent("Mozilla/5.0 (educational research project)"),
timeout(15))
)
title <- page |> html_element("title") |> html_text(trim = TRUE)
meta_desc <- page |> html_element("meta[name='description']") |> html_attr("content")
meta_keys <- page |> html_element("meta[name='keywords']") |> html_attr("content")
h1s <- page |> html_elements("h1") |> html_text(trim = TRUE) |> str_squish() |> paste(collapse = " | ")
h2s <- page |> html_elements("h2") |> html_text(trim = TRUE) |> str_squish() |> paste(collapse = " | ")
h3s <- page |> html_elements("h3") |> html_text(trim = TRUE) |> str_squish() |> paste(collapse = " | ")
tibble(
campus = campus,
url = url,
title = title %||% NA_character_,
meta_desc = meta_desc %||% NA_character_,
meta_keys = meta_keys %||% NA_character_,
h1s = h1s,
h2s = h2s,
h3s = h3s,
title_length = nchar(title),
metadesc_length = nchar(meta_desc),
has_meta_desc = !is.na(meta_desc),
has_keywords = !is.na(meta_keys),
scraped_ok = TRUE
)
}, error = function(e) {
message(" ERROR on ", campus, ": ", e$message)
tibble(
campus = campus, url = url,
title = NA, meta_desc = NA, meta_keys = NA,
h1s = NA, h2s = NA, h3s = NA,
title_length = NA, metadesc_length = NA,
has_meta_desc = FALSE, has_keywords = FALSE,
scraped_ok = FALSE
)
})
}
seo_data <- map2(csu_campuses$campus, csu_campuses$url, scrape_seo) |>
list_rbind()
write.csv(seo_data, "csu_seo_data.csv", row.names = FALSE)
SEO Results Preview
seo_data |>
select(campus, title, title_length, metadesc_length, has_meta_desc, scraped_ok) |>
kable(caption = "SEO Summary — All 23 CSU Campuses")
SEO Summary — All 23 CSU Campuses
| Cal Poly Humboldt |
Cal Poly Humboldt | California State Polytechnic
University, Humboldt |
69 |
144 |
TRUE |
TRUE |
| Cal Poly Pomona |
Cal Poly Pomona - #1 Polytechnic University for
Diversity and Economic Mobility |
79 |
223 |
TRUE |
TRUE |
| Cal Poly San Luis Obispo |
Cal Poly | Learn by Doing |
25 |
114 |
TRUE |
TRUE |
| CSU Bakersfield |
Home | California State University, Bakersfield |
47 |
NA |
FALSE |
TRUE |
| CSU Channel Islands |
CSU Channel Islands |
19 |
159 |
TRUE |
TRUE |
| CSU Chico |
Chico State |
11 |
NA |
FALSE |
TRUE |
| CSU Dominguez Hills |
California State University Dominguez Hills |
43 |
497 |
TRUE |
TRUE |
| CSU East Bay |
California State University, East Bay | CSUEB |
45 |
154 |
TRUE |
TRUE |
| CSU Fresno |
Home - Fresno State |
19 |
NA |
FALSE |
TRUE |
| CSU Fullerton |
Achieve Greatness: California State University,
Fullerton |
57 |
143 |
TRUE |
TRUE |
| CSU Long Beach |
California State University, Long Beach |
39 |
102 |
TRUE |
TRUE |
| CSU Los Angeles |
Home | Cal State LA |
19 |
NA |
FALSE |
TRUE |
| CSU Maritime Academy |
Home | Cal Poly Maritime Academy |
32 |
NA |
FALSE |
TRUE |
| CSU Monterey Bay |
Home | California State University Monterey Bay |
47 |
318 |
TRUE |
TRUE |
| CSU Northridge |
California State University, Northridge |
39 |
149 |
TRUE |
TRUE |
| CSU Sacramento |
California State University, Sacramento | Sacramento
State |
58 |
163 |
TRUE |
TRUE |
| CSU San Bernardino |
California State University, San Bernardino |
CSUSB |
51 |
176 |
TRUE |
TRUE |
| CSU San Marcos |
California State University San Marcos in North San
Diego County | CSUSM |
72 |
NA |
FALSE |
TRUE |
| CSU Stanislaus |
Home | California State University Stanislaus |
45 |
181 |
TRUE |
TRUE |
| San Diego State |
Home | San Diego State University |
33 |
NA |
FALSE |
TRUE |
| San Francisco State |
San Francisco State University |
30 |
153 |
TRUE |
TRUE |
| San Jose State |
San José State University |
25 |
NA |
FALSE |
TRUE |
| Sonoma State |
NA |
NA |
NA |
FALSE |
FALSE |
Part 2: Enrollment & Demographics
Enrollment figures are sourced from the official CSU system facts
page (calstate.edu) and entered directly — the site blocks automated
scraping. Verify or update numbers at: https://www.calstate.edu/csu-system/about-the-csu/facts-about-the-csu/enrollment
(Answers BQ 2 & 3.)
# Source: CSU Facts About the CSU — Fall 2023 headcount enrollment
# Update figures from calstate.edu if a newer year is available
enrollment_clean <- tibble(
campus = c(
"CSU Long Beach",
"CSU Fullerton",
"CSU Northridge",
"San Diego State",
"San Jose State",
"CSU Sacramento",
"Cal Poly San Luis Obispo",
"Cal Poly Pomona",
"CSU Los Angeles",
"San Francisco State",
"CSU Fresno",
"CSU San Bernardino",
"CSU Chico",
"CSU San Marcos",
"CSU Dominguez Hills",
"CSU East Bay",
"CSU Stanislaus",
"CSU Bakersfield",
"CSU Monterey Bay",
"CSU Channel Islands",
"Sonoma State",
"Cal Poly Humboldt",
"CSU Maritime Academy"
),
total_enrollment = c(
45064, 40421, 37701, 37014, 35915,
31536, 22108, 27782, 27311, 25758,
25215, 20622, 16549, 16862, 16484,
14710, 11103, 11201, 8196, 7104,
6912, 6699, 912
)
) |>
arrange(desc(total_enrollment)) |>
mutate(
rank = row_number(),
enrollment_tier = case_when(
total_enrollment >= 30000 ~ "Large (30k+)",
total_enrollment >= 15000 ~ "Medium (15k–30k)",
TRUE ~ "Small (<15k)"
)
)
write.csv(enrollment_clean, "csu_enrollment.csv", row.names = FALSE)
Enrollment by Campus (BQ 2)
enrollment_clean |>
select(rank, campus, total_enrollment, enrollment_tier) |>
kable(
caption = "CSU Enrollment — All Campuses, Fall 2023 (Largest to Smallest)",
format.args = list(big.mark = ",")
)
CSU Enrollment — All Campuses, Fall 2023 (Largest to
Smallest)
| 1 |
CSU Long Beach |
45,064 |
Large (30k+) |
| 2 |
CSU Fullerton |
40,421 |
Large (30k+) |
| 3 |
CSU Northridge |
37,701 |
Large (30k+) |
| 4 |
San Diego State |
37,014 |
Large (30k+) |
| 5 |
San Jose State |
35,915 |
Large (30k+) |
| 6 |
CSU Sacramento |
31,536 |
Large (30k+) |
| 7 |
Cal Poly Pomona |
27,782 |
Medium (15k–30k) |
| 8 |
CSU Los Angeles |
27,311 |
Medium (15k–30k) |
| 9 |
San Francisco State |
25,758 |
Medium (15k–30k) |
| 10 |
CSU Fresno |
25,215 |
Medium (15k–30k) |
| 11 |
Cal Poly San Luis Obispo |
22,108 |
Medium (15k–30k) |
| 12 |
CSU San Bernardino |
20,622 |
Medium (15k–30k) |
| 13 |
CSU San Marcos |
16,862 |
Medium (15k–30k) |
| 14 |
CSU Chico |
16,549 |
Medium (15k–30k) |
| 15 |
CSU Dominguez Hills |
16,484 |
Medium (15k–30k) |
| 16 |
CSU East Bay |
14,710 |
Small (<15k) |
| 17 |
CSU Bakersfield |
11,201 |
Small (<15k) |
| 18 |
CSU Stanislaus |
11,103 |
Small (<15k) |
| 19 |
CSU Monterey Bay |
8,196 |
Small (<15k) |
| 20 |
CSU Channel Islands |
7,104 |
Small (<15k) |
| 21 |
Sonoma State |
6,912 |
Small (<15k) |
| 22 |
Cal Poly Humboldt |
6,699 |
Small (<15k) |
| 23 |
CSU Maritime Academy |
912 |
Small (<15k) |
Demographics (BQ 3)
Demographic data (ethnicity, gender, residency) is entered from the
CSU Facts page. Update at: https://www.calstate.edu/csu-system/about-the-csu/facts-about-the-csu/Pages/student-profile.aspx
# System-wide demographic breakdown (Fall 2023, % of total enrollment)
csu_demographics <- tibble(
group = c(
"Hispanic/Latino", "White", "Asian", "Unknown/Other",
"Black/African American", "International", "Two or More Races",
"American Indian", "Pacific Islander"
),
pct_system = c(45.2, 19.8, 15.1, 7.4, 4.3, 4.1, 3.3, 0.4, 0.4)
)
write.csv(csu_demographics, "csu_demographics.csv", row.names = FALSE)
kable(csu_demographics, caption = "CSU System-Wide Student Demographics — Fall 2023")
CSU System-Wide Student Demographics — Fall 2023
| Hispanic/Latino |
45.2 |
| White |
19.8 |
| Asian |
15.1 |
| Unknown/Other |
7.4 |
| Black/African American |
4.3 |
| International |
4.1 |
| Two or More Races |
3.3 |
| American Indian |
0.4 |
| Pacific Islander |
0.4 |
Part 3: Reddit Sentiment Scraping
Reddit’s public JSON search API collects posts mentioning each
campus. Sentiment is scored using the AFINN lexicon. (Answers BQ 4,
5 & 6.)
scrape_reddit <- function(campus, search_term) {
message("Scraping Reddit: ", campus)
Sys.sleep(2)
tryCatch({
threads <- find_thread_urls(
keywords = search_term,
sort_by = "relevance",
period = "all"
)
if (is.null(threads) || nrow(threads) == 0) {
message(" No threads found for: ", campus)
return(tibble())
}
threads |>
mutate(
campus = campus,
text_full = paste(coalesce(title, ""), coalesce(text, ""), sep = " ")
) |>
select(any_of(c("campus", "title", "text", "text_full", "score",
"comments", "subreddit", "date_utc", "url")))
}, error = function(e) {
message(" ERROR for ", campus, ": ", e$message)
tibble()
})
}
reddit_raw <- map2(csu_campuses$campus, csu_campuses$search_term, scrape_reddit) |>
list_rbind()
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
## parsing URLs on page 1...
# Guarantee text_full exists even if all calls failed
if (!"text_full" %in% names(reddit_raw)) reddit_raw$text_full <- character(0)
write.csv(reddit_raw, "csu_reddit_raw.csv", row.names = FALSE)
message(nrow(reddit_raw), " total Reddit posts collected")
Sentiment Scoring
# Using the Bing lexicon — downloads silently, no interactive prompt required
# Bing labels each word as "positive" or "negative"
# Sentiment score = (positive words - negative words) / total sentiment words
bing <- get_sentiments("bing")
if (nrow(reddit_raw) == 0 || !"text_full" %in% names(reddit_raw)) {
message("reddit_raw is empty — Reddit may be rate-limiting. Sentiment skipped.")
reddit_sentiment <- tibble(
campus = character(), post_id = integer(), score = integer(),
num_comments = integer(), created_dt = as.POSIXct(character()),
n_positive = integer(), n_negative = integer(),
sentiment_score = numeric(), word_count = integer()
)
campus_sentiment <- tibble(
campus = csu_campuses$campus,
n_posts = 0L, avg_sentiment = NA_real_, median_sentiment = NA_real_,
avg_upvotes = NA_real_, avg_comments = NA_real_,
sentiment_label = "No data"
)
} else {
reddit_sentiment <- reddit_raw |>
mutate(
post_id = row_number(),
score = as.numeric(coalesce(as.character(score), "0")),
comments = as.numeric(coalesce(as.character(comments), "0"))
) |>
unnest_tokens(word, text_full) |>
anti_join(stop_words, by = "word") |>
inner_join(bing, by = "word") |>
group_by(campus, post_id, score, comments) |>
summarise(
n_positive = sum(sentiment == "positive"),
n_negative = sum(sentiment == "negative"),
sentiment_score = (n_positive - n_negative) / (n_positive + n_negative),
word_count = n(),
.groups = "drop"
)
campus_sentiment <- reddit_sentiment |>
group_by(campus) |>
summarise(
n_posts = n(),
avg_sentiment = round(mean(sentiment_score, na.rm = TRUE), 3),
median_sentiment = round(median(sentiment_score, na.rm = TRUE), 3),
avg_upvotes = round(mean(score, na.rm = TRUE), 1),
avg_comments = round(mean(comments, na.rm = TRUE), 1),
.groups = "drop"
) |>
mutate(
sentiment_label = case_when(
avg_sentiment > 0.1 ~ "Positive",
avg_sentiment < -0.1 ~ "Negative",
TRUE ~ "Neutral"
)
) |>
arrange(desc(avg_sentiment))
write.csv(reddit_sentiment, "csu_reddit_sentiment.csv", row.names = FALSE)
write.csv(campus_sentiment, "csu_campus_sentiment.csv", row.names = FALSE)
} # end else (reddit_raw has data)
Part 4: Google Trends
Search interest for each campus over the past 5 years, California
only. Campuses are pulled in batches of 4 with "CSU" as a
normalizing anchor term in every batch.
gt_terms <- tibble(
campus = csu_campuses$campus,
trends_term = c(
"Cal Poly Humboldt",
"Cal Poly Pomona",
"Cal Poly SLO",
"CSU Bakersfield",
"Cal State Channel Islands",
"CSU Chico",
"CSU Dominguez Hills",
"CSU East Bay",
"Fresno State",
"Cal State Fullerton",
"Cal State Long Beach",
"Cal State LA",
"Cal Maritime",
"CSU Monterey Bay",
"CSUN",
"Sacramento State",
"Cal State San Bernardino",
"Cal State San Marcos",
"CSU Stanislaus",
"SDSU",
"San Francisco State",
"San Jose State",
"Sonoma State"
)
)
anchor <- "CSU"
batch_size <- 4
batches <- split(gt_terms$trends_term, ceiling(seq_along(gt_terms$trends_term) / batch_size))
fetch_trends_batch <- function(terms, anchor, batch_num) {
message("Google Trends batch ", batch_num, ": ", paste(terms, collapse = ", "))
Sys.sleep(3)
tryCatch({
result <- gtrends(
keyword = c(terms, anchor),
geo = "US-CA",
time = "today+5-y",
onlyInterest = TRUE
)
result$interest_over_time |>
as_tibble() |>
filter(keyword != anchor) |>
select(date, keyword, hits) |>
mutate(
hits = suppressWarnings(as.numeric(hits)),
date = as.Date(date)
)
}, error = function(e) {
message(" ERROR batch ", batch_num, ": ", e$message)
tibble(date = as.Date(NA), keyword = terms, hits = NA_real_)
})
}
trends_raw <- imap(batches, ~ fetch_trends_batch(.x, anchor, .y)) |>
list_rbind()
trends_data <- trends_raw |>
left_join(gt_terms, by = c("keyword" = "trends_term")) |>
select(campus, keyword, date, hits) |>
arrange(campus, date)
trends_summary <- trends_data |>
group_by(campus) |>
summarise(
avg_interest = round(mean(hits, na.rm = TRUE), 1),
peak_interest = max(hits, na.rm = TRUE),
peak_date = date[which.max(hits)],
.groups = "drop"
) |>
arrange(desc(avg_interest))
write.csv(trends_data, "csu_trends_timeseries.csv", row.names = FALSE)
write.csv(trends_summary, "csu_trends_summary.csv", row.names = FALSE)
kable(trends_summary, caption = "Google Trends — Avg Search Interest by Campus (CA, last 5 years)")
Google Trends — Avg Search Interest by Campus (CA, last 5
years)
| CSU Northridge |
38.6 |
81 |
2022-08-28 |
| CSU Fresno |
26.0 |
100 |
2021-09-19 |
| San Diego State |
17.8 |
100 |
2023-04-02 |
| Cal Poly Pomona |
13.3 |
25 |
2023-01-29 |
| San Jose State |
13.1 |
29 |
2023-08-27 |
| CSU Sacramento |
12.6 |
23 |
2022-07-17 |
| Cal Poly San Luis Obispo |
10.1 |
37 |
2024-03-10 |
| San Francisco State |
9.3 |
15 |
2022-03-13 |
| CSU Fullerton |
7.8 |
16 |
2022-03-13 |
| CSU Los Angeles |
7.4 |
18 |
2022-08-21 |
| Sonoma State |
6.3 |
21 |
2025-01-19 |
| CSU Long Beach |
4.0 |
7 |
2022-02-13 |
| Cal Poly Humboldt |
3.3 |
15 |
2024-04-21 |
| CSU Chico |
2.3 |
5 |
2021-08-22 |
| CSU East Bay |
2.1 |
4 |
2022-08-14 |
| CSU Maritime Academy |
1.1 |
3 |
2024-06-02 |
| CSU Bakersfield |
1.0 |
2 |
2022-11-27 |
| CSU Monterey Bay |
1.0 |
3 |
2025-08-03 |
| CSU San Bernardino |
1.0 |
1 |
2021-07-04 |
| CSU San Marcos |
1.0 |
1 |
2021-07-11 |
| CSU Stanislaus |
1.0 |
1 |
2021-08-15 |
| CSU Channel Islands |
0.8 |
1 |
2021-08-15 |
| CSU Dominguez Hills |
0.8 |
2 |
2023-11-26 |
Data Summary
tibble(
Dataset = c(
"SEO Data",
"Enrollment",
"Demographics (raw)",
"Reddit Posts (raw)",
"Reddit Sentiment (post-level)",
"Campus Sentiment (summary)",
"Google Trends (time series)",
"Google Trends (summary)"
),
Rows = c(
nrow(seo_data),
nrow(enrollment_clean),
nrow(csu_demographics),
nrow(reddit_raw),
nrow(reddit_sentiment),
nrow(campus_sentiment),
nrow(trends_data),
nrow(trends_summary)
),
File = c(
"csu_seo_data.csv",
"csu_enrollment.csv",
"csu_demographics.csv",
"csu_reddit_raw.csv",
"csu_reddit_sentiment.csv",
"csu_campus_sentiment.csv",
"csu_trends_timeseries.csv",
"csu_trends_summary.csv"
)
) |>
kable(caption = "All Output Files")
All Output Files
| SEO Data |
23 |
csu_seo_data.csv |
| Enrollment |
23 |
csu_enrollment.csv |
| Demographics (raw) |
9 |
csu_demographics.csv |
| Reddit Posts (raw) |
0 |
csu_reddit_raw.csv |
| Reddit Sentiment (post-level) |
0 |
csu_reddit_sentiment.csv |
| Campus Sentiment (summary) |
23 |
csu_campus_sentiment.csv |
| Google Trends (time series) |
6026 |
csu_trends_timeseries.csv |
| Google Trends (summary) |
23 |
csu_trends_summary.csv |