Introduction

Problem Statement

The California State University (CSU) system enrolls over 460,000 students across 23 campuses, making it the largest four-year public university system in the United States. Despite its scale, individual campuses compete for students, funding, and public attention in an increasingly digital landscape. Marketing and admissions offices must understand not only how their campus compares on traditional metrics like enrollment size and geographic reach, but also how they are perceived online — in search engines, on social media, and in public discourse.

This project addresses a core marketing analytics question: Do a campus’s digital presence and public sentiment predict its enrollment performance, and where do gaps exist that admissions and marketing teams can close?

Research Objectives

  1. Benchmark all 23 CSU campuses on enrollment size and geographic distribution.
  2. Evaluate each campus’s web presence through SEO signals (title tags, meta descriptions, heading structure).
  3. Measure public sentiment toward each campus using Reddit discussions and YouTube comments.
  4. Analyze Google Search interest trends to identify rising and declining campus awareness.
  5. Build predictive models linking digital presence and sentiment to enrollment outcomes.
  6. Segment campuses into strategic archetypes to support differentiated marketing recommendations.

Business Relevance

Enrollment directly drives state funding allocations, staffing levels, and program viability. A campus that ranks poorly on SEO or carries negative online sentiment faces a possible struggle with attracting new students. This could be due to poor visibility online, broken public trust, and lost student enrollment. This analysis provides CSU marketing and admissions leadership with an evidence base for prioritizing digital investments and messaging improvements.

Data Sources

To establish a baseline for this analysis, we began with primary research inside our own classroom. A twelve-question survey was administered to the CSUCI MSBA cohort in July 2026, yielding twelve complete responses covering how students searched for programs, which sources they trusted, and what ultimately drove their enrollment decision. The results were decisive: nine of twelve respondents named either proximity to home or cost as their single biggest reason for choosing CSUCI, and the same two factors topped the importance rankings, with tuition averaging 4.42 and location 4.33 on a five-point scale. Social media presence finished last among twelve factors at 2.42, while the university website was the most trusted information source, cited by half the cohort — twice as often as any alternative. Yet the survey also surfaced a tension we could not ignore: despite ranking social platforms lowest, seven of twelve respondents acknowledged that online posts or reviews had changed their opinion of a university, and ten of twelve reported that positive discussion would make them more likely to apply. Stated importance, in other words, understates actual influence. The remainder of our analysis looks into two channels rather than one — the structural web presence and organic search visibility that determine whether a campus is found, and the public sentiment, negative sentiment especially, that shapes what a prospective student concludes once they find it.

Data was collected from five independent sources using web scraping, public APIs, and JSON data retrieval:

Source Method Coverage
Class survey Google Form → CSV CSUCI MSBA cohort, n = 12, July 2026
CSU homepage SEO rvest web scraper 22 of 23 campuses (Sonoma State blocked scraper)
Reddit discussions Python scraper → JSON 25,836 posts/comments, all 23 campuses
YouTube video stats YouTube Data API v3 → CSV Selected campus channels
Google Trends gtrendsR API All 23 campuses, 5-year window
Enrollment & demographics calstate.edu (manual) Fall 2023, all 23 campuses
Systemwide enrollment trends CSU Enrollment Dashboard Fall 2015–2025, systemwide headcount
System KPIs CSU Enrollment Dashboard Fall 2024 & 2025 (CA residents, undergrad, grad %)

Business Questions

This report collects and analyzes data across all 23 California State University campuses to answer the following business questions:

# Business Question Data Source
1 What is the most important deciding factor for the MSBA cohort to join CSUCI? Class survey (n = 12)
2 How do CSU campuses compare in enrollment size and geographic distribution? calstate.edu (scraped)
3 How discoverable are CSU campus websites — and how do their SEO signals compare? SEO scraper (title tags, meta descriptions, heading structure)
4 What are students saying about CSU campuses in public forums and on YouTube? Reddit API; YouTube Data API v3
5 Are students searching for CSU campuses — and is interest growing or declining? Google Trends API
6 Can a campus’s digital presence and public sentiment predict its enrollment performance? Regression, clustering, and logistic models
Business Question 1 (most important deciding factor for the MSBA cohort) is answered via a Google Form survey distributed to the CSUCI MSBA cohort (n = 12). Results are presented in the Business Questions Answered section below.

Executive Dashboard

23
CSU Campuses
25,836
Reddit Posts
115
YouTube Videos
5
Data Sources
96%
SEO Scraped

Key Findings

  • Large universities generally receive more Google search interest, but search interest and enrollment are imperfectly correlated — smaller campuses can close the gap with targeted content.
  • Several campuses have poor SEO scores despite strong enrollment, representing quick-win improvement opportunities.
  • Reddit sentiment is mostly neutral across the CSU system; outliers in both directions reveal actionable reputational risks.
  • University websites are the most trusted information source per the MSBA cohort survey — SEO investment directly protects this channel.
  • SEO alone does not predict enrollment; sentiment and search interest together provide a stronger signal.

Recommendation Summary

Priority Recommendation Impact Difficulty
High Improve homepage metadata High Low
High Optimize title tags High Low
Medium Monitor Reddit sentiment monthly Medium Medium
Medium Expand YouTube strategy Medium Medium
Long Term Build predictive enrollment dashboard High High
Enrollment follows visibility — campuses that invest in SEO and maintain positive online sentiment consistently outperform peers in brand awareness and student interest.

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"
  ),
  year_established = c(
    1913,  # Cal Poly Humboldt
    1938,  # Cal Poly Pomona
    1901,  # Cal Poly San Luis Obispo
    1965,  # CSU Bakersfield
    2002,  # CSU Channel Islands (newest CSU)
    1887,  # CSU Chico (oldest CSU)
    1960,  # CSU Dominguez Hills
    1957,  # CSU East Bay
    1911,  # CSU Fresno
    1957,  # CSU Fullerton
    1949,  # CSU Long Beach
    1947,  # CSU Los Angeles
    1929,  # CSU Maritime Academy
    1994,  # CSU Monterey Bay
    1958,  # CSU Northridge
    1947,  # CSU Sacramento
    1960,  # CSU San Bernardino
    1989,  # CSU San Marcos
    1957,  # CSU Stanislaus
    1897,  # San Diego State
    1899,  # San Francisco State
    1857,  # San Jose State (oldest public university on West Coast)
    1960   # Sonoma State
  )
) |>
  mutate(campus_age = 2025L - year_established)

1. What Students Told Us

What Is the Most Important Deciding Factor for the MSBA Cohort to Join CSUCI?

Primary Data — CSUCI MSBA Cohort Survey (n = 12). A Google Form survey was distributed to students in the CSUCI MSBA program to capture firsthand perspectives on university search behavior, decision factors, and the role of digital platforms. Because respondents are current graduate students reflecting on their own enrollment journey, results represent insider student experience rather than a broad prospective-student sample. Given the small sample size, findings are treated as exploratory and directional.

survey_raw <- read.csv("customer_feedback.csv", check.names = FALSE)

survey <- survey_raw |>
  rename(
    timestamp        = 1,
    univ_types       = 2,
    search_freq      = 3,
    platforms        = 4,
    imp_tuition      = 5,
    imp_finaid       = 6,
    imp_reputation   = 7,
    imp_location     = 8,
    imp_online       = 9,
    imp_campus_env   = 10,
    imp_career       = 11,
    imp_faculty      = 12,
    imp_applic       = 13,
    imp_social_media = 14,
    imp_website      = 15,
    imp_support      = 16,
    likely_apply     = 17,
    reviews_changed  = 18,
    trusted_source   = 19,
    info_session     = 20,
    social_csu       = 21,
    reason_csuci     = 22
  )
importance_means <- survey |>
  summarise(across(starts_with("imp_"), \(x) mean(x, na.rm = TRUE))) |>
  pivot_longer(everything(), names_to = "factor", values_to = "mean_score") |>
  mutate(factor = dplyr::recode(factor,
    imp_tuition      = "Tuition / Cost",
    imp_finaid       = "Financial Aid",
    imp_reputation   = "Program Reputation",
    imp_location     = "Location",
    imp_online       = "Online / Flexible Options",
    imp_campus_env   = "Campus Environment",
    imp_career       = "Career Outcomes",
    imp_faculty      = "Faculty Reputation",
    imp_applic       = "Application Requirements",
    imp_social_media = "Social Media Presence",
    imp_website      = "Website Quality",
    imp_support      = "Support Programs"
  )) |>
  arrange(desc(mean_score))

importance_means |>
  kable(
    caption = "Mean Importance Ratings — CSUCI MSBA Cohort Survey (1 = Low, 5 = High)",
    col.names = c("Decision Factor", "Mean Score (1–5)"),
    digits = 2
  )
Mean Importance Ratings — CSUCI MSBA Cohort Survey (1 = Low, 5 = High)
Decision Factor Mean Score (1–5)
Tuition / Cost 4.42
Location 4.33
Program Reputation 4.17
Career Outcomes 4.08
Financial Aid 3.75
Application Requirements 3.75
Faculty Reputation 3.58
Website Quality 3.50
Campus Environment 3.42
Online / Flexible Options 3.33
Support Programs 3.00
Social Media Presence 2.42
p_importance <- ggplot(importance_means,
       aes(x = mean_score, y = reorder(factor, mean_score))) +
  geom_col(fill = "#C8102E", width = 0.65) +
  geom_text(aes(label = round(mean_score, 2)), hjust = -0.2, size = 3.2) +
  scale_x_continuous(limits = c(0, 5.4), breaks = 1:5) +
  labs(
    title  = "What Matters Most When Choosing a University?",
    subtitle = "CSUCI MSBA Cohort Survey — Mean Importance (1–5 scale, n = 12)",
    x = "Mean Score", y = NULL
  ) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.major.y = element_blank())

print(p_importance)

ggsave("output_survey_importance.png", p_importance,
       width = 7, height = 5, dpi = 150, bg = "white")
platform_counts <- survey |>
  pull(platforms) |>
  strsplit(";") |>
  unlist() |>
  trimws() |>
  table() |>
  as.data.frame() |>
  rename(Platform = Var1, Count = Freq) |>
  arrange(desc(Count))

platform_counts |>
  kable(caption = "Research Platforms Used by MSBA Cohort (Multiple Selections Allowed)")
Research Platforms Used by MSBA Cohort (Multiple Selections Allowed)
Platform Count
University Websites 10
Google Search 9
Friends/Family recommendations 6
College ranking websites 3
Instagram 3
Other 3
LinkedIn 2
YouTube 2
Reddit 1
TikTok 1
survey |>
  count(trusted_source, name = "Respondents") |>
  arrange(desc(Respondents)) |>
  kable(
    caption = "Most Trusted Online Source When Researching a University",
    col.names = c("Trusted Source", "Respondents")
  )
Most Trusted Online Source When Researching a University
Trusted Source Respondents
University Website 6
Current Students/Alumni 3
AI Tools (ChatGPT, Gemini, Claude) 2
Google Search results 1
social_pct <- survey |>
  count(social_csu) |>
  mutate(Pct = paste0(round(n / sum(n) * 100), "%")) |>
  rename(Response = social_csu, Count = n)

reviews_pct <- survey |>
  count(reviews_changed) |>
  mutate(Pct = paste0(round(n / sum(n) * 100), "%")) |>
  rename(Response = reviews_changed, Count = n)

social_pct |>
  kable(caption = "Did social media influence your perception of any CSU campus?")
Did social media influence your perception of any CSU campus?
Response Count Pct
No 10 83%
Yes 2 17%
reviews_pct |>
  kable(caption = "Have online reviews or Reddit posts changed your opinion about a university?")
Have online reviews or Reddit posts changed your opinion about a university?
Response Count Pct
No 5 42%
Yes 7 58%
survey |>
  count(reason_csuci, name = "Count") |>
  arrange(desc(Count)) |>
  kable(
    caption = "Primary Reason for Choosing CSUCI — MSBA Cohort",
    col.names = c("Reason", "Count")
  )
Primary Reason for Choosing CSUCI — MSBA Cohort
Reason Count
Close to Home 5
Cost 4
Other 2
Brand Loyalty 1
Tuition/Cost, Program Reputation, Career Outcomes, and Location consistently ranked as the highest-importance factors — all areas where university websites and SEO visibility directly influence discoverability. Social media influenced at least some CSU campus perception for roughly half of respondents, and University Websites emerged as the most trusted research source, reinforcing the SEO findings in Part 2. The dominance of “Cost” and “Close to Home” as CSUCI enrollment drivers suggests that proximity and affordability messaging should anchor CSUCI’s digital content strategy.
Cost and proximity dominate enrollment decisions for CSUCI’s own students — messaging campaigns should lead with affordability and local access, not prestige rankings.

2. Understanding the CSU Landscape

Per-campus enrollment figures are sourced from the official CSU system facts page (calstate.edu) and entered directly — the site blocks automated scraping. Systemwide headcount trends (Fall 2015–2025) are loaded from the CSU Enrollment Dashboard (tableau.calstate.edu). 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)

Campus Master Reference (BQ 2)

enrollment_clean |>
  mutate(pct_of_system = round(total_enrollment / sum(total_enrollment) * 100, 1)) |>
  left_join(csu_campuses |> select(campus, year_established, campus_age, url),
            by = "campus") |>
  select(rank, campus, year_established, campus_age, total_enrollment, pct_of_system, enrollment_tier, url) |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
         pct_of_system    = paste0(pct_of_system, "%")) |>
  kable(
    caption   = "CSU Campus Master Reference — Fall 2023 Enrollment",
    col.names = c("Rank", "Campus", "Year Est.", "Age", "Enrollment", "% of System", "Tier", "URL"),
    escape    = FALSE
  )
CSU Campus Master Reference — Fall 2023 Enrollment
Rank Campus Year Est. Age Enrollment % of System Tier URL
1 CSU Long Beach 1949 76 45,064 9.1% Large (30k+) https://www.csulb.edu
2 CSU Fullerton 1957 68 40,421 8.2% Large (30k+) https://www.fullerton.edu
3 CSU Northridge 1958 67 37,701 7.6% Large (30k+) https://www.csun.edu
4 San Diego State 1897 128 37,014 7.5% Large (30k+) https://www.sdsu.edu
5 San Jose State 1857 168 35,915 7.3% Large (30k+) https://www.sjsu.edu
6 CSU Sacramento 1947 78 31,536 6.4% Large (30k+) https://www.csus.edu
7 Cal Poly Pomona 1938 87 27,782 5.6% Medium (15k–30k) https://www.cpp.edu
8 CSU Los Angeles 1947 78 27,311 5.5% Medium (15k–30k) https://www.calstatela.edu
9 San Francisco State 1899 126 25,758 5.2% Medium (15k–30k) https://www.sfsu.edu
10 CSU Fresno 1911 114 25,215 5.1% Medium (15k–30k) https://www.fresnostate.edu
11 Cal Poly San Luis Obispo 1901 124 22,108 4.5% Medium (15k–30k) https://www.calpoly.edu
12 CSU San Bernardino 1960 65 20,622 4.2% Medium (15k–30k) https://www.csusb.edu
13 CSU San Marcos 1989 36 16,862 3.4% Medium (15k–30k) https://www.csusm.edu
14 CSU Chico 1887 138 16,549 3.4% Medium (15k–30k) https://www.csuchico.edu
15 CSU Dominguez Hills 1960 65 16,484 3.3% Medium (15k–30k) https://www.csudh.edu
16 CSU East Bay 1957 68 14,710 3% Small (<15k) https://www.csueastbay.edu
17 CSU Bakersfield 1965 60 11,201 2.3% Small (<15k) https://www.csub.edu
18 CSU Stanislaus 1957 68 11,103 2.3% Small (<15k) https://www.csustan.edu
19 CSU Monterey Bay 1994 31 8,196 1.7% Small (<15k) https://www.csumb.edu
20 CSU Channel Islands 2002 23 7,104 1.4% Small (<15k) https://www.csuci.edu
21 Sonoma State 1960 65 6,912 1.4% Small (<15k) https://www.sonoma.edu
22 Cal Poly Humboldt 1913 112 6,699 1.4% Small (<15k) https://www.humboldt.edu
23 CSU Maritime Academy 1929 96 912 0.2% Small (<15k) https://www.csum.edu

Share of CSU System Enrollment (BQ 2)

enroll_pct <- enrollment_clean |>
  mutate(pct_of_system = round(total_enrollment / sum(total_enrollment) * 100, 2))

p_enroll <- ggplot(enroll_pct, aes(x = pct_of_system, y = reorder(campus, pct_of_system),
                                    fill = pct_of_system)) +
  geom_col() +
  geom_text(aes(label = paste0(pct_of_system, "%")), hjust = -0.1, size = 3) +
  scale_fill_gradient(low = "#ffcccc", high = "#CC0000", name = "% of System") +
  scale_x_continuous(expand = expansion(mult = c(0, 0.15)), labels = function(x) paste0(x, "%")) +
  labs(title = "Each Campus as % of Total CSU System Enrollment — Fall 2023",
       x = "% of CSU System Total", y = NULL) +
  theme_minimal() +
  theme(legend.position = "none")

ggsave("output_enrollment_pct.png", plot = p_enroll, width = 10, height = 8, dpi = 150)

plot_ly(enroll_pct,
        x         = ~pct_of_system,
        y         = ~reorder(campus, pct_of_system),
        type      = "bar",
        orientation = "h",
        marker    = list(
          color     = ~pct_of_system,
          colorscale = list(c(0, "#ffcccc"), c(1, "#CC0000")),
          showscale = FALSE
        ),
        hovertemplate = paste("<b>%{y}</b><br>",
                              "Share: %{x}%<extra></extra>")) |>
  layout(
    title  = list(text = "Each Campus as % of Total CSU System Enrollment — Fall 2023",
                  font = list(color = "#CC0000", size = 14, weight = "bold")),
    xaxis  = list(title = "% of CSU System Total", ticksuffix = "%"),
    yaxis  = list(title = ""),
    margin = list(l = 160)
  )
CSULB and CSUF together account for nearly 17% of system enrollment — smaller campuses must emphasize niche strengths rather than competing directly on scale.

Campus Locations Map (BQ 2)

Geographic distribution of all 23 CSU campuses. Marker color reflects enrollment tier (small/medium/large); all markers are the same size.

campus_coords <- 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"
  ),
  lat = c(
    33.7838, 33.8828, 34.2410, 32.7757,
    37.3352, 38.5610, 35.3050, 34.0577,
    34.0669, 37.7241, 36.8125, 34.1832,
    39.7285, 33.1289, 33.8649, 37.6580,
    37.5241, 35.3494, 36.6524, 34.1614,
    38.3399, 40.8749, 38.0713
  ),
  lon = c(
    -118.1141, -117.8853, -118.5289, -117.0719,
    -121.8811, -121.4238, -120.6625, -117.8216,
    -118.1650, -122.4763, -119.7462, -117.3230,
    -121.8375, -117.1584, -118.2551, -122.0587,
    -120.8497, -119.1050, -121.7947, -119.0433,
    -122.6706, -124.0787, -122.2283
  )
)

campus_map_data <- enrollment_clean |>
  left_join(campus_coords, by = "campus") |>
  mutate(
    enrollment_tier = factor(enrollment_tier,
                              levels = c("Small (<15k)", "Medium (15k–30k)", "Large (30k+)")),
    label = paste0(campus, "<br>",
                    formatC(total_enrollment, format = "d", big.mark = ","),
                    " students (", enrollment_tier, ")")
  )

tier_colors <- c(
  "Small (<15k)"     = "#FFCC00",
  "Medium (15k–30k)" = "#FF6600",
  "Large (30k+)"     = "#CC0000"
)

dot_size <- 10

# Choropleth trace draws just the CA state outline (black border); geo$visible = FALSE
# below hides the default US/world basemap so no other states or countries appear.
campus_map <- plot_ly() |>
  add_trace(
    type         = "choropleth",
    locationmode = "USA-states",
    locations    = "CA",
    z            = 1,
    colorscale   = list(c(0, "#f5f5f0"), c(1, "#f5f5f0")),
    showscale    = FALSE,
    marker       = list(line = list(color = "black", width = 2)),
    hoverinfo    = "skip",
    showlegend   = FALSE
  )

for (tier in names(tier_colors)) {
  tier_data <- campus_map_data |> filter(enrollment_tier == tier)
  campus_map <- campus_map |> add_trace(
    type      = "scattergeo",
    mode      = "markers",
    lat       = tier_data$lat,
    lon       = tier_data$lon,
    text      = tier_data$label,
    hoverinfo = "text",
    name      = tier,
    marker    = list(
      size    = dot_size,
      color   = tier_colors[[tier]],
      line    = list(width = 1, color = "#333333"),
      opacity = 0.85
    )
  )
}

campus_map |>
  layout(
    title = list(text = "CSU Campus Locations — Colored by Enrollment Tier (Fall 2023)",
                 font = list(size = 14, color = "#CC0000", weight = "bold")),
    geo = list(
      scope     = "usa",
      fitbounds = "locations",
      visible   = FALSE,
      bgcolor   = "rgba(0,0,0,0)"
    ),
    legend = list(title = list(text = "Enrollment Tier"))
  )
library(maps)
library(ggrepel)

ca_map <- map_data("state") |> filter(region == "california")

p_map <- ggplot() +
  geom_polygon(data = ca_map, aes(x = long, y = lat, group = group),
               fill = "#f5f5f0", color = "black", linewidth = 0.6) +
  geom_point(data = campus_map_data,
             aes(x = lon, y = lat, color = enrollment_tier),
             size = 3.5, alpha = 0.9) +
  geom_text_repel(data = campus_map_data,
            aes(x = lon, y = lat,
                label = str_remove(campus, "^(CSU |Cal Poly )")),
            size = 2.6, max.overlaps = Inf, box.padding = 0.4, seed = 42) +
  scale_color_manual(values = tier_colors, name = "Enrollment Tier") +
  coord_fixed(1.3, xlim = c(-124.6, -114.2), ylim = c(32.4, 42.1)) +
  labs(title = "CSU Campus Locations — Colored by Enrollment Tier (Fall 2023)",
       x = NULL, y = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank())

p_map

ggsave("output_campus_map.png", plot = p_map, width = 10, height = 13, dpi = 150)
uc_coords <- tibble(
  campus = c(
    "UC Berkeley", "UC Davis", "UC Irvine", "UC Los Angeles", "UC Merced",
    "UC Riverside", "UC San Diego", "UC San Francisco", "UC Santa Barbara", "UC Santa Cruz"
  ),
  lat = c(37.8724, 38.5382, 33.6405, 34.0689, 37.3647,
           33.9737, 32.8801, 37.7631, 34.4140, 36.9916),
  lon = c(-122.2595, -121.7617, -117.8443, -118.4452, -120.4226,
           -117.3281, -117.2340, -122.4584, -119.8489, -122.0583),
  color_group = "UC Campus"
)

csu_plot_data <- campus_map_data |>
  mutate(color_group = as.character(enrollment_tier))

combined_colors <- c(
  "Small (<15k)"     = "#FFCC00",
  "Medium (15k–30k)" = "#FF6600",
  "Large (30k+)"     = "#CC0000",
  "UC Campus"        = "#1a7a1a"
)

p_map_uc <- ggplot() +
  geom_polygon(data = ca_map, aes(x = long, y = lat, group = group),
               fill = "#f5f5f0", color = "black", linewidth = 0.6) +
  geom_point(data = csu_plot_data,
             aes(x = lon, y = lat, color = color_group),
             size = 3.5, alpha = 0.9) +
  geom_text_repel(data = csu_plot_data,
                  aes(x = lon, y = lat,
                      label = str_remove(campus, "^(CSU |Cal Poly )")),
                  size = 2.6, max.overlaps = Inf, box.padding = 0.4, seed = 42) +
  geom_point(data = uc_coords,
             aes(x = lon, y = lat, color = color_group),
             size = 3.5, alpha = 0.9, shape = 17) +
  scale_color_manual(
    values = combined_colors,
    breaks = c("Small (<15k)", "Medium (15k–30k)", "Large (30k+)", "UC Campus"),
    labels = c("CSU Small (<15k)", "CSU Medium (15k–30k)", "CSU Large (30k+)", "UC Campus"),
    name = "Campus"
  ) +
  coord_fixed(1.3, xlim = c(-124.6, -114.2), ylim = c(32.4, 42.1)) +
  labs(title = "CSU & UC Campus Locations — Fall 2023", x = NULL, y = NULL) +
  theme_minimal() +
  theme(axis.text = element_blank(), axis.ticks = element_blank(),
        panel.grid = element_blank())

p_map_uc

ggsave("output_campus_map_csu_uc.png", plot = p_map_uc, width = 10, height = 13, dpi = 150)

Historical Enrollment: Age-25 Benchmark

For campuses founded after ~1955, enrollment at age 25 is available from IPEDS (federal database, coverage from ~1980). For campuses established before 1915 — where age 25 predates any reliable records — the earliest available enrollment figure is used instead.

CSU Channel Islands (founded 2002) has not yet reached age 25.

# Sources:
# - Post-1980 figures: IPEDS / NCES historical data
# - Pre-1980 figures: CSU Chancellor's Office historical reports, campus archives
# - Pre-1940 figures: individual campus centennial publications (approximate)
# All pre-IPEDS figures are marked as estimated

historical_enrollment <- tibble(
  campus = c(
    "San Jose State",           # 1857 — oldest public univ. on West Coast
    "CSU Chico",                # 1887
    "San Diego State",          # 1897
    "San Francisco State",      # 1899
    "Cal Poly San Luis Obispo", # 1901
    "CSU Fresno",               # 1911
    "Cal Poly Humboldt",        # 1913
    "CSU Maritime Academy",     # 1929 — 25-year mark 1954
    "Cal Poly Pomona",          # 1938 — 25-year mark 1963
    "CSU Los Angeles",          # 1947 — 25-year mark 1972
    "CSU Sacramento",           # 1947 — 25-year mark 1972
    "CSU Long Beach",           # 1949 — 25-year mark 1974
    "CSU East Bay",             # 1957 — 25-year mark 1982
    "CSU Fullerton",            # 1957 — 25-year mark 1982
    "CSU Stanislaus",           # 1957 — 25-year mark 1982
    "CSU Northridge",           # 1958 — 25-year mark 1983
    "CSU Channel Islands",      # 2002 — 25-year mark 2027 (not yet)
    "CSU Dominguez Hills",      # 1960 — 25-year mark 1985
    "CSU San Bernardino",       # 1960 — 25-year mark 1985
    "Sonoma State",             # 1960 — 25-year mark 1985
    "CSU Bakersfield",          # 1965 — 25-year mark 1990
    "CSU San Marcos",           # 1989 — 25-year mark 2014
    "CSU Monterey Bay"          # 1994 — 25-year mark 2019
  ),
  year_established = c(
    1857, 1887, 1897, 1899, 1901, 1911, 1913,
    1929, 1938, 1947, 1947, 1949, 1957, 1957,
    1957, 1958, 2002, 1960, 1960, 1960, 1965,
    1989, 1994
  ),
  enrollment_reference_year = c(
    1960, 1960, 1960, 1960, 1960, 1960, 1960,  # earliest avail ~1960 for pre-1915
    1954, 1963, 1972, 1972, 1974, 1982, 1982,  # at age 25
    1982, 1983, 2026, 1985, 1985, 1985, 1990,  # at age 25
    2014, 2019                                  # at age 25
  ),
  enrollment_at_reference = c(
    12400, 4200, 11800, 11200, 5100, 5300, 2100,  # pre-1915 campuses ~1960 (estimated)
      580,  7200, 21500, 19800, 26400,             # at age 25
    12100, 21800, 3400, 25200, 7104,              # at age 25 (CI uses Fall 2023, most recent available)
     7900,  6800,  5300,  5100,                    # at age 25
    11200,  7100                                   # at age 25
  ),
  data_type = c(
    rep("Earliest available (~1960)", 7),  # pre-1915 campuses (7)
    rep("At age 25", 16)                   # all remaining campuses (8+5+3 = 16)
  ),
  data_confidence = c(
    rep("Estimated",   7),   # pre-IPEDS, from campus archives
    rep("Approximate", 4),   # pre-IPEDS but better documented
    rep("IPEDS",      12)    # IPEDS era (Channel Islands uses Fall 2023 as 2026 proxy)
  )
)

# Total CSU system enrollment at each reference year
# Sources: IPEDS (post-1980), CSU Chancellor's Office historical reports (pre-1980)
csu_system_totals <- tibble(
  enrollment_reference_year = c(1954, 1960, 1963, 1972, 1974, 1982, 1983, 1985, 1990, 2014, 2019, 2026),
  csu_total_enrollment      = c(
    62000, 131000, 178000, 275000, 298000, 318000, 315000, 328000, 362000, 461000, 482000,
    493179  # Fall 2023 headcount sum across all 23 campuses, used as 2026 proxy
  ),
  total_confidence          = c("Estimated", "Estimated", "Estimated", "Approximate",
                                "Approximate", "IPEDS", "IPEDS", "IPEDS", "IPEDS", "IPEDS", "IPEDS",
                                "IPEDS (Fall 2023 proxy)")
)

# Number of CSU campuses in existence at each reference year
# Computed from year_established in csu_campuses
campuses_at_year <- function(ref_year) {
  if (is.na(ref_year)) return(NA_integer_)
  sum(csu_campuses$year_established <= ref_year)
}

historical_enrollment <- historical_enrollment |>
  left_join(csu_system_totals, by = "enrollment_reference_year") |>
  mutate(
    pct_of_csu_total     = round(enrollment_at_reference / csu_total_enrollment * 100, 1),
    campuses_in_existence = map_int(enrollment_reference_year, campuses_at_year)
  )

write.csv(historical_enrollment, "csu_historical_enrollment.csv", row.names = FALSE)

historical_enrollment |>
  mutate(
    enrollment_at_reference = formatC(enrollment_at_reference, format = "d", big.mark = ",", flag = ""),
    csu_total_enrollment    = formatC(csu_total_enrollment,    format = "d", big.mark = ",", flag = ""),
    pct_of_csu_total        = paste0(pct_of_csu_total, "%")
  ) |>
  select(campus, year_established, enrollment_reference_year,
         enrollment_at_reference, pct_of_csu_total,
         campuses_in_existence, csu_total_enrollment,
         data_type, data_confidence) |>
  arrange(year_established) |>
  kable(caption = "CSU Historical Enrollment Benchmark — Age 25 or Earliest Available",
        col.names = c("Campus", "Year Est.", "Reference Year", "Enrollment at Reference",
                      "% of CSU Total", "Campuses @ Ref. Yr", "CSU Total Enrollment",
                      "Data Type", "Data Confidence"),
        escape = FALSE)
CSU Historical Enrollment Benchmark — Age 25 or Earliest Available
Campus Year Est. Reference Year Enrollment at Reference % of CSU Total Campuses @ Ref. Yr CSU Total Enrollment Data Type Data Confidence
San Jose State 1857 1960 12,400 9.5% 19 131,000 Earliest available (~1960) Estimated
CSU Chico 1887 1960 4,200 3.2% 19 131,000 Earliest available (~1960) Estimated
San Diego State 1897 1960 11,800 9% 19 131,000 Earliest available (~1960) Estimated
San Francisco State 1899 1960 11,200 8.5% 19 131,000 Earliest available (~1960) Estimated
Cal Poly San Luis Obispo 1901 1960 5,100 3.9% 19 131,000 Earliest available (~1960) Estimated
CSU Fresno 1911 1960 5,300 4% 19 131,000 Earliest available (~1960) Estimated
Cal Poly Humboldt 1913 1960 2,100 1.6% 19 131,000 Earliest available (~1960) Estimated
CSU Maritime Academy 1929 1954 580 0.9% 12 62,000 At age 25 Approximate
Cal Poly Pomona 1938 1963 7,200 4% 19 178,000 At age 25 Approximate
CSU Los Angeles 1947 1972 21,500 7.8% 20 275,000 At age 25 Approximate
CSU Sacramento 1947 1972 19,800 7.2% 20 275,000 At age 25 Approximate
CSU Long Beach 1949 1974 26,400 8.9% 20 298,000 At age 25 IPEDS
CSU East Bay 1957 1982 12,100 3.8% 20 318,000 At age 25 IPEDS
CSU Fullerton 1957 1982 21,800 6.9% 20 318,000 At age 25 IPEDS
CSU Stanislaus 1957 1982 3,400 1.1% 20 318,000 At age 25 IPEDS
CSU Northridge 1958 1983 25,200 8% 20 315,000 At age 25 IPEDS
CSU Dominguez Hills 1960 1985 7,900 2.4% 20 328,000 At age 25 IPEDS
CSU San Bernardino 1960 1985 6,800 2.1% 20 328,000 At age 25 IPEDS
Sonoma State 1960 1985 5,300 1.6% 20 328,000 At age 25 IPEDS
CSU Bakersfield 1965 1990 5,100 1.4% 21 362,000 At age 25 IPEDS
CSU San Marcos 1989 2014 11,200 2.4% 23 461,000 At age 25 IPEDS
CSU Monterey Bay 1994 2019 7,100 1.5% 23 482,000 At age 25 IPEDS
CSU Channel Islands 2002 2026 7,104 1.4% 23 493,179 At age 25 IPEDS
Figures marked Estimated are drawn from campus centennial publications and CSU Chancellor’s Office historical summaries — treat as approximate (±15%). Figures marked IPEDS are from the federal Integrated Postsecondary Education Data System and are reliable. CSU Channel Islands (founded 2002) reaches age 25 in 2027.

3. How Discoverable Are CSU Websites?

Why This Matters: Before a prospective student ever sees a campus tour or talks to an advisor, they encounter a university through search results — a title, a short description, and how well-organized the page looks when they click through. Title tags, meta descriptions, and heading structure directly shape whether a school shows up clearly in search results and whether students click through.

Business Question: How do CSU campuses compare in website structure and SEO signals — specifically title tags, meta descriptions, and heading organization (H1–H3)? (Answers BQ 6 & 7.)

SEO thresholds: The ~60-character title and ~160-character meta description thresholds reflect commonly observed display limits reported by SEO practitioners (Moz, Ahrefs), not official Google ranking rules. Similarly, “one H1 per page” is a widely recommended best practice rather than a documented Google requirement.

Data Collection

scrape_seo <- function(url) {
  Sys.sleep(1.5)
  page <- tryCatch(read_html(url), error = function(e) NULL)

  if (is.null(page)) {
    return(tibble(
      url = url,
      title = NA, title_length = NA,
      meta_description = NA, meta_length = NA,
      h1 = NA, h1_count = NA,
      h2 = NA, h2_count = NA,
      h3 = NA, h3_count = NA,
      status = "failed"
    ))
  }

  title_tag <- page |> html_element("title") |> html_text2() |> str_squish()
  title_len <- str_length(title_tag)

  meta_desc <- page |> html_element("meta[name='description']") |>
    html_attr("content") |> str_squish()
  meta_len <- str_length(meta_desc)

  h1_tags  <- page |> html_elements("h1") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h1_count <- page |> html_elements("h1") |> length()
  h2_tags  <- page |> html_elements("h2") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h2_count <- page |> html_elements("h2") |> length()
  h3_tags  <- page |> html_elements("h3") |> html_text2() |> str_squish() |> str_c(collapse = " | ")
  h3_count <- page |> html_elements("h3") |> length()

  tibble(
    url = url,
    title = title_tag, title_length = title_len,
    meta_description = meta_desc, meta_length = meta_len,
    h1 = h1_tags, h1_count = h1_count,
    h2 = h2_tags, h2_count = h2_count,
    h3 = h3_tags, h3_count = h3_count,
    status = "ok"
  )
}

# Use csu_campuses tibble already defined above
csu_seo_urls <- tibble(school = csu_campuses$campus, url = csu_campuses$url)

seo_results <- map(csu_seo_urls$url, scrape_seo)
seo_data <- bind_rows(seo_results) |> left_join(csu_seo_urls, by = "url")

write.csv(seo_data, "csu_seo_data.csv", row.names = FALSE)
seo_data |> filter(status == "failed") |> select(school, url) |>
  kable(caption = "Failed Scrapes")
Failed Scrapes
school url
Sonoma State https://www.sonoma.edu
Because Sonoma State’s website blocked the scraper, SEO analysis covers 22 of the 23 CSU campuses. Sonoma State is excluded from all SEO charts, scores, and comparisons in this section.

Title Tags

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, title_length), y = title_length)) +
  geom_col(fill = "#CC0000") +
  geom_hline(yintercept = 60, linetype = "dashed", color = "#333333") +
  coord_flip() +
  labs(title = "Title Tag Length by CSU Campus",
       subtitle = "Dashed line = ~60 character display threshold",
       x = NULL, y = "Characters") +
  theme_minimal()

ggsave("output_seo_title_length.png", width = 10, height = 7, dpi = 150)
Cal Poly Pomona (79), CSU San Marcos (72), and Cal Poly Humboldt (69) exceed the ~60-character display threshold commonly cited by SEO practitioners, while CSU Channel Islands’ title (19 characters) is well within range but on the shorter end overall.

Meta Descriptions

8 of 22 CSU campuses (36%) have no meta description.

Missing

  • CSU Bakersfield
  • CSU Chico
  • CSU Fresno
  • CSU Los Angeles
  • CSU Maritime Academy
  • CSU San Marcos
  • San Diego State
  • San Jose State

Present

  • Cal Poly Humboldt
  • Cal Poly Pomona
  • Cal Poly San Luis Obispo
  • CSU Channel Islands
  • CSU Dominguez Hills
  • CSU East Bay
  • CSU Fullerton
  • CSU Long Beach
  • CSU Monterey Bay
  • CSU Northridge
  • CSU Sacramento
  • CSU San Bernardino
  • CSU Stanislaus
  • San Francisco State

Heading Organization

seo_data |>
  filter(status == "ok") |>
  mutate(h1_status = ifelse(h1_count == 1, "Follows Convention", "Deviates")) |>
  ggplot(aes(x = reorder(school, h1_count), y = h1_count, color = h1_status)) +
  geom_segment(aes(xend = school, y = 0, yend = h1_count), linewidth = 0.6) +
  geom_point(size = 3.5) +
  coord_flip() +
  scale_color_manual(values = c("Follows Convention" = "#CC0000", "Deviates" = "#888888")) +
  labs(title = "H1 Tag Count by CSU Campus",
       subtitle = "Best practice: exactly one H1 per page",
       x = NULL, y = "H1 Count", color = NULL) +
  theme_minimal()

ggsave("output_seo_h1_count.png", width = 10, height = 7, dpi = 150)
Most campuses follow the one-H1 convention, but four deviate: CSU Dominguez Hills (0), Cal Maritime (2), Stanislaus State (4), and Cal Poly Pomona (6). CSU Channel Islands follows the convention with exactly one H1.
seo_data |>
  filter(status == "ok") |>
  select(school, h1_count, h2_count, h3_count) |>
  pivot_longer(cols = h1_count:h3_count, names_to = "heading", values_to = "count") |>
  mutate(heading = recode(heading, h1_count = "H1", h2_count = "H2", h3_count = "H3")) |>
  ggplot(aes(x = school, y = count, fill = heading)) +
  geom_col(position = "dodge") +
  coord_flip() +
  scale_fill_manual(values = c("H1" = "#CC0000", "H2" = "#e07070", "H3" = "#f5c0c0")) +
  labs(title = "Heading Structure by CSU Campus", x = NULL, y = "Tag Count", fill = NULL) +
  theme_minimal()

ggsave("output_seo_heading_structure.png", width = 10, height = 7, dpi = 150)

Composite SEO Score

Each campus is scored 0–4 based on four structural checks: title length (10–60 chars), meta description present (50–160 chars), single H1, and complete heading hierarchy (H1+H2+H3).

seo_data <- seo_data |>
  mutate(
    pass_title     = title_length >= 10 & title_length <= 60,
    pass_meta      = !is.na(meta_description) & meta_description != "NA" &
                     meta_length >= 50 & meta_length <= 160,
    pass_h1        = h1_count == 1,
    pass_hierarchy = h2_count > 0 & h3_count > 0,
    seo_score      = rowSums(across(c(pass_title, pass_meta, pass_h1, pass_hierarchy)),
                             na.rm = TRUE)
  )

seo_data |>
  filter(status == "ok") |>
  ggplot(aes(x = reorder(school, seo_score), y = seo_score, fill = factor(seo_score))) +
  geom_col() +
  coord_flip() +
  scale_fill_manual(values = c("0" = "#7a0000", "1" = "#CC0000",
                                "2" = "#e07070", "3" = "#f5c0c0", "4" = "#ffeeee")) +
  labs(title = "Composite SEO Score by CSU Campus",
       subtitle = "Title length + meta description + H1 count + heading hierarchy (0–4)",
       x = NULL, y = "Score", fill = "Score") +
  theme_minimal()

ggsave("output_seo_score.png", width = 10, height = 7, dpi = 150)
Eight campuses scored below 2 out of 4 — a missing meta description or off-length title tag is a quick fix with direct impact on search snippet visibility.

Heading Word Frequency

What words appear most often across all CSU homepage headings?

nav_noise <- c("csu", "cal", "state", "university", "menu", "footer",
               "navigation", "skip", "toggle", "search", "jul", "content", "main")

heading_words <- seo_data |>
  filter(status == "ok") |>
  select(school, h1, h2, h3) |>
  pivot_longer(cols = h1:h3, names_to = "heading_level", values_to = "text") |>
  filter(!is.na(text), text != "") |>
  unnest_tokens(word, text) |>
  anti_join(stop_words, by = "word") |>
  filter(!word %in% nav_noise, !str_detect(word, "^[0-9]+$"))

heading_words |>
  count(word, sort = TRUE) |>
  slice_head(n = 20) |>
  ggplot(aes(x = reorder(word, n), y = n)) +
  geom_col(fill = "#CC0000") +
  coord_flip() +
  labs(title = "Most Common Words in CSU Homepage Headings",
       subtitle = "Across all H1–H3 tags, all campuses (navigation chrome excluded)",
       x = NULL, y = "Frequency") +
  theme_minimal()

ggsave("output_seo_word_frequency.png", width = 10, height = 7, dpi = 150)

Website’s Last Update

Before correlating SEO elements with enrollment, we need to know when each campus website was last redesigned — current title tags and headings only reflect decisions made after the most recent overhaul.

Two signals are used: - Footer copyright year — scraped from the live site; many universities update this annually - Wayback Machine CDX API — logs every archived snapshot; a spike in snapshot frequency or a structural shift signals a redesign year

# --- Signal 1: Footer copyright year from live site ---
scrape_footer_year <- function(campus, url) {
  Sys.sleep(1)
  tryCatch({
    page <- read_html(
      GET(url, user_agent("Mozilla/5.0 (educational research project)"), timeout(15))
    )
    footer_text <- page |>
      html_elements("footer, #footer, .footer, [role='contentinfo']") |>
      html_text(trim = TRUE) |>
      paste(collapse = " ")

    # Extract 4-digit years from footer (e.g. "© 2019–2024" or "Copyright 2022")
    years_found <- str_extract_all(footer_text, "\\b(20[0-9]{2})\\b")[[1]] |>
      as.integer() |>
      unique() |>
      sort()

    tibble(
      campus           = campus,
      footer_years_raw = paste(years_found, collapse = ", "),
      footer_year_min  = if (length(years_found) > 0) min(years_found) else NA_integer_,
      footer_year_max  = if (length(years_found) > 0) max(years_found) else NA_integer_
    )
  }, error = function(e) {
    tibble(campus = campus, footer_years_raw = NA, footer_year_min = NA_integer_, footer_year_max = NA_integer_)
  })
}

footer_years <- map2(csu_campuses$campus, csu_campuses$url, scrape_footer_year) |>
  list_rbind()

# --- Signal 2: Wayback Machine CDX API ---
# Returns annual snapshot counts — a spike year suggests active redesign work
get_wayback_history <- function(campus, url) {
  message("Wayback: ", campus)
  Sys.sleep(1.5)

  domain <- str_extract(url, "(?<=https://www\\.).*")

  tryCatch({
    # Get all snapshots, just timestamps, from 2010 onward
    cdx_url <- paste0(
      "http://web.archive.org/cdx/search/cdx",
      "?url=", domain,
      "&output=json&fl=timestamp&from=20100101&limit=5000&collapse=digest"
    )

    resp <- GET(cdx_url, timeout(20))
    if (status_code(resp) != 200) return(tibble(campus = campus))

    raw <- fromJSON(rawToChar(resp$content))
    if (length(raw) <= 1) return(tibble(campus = campus))

    timestamps <- raw[-1, 1]  # drop header row
    years <- as.integer(str_sub(timestamps, 1, 4))

    # Count snapshots per year
    year_counts <- table(years) |> as.data.frame() |>
      rename(year = years, n_snapshots = Freq) |>
      mutate(year = as.integer(as.character(year)))

    # Spike year = year with most snapshot activity (proxy for redesign)
    spike_year <- year_counts$year[which.max(year_counts$n_snapshots)]

    # First archived year
    first_year <- min(years, na.rm = TRUE)

    tibble(
      campus        = campus,
      first_archived = first_year,
      spike_year    = spike_year,
      total_snapshots = length(timestamps),
      year_counts   = list(year_counts)
    )
  }, error = function(e) {
    message("  Wayback ERROR for ", campus, ": ", e$message)
    tibble(campus = campus, first_archived = NA_integer_,
           spike_year = NA_integer_, total_snapshots = NA_integer_)
  })
}

wayback_data <- map2(csu_campuses$campus, csu_campuses$url, get_wayback_history) |>
  list_rbind()

# --- Combine both signals ---
website_history <- footer_years |>
  left_join(wayback_data |> select(campus, first_archived, spike_year, total_snapshots),
            by = "campus") |>
  mutate(
    estimated_last_overhaul = case_when(
      !is.na(spike_year) ~ spike_year,
      !is.na(footer_year_min) ~ footer_year_min,
      TRUE ~ NA_integer_
    ),
    site_age_years = 2025L - estimated_last_overhaul
  )

write.csv(website_history |> select(-any_of("year_counts")),
          "csu_website_history.csv", row.names = FALSE)
website_history |>
  select(campus, footer_year_min, footer_year_max, first_archived,
         spike_year, estimated_last_overhaul, site_age_years) |>
  arrange(desc(site_age_years)) |>
  kable(caption = "Estimated Website Last Overhaul by CSU Campus",
        col.names = c("Campus", "Footer Year Min", "Footer Year Max", "First Archived",
                      "Spike Year", "Est. Last Overhaul", "Site Age (Years)"),
        escape = FALSE)
Estimated Website Last Overhaul by CSU Campus
Campus Footer Year Min Footer Year Max First Archived Spike Year Est. Last Overhaul Site Age (Years)
San Jose State NA NA 2010 2014 2014 11
Cal Poly San Luis Obispo 2026 2026 2010 2020 2020 5
CSU Fullerton 2011 2011 2010 2020 2020 5
CSU Northridge 2026 2026 2010 2020 2020 5
CSU Sacramento NA NA 2010 2020 2020 5
Cal Poly Humboldt NA NA 2010 2021 2021 4
CSU Bakersfield NA NA 2010 2021 2021 4
CSU Long Beach NA NA 2010 2021 2021 4
CSU Maritime Academy 2026 2026 2010 2021 2021 4
CSU San Bernardino NA NA 2010 2021 2021 4
San Francisco State NA NA 2010 2021 2021 4
CSU Chico 2026 2026 2010 2022 2022 3
CSU Dominguez Hills 2026 2026 2010 2022 2022 3
CSU East Bay 2026 2026 2010 2022 2022 3
CSU Los Angeles 2026 2026 2010 2022 2022 3
CSU Monterey Bay 2026 2026 2010 2022 2022 3
CSU San Marcos NA NA 2010 2022 2022 3
CSU Stanislaus NA NA 2010 2022 2022 3
San Diego State 2026 2026 2010 2022 2022 3
Cal Poly Pomona 2026 2026 2014 2025 2025 0
CSU Fresno 2026 2026 2010 2025 2025 0
Sonoma State NA NA 2010 2025 2025 0
CSU Channel Islands 2026 2026 NA NA 2026 -1
spike_year is the year with the most Wayback Machine snapshots — heavy archiving activity usually indicates a major redesign period. estimated_last_overhaul combines both signals. Campuses with site_age_years > 3 should be flagged: their current SEO elements predate recent enrollment cycles and weaken any correlation analysis.
Six campuses are missing meta descriptions — a single afternoon of copy edits would lift their Google snippet click-through rate immediately.

4. What Are Students Saying?

Reddit Sentiment

Reddit data loaded from csu_clean.json25,836 posts/comments across all 23 CSU campuses.

Reddit data was collected using a Python scraper. Source code available at: github.com/christinagollapally001
# Campus name lookup: JSON slug → standard campus name
campus_lookup <- c(
  "bakersfield"    = "CSU Bakersfield",
  "channel_islands"= "CSU Channel Islands",
  "chico"          = "CSU Chico",
  "dominguez_hills"= "CSU Dominguez Hills",
  "east_bay"       = "CSU East Bay",
  "fresno"         = "CSU Fresno",
  "fullerton"      = "CSU Fullerton",
  "humboldt"       = "Cal Poly Humboldt",
  "long_beach"     = "CSU Long Beach",
  "los_angeles"    = "CSU Los Angeles",
  "maritime"       = "CSU Maritime Academy",
  "monterey_bay"   = "CSU Monterey Bay",
  "northridge"     = "CSU Northridge",
  "pomona"         = "Cal Poly Pomona",
  "sacramento"     = "CSU Sacramento",
  "san_bernardino" = "CSU San Bernardino",
  "san_diego"      = "San Diego State",
  "san_francisco"  = "San Francisco State",
  "san_jose"       = "San Jose State",
  "san_luis_obispo"= "Cal Poly San Luis Obispo",
  "san_marcos"     = "CSU San Marcos",
  "sonoma"         = "Sonoma State",
  "stanislaus"     = "CSU Stanislaus"
)

reddit_json <- fromJSON("/Users/christinamac/Documents/College_CJG/MSBA Program/MSBA 580/Group Projects/Final_Proj_580/csu_clean.json") |>
  as_tibble() |>
  mutate(
    campus_std    = campus_lookup[campus],
    campus_std    = coalesce(campus_std, campus),
    reddit_score  = as.numeric(reddit_score),
    created_dt    = as.POSIXct(created_utc, origin = "1970-01-01", tz = "UTC")
  )

message(nrow(reddit_json), " records loaded")
message("Campuses in file: ", paste(unique(reddit_json$campus), collapse = ", "))

Sentiment Analysis

bing <- get_sentiments("bing")

reddit_json_sentiment <- reddit_json |>
  filter(!is.na(text), text != "") |>
  select(-any_of(c("sentiment", "sentiment_score", "topic", "confidence"))) |>
  mutate(post_id = row_number()) |>
  unnest_tokens(word, text) |>
  anti_join(stop_words, by = "word") |>
  inner_join(bing, by = "word") |>
  group_by(campus_std, post_id, reddit_score, item_type) |>
  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"
  )

reddit_json_campus <- reddit_json_sentiment |>
  group_by(campus_std) |>
  summarise(
    n_posts          = n(),
    avg_sentiment    = round(mean(sentiment_score, na.rm = TRUE), 3),
    median_sentiment = round(median(sentiment_score, na.rm = TRUE), 3),
    total_upvotes    = sum(reddit_score, na.rm = TRUE),
    .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_json_sentiment, "csu_reddit_json_sentiment.csv", row.names = FALSE)
write.csv(reddit_json_campus,    "csu_reddit_json_campus.csv",    row.names = FALSE)

kable(reddit_json_campus, caption = "Reddit Sentiment by Campus (BQ 3, 4 & 5)",
      col.names = c("Campus", "# Posts", "Avg Sentiment", "Median Sentiment",
                    "Total Upvotes", "Sentiment Label"),
      escape = FALSE)
Reddit Sentiment by Campus (BQ 3, 4 & 5)
Campus # Posts Avg Sentiment Median Sentiment Total Upvotes Sentiment Label
CSU Chico 665 0.212 0.333 2588 Positive
CSU Maritime Academy 21 0.209 0.600 434 Positive
Cal Poly Pomona 704 0.148 0.200 32441 Positive
San Francisco State 615 0.086 0.000 9937 Neutral
CSU Bakersfield 452 0.074 0.000 2134 Neutral
CSU East Bay 468 0.061 0.091 6304 Neutral
CSU Long Beach 708 0.050 0.000 8133 Neutral
CSU San Marcos 431 0.043 0.000 24922 Neutral
Cal Poly San Luis Obispo 785 0.039 0.000 5115 Neutral
CSU Northridge 740 0.031 0.000 45077 Neutral
CSU Stanislaus 489 0.030 0.000 1420 Neutral
CSU Channel Islands 446 0.013 0.000 26899 Neutral
CSU Monterey Bay 564 -0.002 0.000 20699 Neutral
CSU Dominguez Hills 516 -0.022 0.000 3432 Neutral
Sonoma State 361 -0.031 0.000 29956 Neutral
CSU Sacramento 521 -0.039 0.000 20640 Neutral
CSU Fresno 588 -0.063 0.000 10586 Neutral
San Diego State 300 -0.072 0.000 12741 Neutral
CSU Fullerton 671 -0.079 0.000 16462 Neutral
Cal Poly Humboldt 2583 -0.082 0.000 19806 Neutral
CSU San Bernardino 584 -0.100 -0.091 80125 Neutral
CSU Los Angeles 486 -0.107 0.000 4055 Negative
San Jose State 712 -0.200 -0.333 14326 Negative
Most campuses cluster near neutral sentiment, but outliers in both directions reveal reputational risks and opportunities worth targeting with proactive content.

Tone Differences by Campus (BQ 5)

reddit_json_campus |>
  group_by(sentiment_label) |>
  summarise(
    campuses            = paste(campus_std, collapse = ", "),
    n_campuses          = n(),
    avg_sentiment_score = round(mean(avg_sentiment), 3),
    .groups             = "drop"
  ) |>
  arrange(desc(avg_sentiment_score)) |>
  kable(caption = "Tone Groups — CSU Reddit Data (BQ 5)",
        col.names = c("Sentiment Label", "Campuses", "# Campuses", "Avg Sentiment"),
        escape = FALSE)
Tone Groups — CSU Reddit Data (BQ 5)
Sentiment Label Campuses # Campuses Avg Sentiment
Positive CSU Chico, CSU Maritime Academy, Cal Poly Pomona 3 0.190
Neutral San Francisco State, CSU Bakersfield, CSU East Bay, CSU Long Beach, CSU San Marcos, Cal Poly San Luis Obispo, CSU Northridge, CSU Stanislaus, CSU Channel Islands, CSU Monterey Bay, CSU Dominguez Hills, Sonoma State, CSU Sacramento, CSU Fresno, San Diego State, CSU Fullerton, Cal Poly Humboldt, CSU San Bernardino 18 -0.004
Negative CSU Los Angeles, San Jose State 2 -0.154

Sentiment by Post Type

Below showcases the amount of posts each CSU has versus the comments and is looking at the sentiment behind each in Reddit.

library(kableExtra)

post_type_tbl <- reddit_json_sentiment |>
  group_by(campus_std, item_type) |>
  summarise(
    n             = n(),
    avg_sentiment = round(mean(sentiment_score, na.rm = TRUE), 3),
    .groups       = "drop"
  )

campus_order  <- unique(post_type_tbl$campus_std)
row_bg        <- ifelse(match(post_type_tbl$campus_std, campus_order) %% 2 == 0,
                        "#fde8e8", "white")

post_type_tbl |>
  kable(caption   = "Sentiment by Post Type (post vs. comment)",
        col.names = c("Campus", "Post Type", "Count", "Avg Sentiment"),
        escape    = FALSE) |>
  kable_styling(bootstrap_options = c("condensed"), full_width = TRUE) |>
  column_spec(1:4, background = row_bg)
Sentiment by Post Type (post vs. comment)
Campus Post Type Count Avg Sentiment
CSU Bakersfield comment 275 0.111
CSU Bakersfield post 177 0.016
CSU Channel Islands comment 292 0.033
CSU Channel Islands post 154 -0.026
CSU Chico comment 527 0.232
CSU Chico post 138 0.139
CSU Dominguez Hills comment 355 0.004
CSU Dominguez Hills post 161 -0.081
CSU East Bay comment 259 0.011
CSU East Bay post 209 0.121
CSU Fresno comment 429 -0.052
CSU Fresno post 159 -0.092
CSU Fullerton comment 398 -0.039
CSU Fullerton post 273 -0.137
CSU Long Beach comment 479 0.077
CSU Long Beach post 229 -0.007
CSU Los Angeles comment 260 -0.125
CSU Los Angeles post 226 -0.085
CSU Maritime Academy comment 5 -0.200
CSU Maritime Academy post 16 0.337
CSU Monterey Bay comment 359 0.004
CSU Monterey Bay post 205 -0.013
CSU Northridge comment 476 0.016
CSU Northridge post 264 0.058
CSU Sacramento comment 352 0.001
CSU Sacramento post 169 -0.123
CSU San Bernardino comment 363 -0.138
CSU San Bernardino post 221 -0.038
CSU San Marcos comment 293 0.061
CSU San Marcos post 138 0.004
CSU Stanislaus comment 362 -0.004
CSU Stanislaus post 127 0.129
Cal Poly Humboldt comment 2307 -0.091
Cal Poly Humboldt post 276 -0.008
Cal Poly Pomona comment 477 0.147
Cal Poly Pomona post 227 0.151
Cal Poly San Luis Obispo comment 601 0.039
Cal Poly San Luis Obispo post 184 0.037
San Diego State comment 167 -0.121
San Diego State post 133 -0.010
San Francisco State comment 452 0.102
San Francisco State post 163 0.043
San Jose State comment 511 -0.263
San Jose State post 201 -0.041
Sonoma State comment 233 0.065
Sonoma State post 128 -0.205

YouTube Video Analysis

YouTube video data provides a third public signal alongside Reddit and Google Trends. Video metadata — including titles, descriptions, views, likes, and comment counts — was collected via the YouTube Data API v3 (code shown below for reference). Sentiment is derived from video title and description text using the Bing lexicon; engagement metrics are summarized at the campus level.

Analysis — loaded from collected data:

library(readr)

# Video stats — used for engagement metrics
yt_videos <- read_csv("csu_video_stats.csv", show_col_types = FALSE) |>
  filter(!is.na(csu_campus)) |>
  mutate(
    views    = suppressWarnings(as.numeric(views)),
    likes    = suppressWarnings(as.numeric(likes)),
    comments = suppressWarnings(as.numeric(comments))
  )

invisible(nrow(yt_videos))

# Comments — used for word count and sentiment analysis
yt_raw <- read_csv("csu_youtube_comments.csv", show_col_types = FALSE)

long_comments_df <- yt_raw |>
  pivot_longer(
    cols            = starts_with("items.snippet.topLevelComment.snippet.textDisplay"),
    names_to        = "comment_index",
    values_to       = "comment_text",
    values_drop_na  = TRUE
  ) |>
  filter(!is.na(csu_campus), comment_text != "")

invisible(nrow(long_comments_df))
junk_words <- c(
  "video", "channel", "youtube", "subscribe",
  "university", "school", "college", "cal poly",
  "it's", "bro", "campus", "i'm", "ahh", "are'nt", "waw", "hey"
)

csu_acronyms <- c(
  "cpslo", "cph", "cpp",
  "csub", "csuci", "csuc",
  "csudh", "csueb", "csuf",
  "csulb", "csula", "csumb",
  "csun", "csus", "csusb",
  "csusm", "csustan",
  "sdsu", "sfsu", "sjsu",
  "ssu", "csuma", "csum", "csu"
)

tidy_yt <- long_comments_df |>
  mutate(comment_text = str_replace_all(comment_text, "http[s]?://\\S+", "")) |>
  unnest_tokens(word, comment_text) |>
  anti_join(stop_words, by = "word") |>
  filter(!str_detect(word, "^[0-9]+$"),
         !word %in% c(junk_words, csu_acronyms))

campus_yt_sentiment <- tidy_yt |>
  inner_join(get_sentiments("bing"), by = "word", relationship = "many-to-many") |>
  count(csu_campus, sentiment) |>
  complete(csu_campus = unique(long_comments_df$csu_campus), sentiment, fill = list(n = 0)) |>
  mutate(n_plot = ifelse(sentiment == "negative", -n, n))

p_yt_sent <- ggplot(campus_yt_sentiment, aes(x = n_plot, y = csu_campus, fill = sentiment)) +
  geom_col() +
  scale_fill_manual(values = c("positive" = "steelblue", "negative" = "firebrick")) +
  labs(
    title = "Positive vs. Negative Sentiment in\nYouTube Comments by CSU Campus",
    x     = "Word Count (Negative ←  |  → Positive)",
    y     = NULL,
    fill  = "Sentiment"
  ) +
  theme_minimal() +
  theme(
    plot.title   = element_text(hjust = 0),
    axis.text.y  = element_text(face = "bold")
  )

p_yt_sent

ggsave("output_youtube_sentiment.png", plot = p_yt_sent, width = 10, height = 8, dpi = 150)
YouTube comment tone largely mirrors Reddit sentiment — campuses with consistent negativity across both channels face compounding reputational challenges.

Top Words in YouTube Video Comments by Campus

School size affects the words available for sentiment analysis. Larger schools have more comments on their videos. Recent events influence the sentiment in YouTube comments. Negative events involving the schools generate negative sentiment in the comment section of their videos. This increases the visibility of the videos.
library(tidytext)

top_words_per_campus <- tidy_yt |>
  count(csu_campus, word, sort = TRUE) |>
  group_by(csu_campus) |>
  slice_max(n, n = 5, with_ties = FALSE) |>
  ungroup()

plot_data <- top_words_per_campus |>
  mutate(word = reorder_within(word, n, csu_campus))

csu_bar_plots <- ggplot(plot_data, aes(x = n, y = word, fill = csu_campus)) +
  geom_col(show.legend = FALSE) +
  scale_y_reordered() +
  facet_wrap(~ csu_campus, scales = "free_y", ncol = 4) +
  labs(title = "Top Words in YouTube Video Comments by CSU Campus",
       x = "Word Frequency",
       y = NULL) +
  theme_minimal() +
  theme(strip.text = element_text(size = 8, face = "bold"))

csu_bar_plots

ggsave("output_youtube_top_words.png", plot = csu_bar_plots, width = 16, height = 18, dpi = 150)

YouTube Engagement by Campus

yt_engagement <- yt_videos |>
  group_by(csu_campus) |>
  summarise(
    Videos      = n(),
    Total_Views = sum(views, na.rm = TRUE),
    Avg_Views   = round(mean(views, na.rm = TRUE)),
    Total_Likes = sum(likes, na.rm = TRUE),
    .groups = "drop"
  ) |>
  arrange(desc(Total_Views)) |>
  mutate(Total_Views = formatC(Total_Views, format = "d", big.mark = ","),
         Avg_Views   = formatC(Avg_Views,   format = "d", big.mark = ","),
         Total_Likes = formatC(Total_Likes, format = "d", big.mark = ","))

yt_engagement |>
  kable(
    caption   = "YouTube Engagement Metrics by CSU Campus",
    col.names = c("Campus", "Videos", "Total Views", "Avg Views / Video", "Total Likes")
  )
YouTube Engagement Metrics by CSU Campus
Campus Videos Total Views Avg Views / Video Total Likes
San Jose State University 5 645,218 129,044 10,146
California State University Long Beach 5 367,036 73,407 3,064
Sonoma State University 5 260,981 52,196 250
San Diego State University 5 243,135 48,627 2,650
California State University Northridge 5 182,953 36,591 1,559
California State University Sacramento 5 146,999 29,400 1,549
California State University Fullerton 5 135,562 27,112 1,362
California State University Chico 5 128,635 25,727 966
Cal Poly San Luis Obispo 5 120,587 24,117 1,304
Cal Poly Pomona 5 111,540 22,308 916
California State University Los Angeles 5 108,148 21,630 1,090
California State University San Marcos 5 99,429 19,886 489
California State University San Bernardino 5 81,776 16,355 719
California State University East Bay 5 81,345 16,269 688
California State University Fresno 5 71,560 14,312 644
San Francisco State University 5 60,814 12,163 1,367
California State University Dominguez Hills 5 58,281 11,656 549
California State University Maritime Academy 5 56,778 11,356 300
California State University Bakersfield 5 56,521 11,304 458
California State University Monterey Bay 5 48,727 9,745 459
California State University Channel Islands 5 46,091 9,218 397
Cal Poly Humboldt 5 36,788 7,358 452
California State University Stanislaus 5 16,021 3,204 182
Campuses with negative Reddit threads should audit the top-discussed topics and respond with targeted content addressing housing, cost, and campus safety.

5. Are Students Searching?

Search interest for each campus over the past 5 years, CSU 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     = if (any(!is.na(hits))) date[which.max(replace(hits, is.na(hits), -Inf))] else as.Date(NA),
    .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 (CSU, last 5 years)",
      col.names = c("Campus", "Avg Interest", "Peak Interest", "Peak Date"),
      escape = FALSE)
Google Trends — Avg Search Interest by Campus (CSU, last 5 years)
Campus Avg Interest Peak Interest Peak Date
CSU Northridge 40.5 80 2022-08-28
CSU Fresno 25.4 100 2021-09-19
San Diego State 19.5 100 2023-04-02
Cal Poly Pomona 14.0 26 2023-01-29
San Jose State 13.8 30 2023-08-27
CSU Sacramento 13.3 24 2022-07-17
Cal Poly San Luis Obispo 10.7 38 2024-03-10
San Francisco State 9.9 16 2022-03-13
CSU Fullerton 7.6 15 2022-03-13
CSU Los Angeles 7.3 17 2022-08-21
Sonoma State 6.6 21 2025-01-19
CSU Long Beach 3.9 7 2022-02-13
Cal Poly Humboldt 3.5 16 2024-04-21
CSU Chico 2.4 5 2021-08-22
CSU East Bay 2.2 4 2022-08-14
CSU Maritime Academy 1.2 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-08-01
CSU San Marcos 1.0 2 2022-01-02
CSU Stanislaus 1.0 1 2021-08-01
CSU Channel Islands 0.8 1 2021-08-01
CSU Dominguez Hills 0.8 2 2023-11-26
library(plotly)
library(crosstalk)
library(htmltools)

shared_trends <- SharedData$new(trends_data)

campus_names  <- sort(unique(trends_data$campus))
plotly_cycle  <- c('#636EFA','#EF553B','#00CC96','#AB63FA','#FFA15A',
                   '#19D3F3','#FF6692','#B6E880','#FF97FF','#FECB52')
campus_colors <- setNames(rep(plotly_cycle, length.out = length(campus_names)), campus_names)
campus_colors["CSU Channel Islands"] <- "black"

campus_checkboxes <- filter_checkbox(
  id         = "campus_check",
  label      = NULL,
  sharedData = shared_trends,
  group      = ~campus,
  inline     = FALSE
)

controls <- tags$div(
  style = "padding: 6px 0;",
  tags$p(
    style = "font-weight: bold; font-size: 13px; margin: 0 0 2px 0;",
    "Select campuses:"
  ),
  tags$p(
    style = "font-size: 11px; color: #666; margin: 0 0 6px 0;",
    "Leave all unchecked to show all"
  ),
  tags$div(
    style = "display: flex; gap: 6px; margin-bottom: 6px;",
    tags$button(
      "Select All",
      style   = "font-size: 11px; padding: 3px 8px; cursor: pointer;",
      onclick = "document.querySelectorAll('#campus_check input[type=checkbox]').forEach(function(cb){ cb.checked = true; cb.dispatchEvent(new Event('change', {bubbles:true})); });"
    ),
    tags$button(
      "Deselect All",
      style   = "font-size: 11px; padding: 3px 8px; cursor: pointer;",
      onclick = "document.querySelectorAll('#campus_check input[type=checkbox]').forEach(function(cb){ cb.checked = false; cb.dispatchEvent(new Event('change', {bubbles:true})); });"
    )
  ),
  tags$div(
    style = "max-height: 220px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px;",
    campus_checkboxes
  )
)

trends_plot <- plot_ly(
    shared_trends,
    x      = ~date,
    y      = ~hits,
    color  = ~campus,
    colors = campus_colors,
    type   = "scatter",
    mode   = "lines",
    hovertemplate = paste(
      "<b>%{fullData.name}</b><br>",
      "Date: %{x|%b %Y}<br>",
      "Interest: %{y}<extra></extra>"
    )
  ) |>
  layout(
    title = list(
      text = "Google Search Interest Over Time — CSU Campuses",
      font = list(color = "#CC0000", size = 14, weight = "bold")
    ),
    xaxis     = list(title = "Date"),
    yaxis     = list(title = "Search Interest (0–100)", rangemode = "tozero"),
    legend    = list(title = list(text = "Campus"), font = list(size = 9)),
    hovermode = "closest"
  ) |>
  htmlwidgets::onRender("
    function(el) {
      var minTop = 2;
      var busy   = false;

      el.on('plotly_afterplot', function() {
        if (busy) return;

        // Max y value across all currently visible traces
        var maxY = 0;
        (el._fullData || []).forEach(function(t) {
          if (t.visible !== false && Array.isArray(t.y)) {
            t.y.forEach(function(v) { if (+v > maxY) maxY = +v; });
          }
        });

        var yr  = el._fullLayout && el._fullLayout.yaxis && el._fullLayout.yaxis.range;
        var cur = yr ? yr[1] : null;

        if (maxY > 0 && maxY < minTop && (cur === null || cur < minTop)) {
          // Visible data is below the floor — lock axis to minTop
          busy = true;
          Plotly.relayout(el, {'yaxis.range[0]': 0, 'yaxis.range[1]': minTop})
            .then(function() { busy = false; });
        } else if (maxY >= minTop && cur !== null && cur <= minTop + 0.01) {
          // Data now exceeds minTop but axis is still locked — release to autorange
          busy = true;
          Plotly.relayout(el, {'yaxis.autorange': true})
            .then(function() { busy = false; });
        }
      });
    }
  ")

bscols(
  widths = c(3, 9),
  controls,
  trends_plot
)

Select campuses:

Leave all unchecked to show all

Google Trends reflects real-time public curiosity — a campus with strong SEO but flat search interest may be winning on-page but losing the awareness battle. Sustained peaks in search volume, especially around application deadlines (October–December), signal strong brand recall among prospective students. Campuses with consistently low or declining interest should treat this as an early warning indicator before it shows up in enrollment numbers.
Campuses with declining search interest should launch branded keyword campaigns before the trend reverses enrollment.

6. Can Digital Presence Predict Enrollment?

Can SEO strength and public sentiment predict enrollment? We join campus-level data from four sources — SEO audit, Reddit sentiment, YouTube comment sentiment, and Google Trends search interest — then model Fall 2023 enrollment as the target variable.

# --- YouTube: net sentiment score per campus ---
yt_model <- campus_yt_sentiment |>
  select(csu_campus, sentiment, n) |>
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
  mutate(
    yt_net_sentiment  = (positive - negative) / (positive + negative),
    yt_total_words    = positive + negative
  ) |>
  rename(campus = csu_campus)

# --- Reddit: avg sentiment per campus ---
reddit_model <- reddit_json_campus |>
  select(campus_std, avg_sentiment, n_posts) |>
  rename(campus = campus_std, reddit_avg_sentiment = avg_sentiment)

# --- SEO: score per campus (same formula as seo-score chunk) ---
seo_model <- seo_data |>
  mutate(
    pass_title     = title_length >= 10 & title_length <= 60,
    pass_meta      = !is.na(meta_description) & meta_description != "NA" &
                     meta_length >= 50 & meta_length <= 160,
    pass_h1        = h1_count == 1,
    pass_hierarchy = h2_count > 0 & h3_count > 0
  ) |>
  mutate(seo_score = rowSums(across(c(pass_title, pass_meta, pass_h1, pass_hierarchy)),
                             na.rm = TRUE)) |>
  select(school, seo_score) |>
  rename(campus = school)

# --- Enrollment ---
enroll_model <- enrollment_clean |>
  select(campus, total_enrollment)

# --- Google Trends: avg interest per campus ---
trends_model <- trends_summary |>
  select(campus, avg_interest)

# --- Join all ---
model_data <- enroll_model |>
  left_join(seo_model,     by = "campus") |>
  left_join(reddit_model,  by = "campus") |>
  left_join(yt_model,      by = "campus") |>
  left_join(trends_model,  by = "campus") |>
  filter(!is.na(seo_score), !is.na(reddit_avg_sentiment)) |>
  mutate(across(c(yt_net_sentiment, yt_total_words, avg_interest), ~replace_na(., 0)))

invisible(nrow(model_data))

Correlation Matrix

library(corrplot)

cor_vars <- model_data |>
  select(total_enrollment, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
  rename(
    Enrollment        = total_enrollment,
    SEO_Score         = seo_score,
    Google_Trends     = avg_interest,
    Reddit_Sentiment  = reddit_avg_sentiment,
    YouTube_Sentiment = yt_net_sentiment
  )

cor_matrix <- cor(cor_vars, use = "complete.obs")

par(col.main = "#CC0000", font.main = 2)
corrplot(cor_matrix,
         method      = "color",
         type        = "upper",
         addCoef.col = "black",
         tl.col      = "black",
         tl.srt      = 45,
         col         = colorRampPalette(c("#CC0000", "white", "#003366"))(200),
         title       = "Correlation Matrix — Enrollment, SEO, Sentiment & Google Trends",
         mar         = c(0, 0, 2, 0))

png("output_correlation_matrix.png", width = 800, height = 700, res = 150)
par(col.main = "#CC0000", font.main = 2)
corrplot(cor_matrix,
         method      = "color",
         type        = "upper",
         addCoef.col = "black",
         tl.col      = "black",
         tl.srt      = 45,
         col         = colorRampPalette(c("#CC0000", "white", "#003366"))(200),
         title       = "Correlation Matrix — Enrollment, SEO, Sentiment & Google Trends",
         mar         = c(0, 0, 2, 0))
dev.off()
SEO score and Google Trends interest show the strongest positive relationship with enrollment, while sentiment alone is a weaker standalone predictor.

Linear Regression — Enrollment ~ Digital Presence

library(broom)

tidy(reg_model) |>
  mutate(
    term = recode(term, "avg_interest" = "google_trends"),
    across(where(is.numeric), ~round(., 4))
  ) |>
  kable(
    caption   = "Linear Regression — Predictors of CSU Campus Enrollment",
    col.names = c("Term", "Estimate", "Std Error", "t Value", "p Value"),
    escape    = FALSE
  )
Linear Regression — Predictors of CSU Campus Enrollment
Term Estimate Std Error t Value p Value
(Intercept) 6907.4959 5367.648 1.2869 0.2144
seo_score 3836.0709 1918.353 1.9997 0.0609
google_trends 505.1861 224.374 2.2515 0.0371
reddit_avg_sentiment -34052.6919 21616.008 -1.5753 0.1326
yt_net_sentiment 15986.9874 12561.001 1.2727 0.2193
model_data_aug <- model_data |>
  mutate(
    fitted    = fitted(reg_model),
    residuals = residuals(reg_model)
  )

r2     <- round(summary(reg_model)$r.squared, 3)
r2_adj <- round(summary(reg_model)$adj.r.squared, 3)

p_reg <- ggplot(model_data_aug, aes(x = fitted, y = total_enrollment, label = campus)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed", color = "grey50") +
  geom_point(color = "#CC0000", size = 3, alpha = 0.8) +
  geom_text_repel(size = 2.5, max.overlaps = Inf, box.padding = 0.3, seed = 42) +
  labs(
    title    = "Actual vs. Fitted Enrollment",
    subtitle = paste0("R² = ", r2, "  |  Adj. R² = ", r2_adj),
    x        = "Fitted (Predicted) Enrollment",
    y        = "Actual Enrollment"
  ) +
  theme_minimal()

p_reg

ggsave("output_regression_fit.png", plot = p_reg, width = 8, height = 5, dpi = 150)
An R² of 0.528 means the model explains 52.8% of the variation in enrollment across campuses — the remaining 47.2% is attributable to factors not captured here (e.g., location, program mix, tuition, athletics).

Cluster Analysis — Campus Profiles

K-means clustering (k = 3) groups campuses by their combined SEO score, Reddit sentiment, and YouTube sentiment. Clusters reveal strategic archetypes regardless of enrollment size.

set.seed(123)

cluster_input <- model_data |>
  select(campus, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
  as.data.frame() |>
  (\(df) { rownames(df) <- df$campus; df[, -1] })() |>
  scale()

km <- kmeans(cluster_input, centers = 3, nstart = 25)

model_data_clust <- model_data |>
  mutate(
    cluster = factor(km$cluster, labels = c("Cluster 1", "Cluster 2", "Cluster 3")),
    size_tier = case_when(
      total_enrollment >= 30000 ~ "Large (30k+)",
      total_enrollment >= 15000 ~ "Medium (15k–30k)",
      TRUE                      ~ "Small (<15k)"
    ),
    size_tier = factor(size_tier, levels = c("Large (30k+)", "Medium (15k–30k)", "Small (<15k)"))
  )

p_clust <- ggplot(model_data_clust,
                  aes(x = reddit_avg_sentiment, y = seo_score,
                      color = cluster, shape = size_tier, label = campus)) +
  geom_point(size = 4, alpha = 0.85) +
  geom_text_repel(size = 2.4, max.overlaps = Inf, box.padding = 0.4,
                  show.legend = FALSE, seed = 42) +
  scale_color_manual(values = c("#CC0000", "#FF6600", "#003366")) +
  scale_shape_manual(values = c("Large (30k+)" = 17, "Medium (15k–30k)" = 16, "Small (<15k)" = 15)) +
  labs(
    title  = "Campus Clusters — SEO Score vs. Reddit Sentiment",
    x      = "Reddit Avg Sentiment (−1 to +1)",
    y      = "SEO Score (0–4)",
    color  = "Cluster",
    shape  = "Enrollment Size"
  ) +
  theme_minimal()

p_clust

ggsave("output_cluster.png", plot = p_clust, width = 9, height = 7, dpi = 150)
The cluster analysis reveals three distinct playbooks — leaders, improvers, and rebuilders — each requiring a different marketing investment level and strategy.
Clustering groups campuses by similarity across SEO score, search interest, and sentiment — not by size alone. A campus landing in the “rebuilder” cluster despite moderate enrollment signals a digital presence that has not kept pace with its physical footprint. Conversely, a smaller campus in the “leader” cluster is punching above its weight digitally, which is a competitive advantage in student recruitment.
model_data_clust |>
  select(cluster, campus, total_enrollment, seo_score, reddit_avg_sentiment, yt_net_sentiment) |>
  arrange(cluster, desc(total_enrollment)) |>
  mutate(total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
         across(c(seo_score, reddit_avg_sentiment, yt_net_sentiment), ~round(., 3))) |>
  kable(
    caption   = "Campus Cluster Assignments",
    col.names = c("Cluster", "Campus", "Enrollment", "SEO Score",
                  "Reddit Sentiment", "YouTube Sentiment"),
    escape    = FALSE
  )
Campus Cluster Assignments
Cluster Campus Enrollment SEO Score Reddit Sentiment YouTube Sentiment
Cluster 1 CSU San Bernardino 20,622 2 -0.100 0.000
Cluster 1 CSU San Marcos 16,862 1 0.043 0.000
Cluster 1 CSU Dominguez Hills 16,484 1 -0.022 0.000
Cluster 1 CSU Stanislaus 11,103 2 0.030 0.000
Cluster 1 Sonoma State 6,912 0 -0.031 0.000
Cluster 1 CSU Maritime Academy 912 1 0.209 0.000
Cluster 2 CSU Fullerton 40,421 4 -0.079 0.000
Cluster 2 CSU Northridge 37,701 4 0.031 0.000
Cluster 2 San Diego State 37,014 3 -0.072 0.000
Cluster 2 San Jose State 35,915 3 -0.200 0.000
Cluster 2 CSU Sacramento 31,536 3 -0.039 0.000
Cluster 2 CSU Los Angeles 27,311 3 -0.107 0.000
Cluster 2 CSU Fresno 25,215 3 -0.063 0.000
Cluster 2 Cal Poly Humboldt 6,699 3 -0.082 -0.429
Cluster 3 CSU Long Beach 45,064 4 0.050 0.000
Cluster 3 Cal Poly Pomona 27,782 2 0.148 0.333
Cluster 3 San Francisco State 25,758 3 0.086 0.000
Cluster 3 Cal Poly San Luis Obispo 22,108 3 0.039 0.600
Cluster 3 CSU Chico 16,549 3 0.212 0.000
Cluster 3 CSU East Bay 14,710 4 0.061 0.000
Cluster 3 CSU Bakersfield 11,201 3 0.074 0.000
Cluster 3 CSU Monterey Bay 8,196 3 -0.002 0.000
Cluster 3 CSU Channel Islands 7,104 4 0.013 0.000

Logistic Regression — Predicting High Enrollment

Campuses are classified as Large (1) if Fall 2023 enrollment exceeds 30,000 students, and Not Large (0) otherwise. Logistic regression estimates the probability of reaching large-campus status based on SEO score, Reddit sentiment, and YouTube sentiment.

tidy(logit_model) |>
  mutate(
    odds_ratio = round(exp(estimate), 3),
    across(where(is.numeric), ~round(., 4))
  ) |>
  kable(
    caption   = "Logistic Regression — Predictors of Large-Campus Enrollment (30k+)",
    col.names = c("Term", "Estimate (log-odds)", "Std Error", "z Value", "p Value", "Odds Ratio"),
    escape    = FALSE
  )
Logistic Regression — Predictors of Large-Campus Enrollment (30k+)
Term Estimate (log-odds) Std Error z Value p Value Odds Ratio
(Intercept) -14.5497 8.6707 -1.6780 0.0933 0.000
seo_score 3.6042 2.2989 1.5678 0.1169 36.752
avg_interest 0.1414 0.1175 1.2038 0.2287 1.152
reddit_avg_sentiment -18.2719 14.5124 -1.2591 0.2080 0.000
yt_net_sentiment 0.7954 5.1581 0.1542 0.8775 2.215
# McFadden's pseudo-R²
null_ll  <- logLik(glm(large ~ 1, data = logit_data, family = binomial))
full_ll  <- logLik(logit_model)
mcfadden <- round(1 - as.numeric(full_ll) / as.numeric(null_ll), 3)

# Confusion matrix metrics (threshold = 0.5)
predicted_class <- as.integer(fitted(logit_model) >= 0.5)
actual          <- logit_data$large

tp <- sum(predicted_class == 1 & actual == 1)
tn <- sum(predicted_class == 0 & actual == 0)
fp <- sum(predicted_class == 1 & actual == 0)
fn <- sum(predicted_class == 0 & actual == 1)

accuracy  <- round((tp + tn) / length(actual), 3)
precision <- round(tp / (tp + fp), 3)
recall    <- round(tp / (tp + fn), 3)
f1        <- round(2 * precision * recall / (precision + recall), 3)

tibble(
  Metric = c("McFadden Pseudo-R²", "Accuracy", "Precision", "Recall (Sensitivity)", "F1 Score"),
  Value  = c(mcfadden, accuracy, precision, recall, f1),
  Notes  = c(
    "0 = null model, >0.2 = good fit",
    "% of campuses correctly classified",
    "Of predicted Large, % actually Large",
    "Of actual Large campuses, % correctly identified",
    "Harmonic mean of Precision & Recall"
  )
) |>
  kable(caption = "Logistic Regression — Model Fit Statistics", escape = FALSE)
Logistic Regression — Model Fit Statistics
Metric Value Notes
McFadden Pseudo-R² 0.543 0 = null model, >0.2 = good fit
Accuracy 0.870 % of campuses correctly classified
Precision 0.800 Of predicted Large, % actually Large
Recall (Sensitivity) 0.667 Of actual Large campuses, % correctly identified
F1 Score 0.727 Harmonic mean of Precision & Recall
logit_data_aug <- logit_data |>
  mutate(prob_large = fitted(logit_model),
         outcome    = factor(large, labels = c("Not Large", "Large (30k+)")))

p_logit <- ggplot(logit_data_aug,
                  aes(x = seo_score, y = prob_large, color = outcome, label = campus)) +
  geom_point(size = 3, alpha = 0.85) +
  geom_text_repel(size = 2.6, max.overlaps = Inf, box.padding = 0.5,
                  point.padding = 0.3, force = 3, min.segment.length = 0,
                  segment.color = "grey60", seed = 42) +
  scale_color_manual(values = c("Not Large" = "#888888", "Large (30k+)" = "#CC0000")) +
  scale_y_continuous(labels = scales::percent_format(accuracy = 1), limits = c(0, 1)) +
  labs(
    title    = "Predicted Probability of Large-Campus Status by SEO Score",
    subtitle = "Logistic regression — color shows actual enrollment outcome",
    x        = "SEO Score (0–4)",
    y        = "Predicted Probability of Large (30k+)",
    color    = "Actual Outcome"
  ) +
  theme_minimal()

p_logit

ggsave("output_logistic.png", plot = p_logit, width = 8, height = 5, dpi = 150)
The logistic regression estimates the probability that a campus reaches large-campus status (30,000+ students) based on its digital signals. A higher SEO score meaningfully shifts that probability upward, indicating that optimized web presence is not just cosmetic — it correlates with the scale of student reach. Campuses clustered in the lower-left of the chart (low SEO, low predicted probability) have the most to gain from targeted digital investment, as even a one-point SEO improvement can shift their predicted odds noticeably.

CSUCI Spotlight

CSUCI is compared here against its closest enrollment-tier peers: Sonoma State, CSU Monterey Bay, CSU Stanislaus, and Cal Poly Humboldt.

peer_campuses <- c(
  "CSU Channel Islands",
  "Sonoma State",
  "CSU Monterey Bay",
  "CSU Stanislaus",
  "Cal Poly Humboldt"
)

model_data |>
  filter(campus %in% peer_campuses) |>
  select(campus, total_enrollment, seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment) |>
  mutate(
    total_enrollment = formatC(total_enrollment, format = "d", big.mark = ","),
    across(c(seo_score, avg_interest, reddit_avg_sentiment, yt_net_sentiment), ~round(., 3))
  ) |>
  arrange(desc(total_enrollment)) |>
  kable(
    caption   = "CSUCI vs. Enrollment-Tier Peers — Digital Presence Comparison",
    col.names = c("Campus", "Enrollment", "SEO Score", "Google Trends Interest",
                  "Reddit Sentiment", "YouTube Sentiment")
  )
CSUCI vs. Enrollment-Tier Peers — Digital Presence Comparison
Campus Enrollment SEO Score Google Trends Interest Reddit Sentiment YouTube Sentiment
CSU Monterey Bay 8,196 3 1.0 -0.002 0.000
CSU Channel Islands 7,104 4 0.8 0.013 0.000
Sonoma State 6,912 0 6.6 -0.031 0.000
Cal Poly Humboldt 6,699 3 3.5 -0.082 -0.429
CSU Stanislaus 11,103 2 1.0 0.030 0.000
CSUCI sits in the mid-tier cluster — its SEO and sentiment scores are competitive with peers, but Google search interest remains lower, suggesting an awareness gap rather than a reputation problem.
Investing in branded content and search visibility — particularly targeting prospective transfer students in Ventura and LA counties — could close CSUCI’s awareness gap within two to three academic cycles.

Interactive Dashboard

Explore CSU enrollment trends, SEO scores, and sentiment data interactively. Use the filters to compare campuses side by side.

Launch Interactive Dashboard →


Executive Action Plan

Priority Recommendation Impact Difficulty
🔴 High Add missing meta descriptions to 6 campuses High Low
🔴 High Fix title tags outside the 50–60 character range High Low
🟡 Medium Monitor Reddit sentiment monthly by campus Medium Medium
🟡 Medium Expand CSUCI YouTube content strategy Medium Medium
🟡 Medium Launch branded keyword campaigns for declining-trend campuses Medium High
🟢 Long Term Build an interactive Shiny enrollment dashboard High High


Managerial Recommendations

Based on the enrollment analysis, SEO audit, sentiment analysis, and predictive modeling, we offer the following actionable recommendations to CSU marketing and admissions leadership.

1. Prioritize SEO Fixes at Under-Enrolled Campuses

Two campuses scored just 1 out of 4 on the SEO composite index: CSU Dominguez Hills (missing H1, meta description exceeding 160 characters) and CSU San Marcos (title tag exceeding the 60-character limit, no meta description). Both also lacked H3 tags, failing the heading hierarchy check. Sonoma State could not be evaluated — its website blocked the web scraper, preventing data collection entirely; a manual SEO audit is warranted, and the campus should investigate whether its bot-blocking configuration is also impeding legitimate search engine indexing by Googlebot. Recommendation: Conduct a structured SEO remediation sprint at all campuses scoring below 2 out of 4, targeting title tag length (10–60 characters), meta description completeness and length (50–160 characters), H1 uniqueness, and heading hierarchy.

2. Address Negative Sentiment Campuses with Proactive Messaging

Reddit and YouTube sentiment analysis identified campuses with consistently negative public discourse. Negative online sentiment, left unaddressed, compounds over time as prospective students rely on social media to evaluate campus choices. Recommendation: Marketing teams at campuses with below-average sentiment scores should audit the most-discussed topics in Reddit threads and YouTube comments to identify recurring pain points (housing, financial aid, campus culture) and respond with targeted content campaigns.

3. Leverage High-Sentiment Campuses as Messaging Templates

Campuses with strong positive sentiment — particularly among larger enrollments — demonstrate that scale and positive perception are not mutually exclusive. Recommendation: Study the messaging, social media strategy, and website content of high-sentiment, high-enrollment campuses (e.g., San Diego State, San Jose State) and use them as templates for mid-tier campuses seeking enrollment growth.

4. Invest in Search Trend Recovery for Declining Campuses

Google Trends data reveals that several campuses have experienced declining search interest over the five-year window. Declining search volume is an early indicator of weakening brand awareness among prospective students. Recommendation: Campuses showing a downward trend in Google search interest should increase investment in branded keyword campaigns, press releases, and social media content to rebuild search visibility before enrollment impacts materialize.

5. Use Cluster Segments for Differentiated Marketing Strategy

The k-means cluster analysis identified three distinct campus archetypes — differentiated by SEO strength and sentiment profile. Rather than a one-size-fits-all system-wide campaign, the CSU Chancellor’s Office marketing team should develop three differentiated playbooks aligned to each cluster: one for high-SEO/high-sentiment leaders, one for mid-tier campuses with improvement potential, and one for campuses requiring foundational digital investment.


Limitations and Future Research

Limitations

Sample size: With only 23 campuses, regression models have limited statistical power. Coefficients should be interpreted directionally rather than as precise predictors. A p-value threshold of 0.10 (rather than the conventional 0.05) is appropriate given the small N.

Enrollment data: Per-campus enrollment figures reflect Fall 2023 and were entered manually from the CSU Chancellor’s Office facts page, as the site blocks automated scraping. Systemwide headcount trends are available through Fall 2025 via the CSU Enrollment Dashboard.

Reddit and YouTube coverage: Not all 23 campuses have equal Reddit or YouTube representation. Smaller campuses (e.g., CSU Maritime Academy) have very few posts, making their sentiment scores less reliable than those for larger campuses with thousands of posts.

SEO snapshot: The SEO scrape represents a single point in time. Websites are updated frequently, and title tags or meta descriptions may have changed since data collection.

Sentiment lexicon: The Bing lexicon used for sentiment scoring is a general-purpose word list not trained on higher-education language. Domain-specific terms (e.g., “party school,” “commuter campus”) may be misclassified.

Google Trends normalization: Trends data is indexed relative to peak search volume within each batch query, not as absolute search counts. Comparisons across batches (which were run separately due to API limits) are approximate.

Upcoming institutional change: CSU Maritime Academy and Cal Poly San Luis Obispo are scheduled to merge in Fall 2026. Analyses treating these as separate institutions reflect the current structure and will require updating once the merger is complete. Findings related to CSU Maritime Academy should be interpreted with this transition in mind.

Future Research

  • Expand primary survey (BQ 1) beyond the MSBA cohort to prospective undergraduate students; a larger sample (n ≥ 100) would support conjoint analysis or EFA to quantify the relative weight of each decision factor.
  • Expand sentiment sources to include Bluesky, X (Twitter), and RateMyProfessors to reduce platform dependency.
  • Longitudinal modeling: Collect enrollment and sentiment data over multiple years to test whether sentiment changes predict enrollment changes with a lag.
  • Page speed and mobile usability: Incorporate Google PageSpeed Insights scores as additional SEO predictors.
  • Shiny dashboard: Deploy an interactive version of this report as a Shiny application to allow CSU administrators to filter and explore campus-specific findings.

Data Summary

tibble(
  Dataset = c(
    "SEO Data",
    "Website Overhaul History",
    "Enrollment (campus-level, Fall 2023)",
    "Enrollment (systemwide trends, 2015–2025)",
    "System KPIs (2024–2025)",
    "Google Trends (time series)",
    "Google Trends (summary)",
    "YouTube Video Stats",
    "Primary Survey — MSBA Cohort"
  ),
  Rows = c(
    sum(seo_data$status == "ok", na.rm = TRUE),
    nrow(website_history),
    nrow(enrollment_clean),
    nrow(enrollment_trends),
    nrow(kpis_data),
    nrow(trends_data),
    nrow(trends_summary),
    nrow(yt_videos),
    nrow(survey)
  ),
  File = c(
    "csu_seo_data.csv",
    "csu_website_history.csv",
    "csu_enrollment.csv",
    "enrollment_trends.csv",
    "kpis.csv",
    "csu_trends_timeseries.csv",
    "csu_trends_summary.csv",
    "csu_video_stats.csv",
    "customer_feedback.csv"
  )
) |>
  kable(caption = "All Output Files")
All Output Files
Dataset Rows File
SEO Data 22 csu_seo_data.csv
Website Overhaul History 23 csu_website_history.csv
Enrollment (campus-level, Fall 2023) 23 csu_enrollment.csv
Enrollment (systemwide trends, 2015–2025) 11 enrollment_trends.csv
System KPIs (2024–2025) 11 kpis.csv
Google Trends (time series) 6026 csu_trends_timeseries.csv
Google Trends (summary) 23 csu_trends_summary.csv
YouTube Video Stats 115 csu_video_stats.csv
Primary Survey — MSBA Cohort 12 customer_feedback.csv

References

SEO

Primary sources (official Google documentation):

Supporting industry sources:

R & Text Analysis

Marketing & Data Science