Among the most widely known social media apps, X uniquely enables individuals to easily share their thoughts, opinions, and news. Political figures, in particular uses the platform to share updates on policy, making it easier for the public to know where they stand. Grounded in First-Level Agenda-Setting and Framing theories, this study explores which of three topics—Immigration, Economy, and the order to release the Epstein Files— individual members of the current U.S. Congress have posted about most often on their official X accounts. Drawing upon these two media theories, the study examines which topics members have prioritized, how members have constructed narratives around the topics, and how political party affiliation and external political developments have influenced topic prioritization and narrative construction over time.
The data for the study consist of all X.com posts by members of the 119th United States Congress between Jan. 1, 2025 and March 15, 2026 - more than 519,000 posts in all. The posts were gathered via a Brandwatch query made possible by the MTSU School of Journalism and Strategic Media’s Social Media Insights lab. A custom R script was developed to compile and analyze the data. Posts from the first two weeks of September 2025 are missing, due to a failure in the connection between X.com and Brandwatch. We are investigating ways to acquire the missing posts. We are considering deploying inferential statistical analysis to assess volume differences by topic and party. However, inferential statistics may provide little additional value to the analysis, given that the dataset includes all Congressional X.com posts rather than a mere sample of the posts.
Are surges evident in the volume of X.com posts that members of the current U.S. Congress have made about immigration, the economy, and the Epstein files? (Yes)
Does surge volume interact with party affiliation? (Yes)
When one party initiates a surge, how does the other party respond? (By diverting attention and/or reframing topics)
What did members post about most frequently in the weeks following the attack on Iran by the U.S. and Israel?
During topic surges, the opposing party often shifts attention to related but different policy narratives rather than posting about the same framing.
Surges in topic attention are often driven by a small number of highly active members, rather than coordinated posting across the entire party.
Epstein Files: Not the biggest spike in volume, BUT posts about Epstein tend to receive high engagement (“high-impact” surge rather than high-frequency)
Posting volume varied over time by topic and party, with several evident “surges” in posting frequency.
Meanwhile, total posting volume shows week-to-week variation. Within a given week, however, Republican posting volume generally exceeds Democratic/Independent posting volume.
Republicans posted infrequently about the Epstein files. But what posts they did share tended to receive high numbers of “Likes” from post viewers.
This R code produces all of the visualizations shown above.
# ============================================
# Congressional X Posts text analysis
# ============================================
# ============================================
# --- Load required libraries ---
# ============================================
if (!require("tidyverse")) install.packages("tidyverse")
if (!require("tidytext")) install.packages("tidytext")
library(tidyverse)
library(tidytext)
# ============================================
# --- Load the data from project folder ---
# ============================================
Data <- readRDS("Latest119thData.rds")
# ============================================
# --- Add "Week" variable ---
# ============================================
Data <- Data %>%
mutate(Date = as.Date(Date)) %>%
mutate(
WeekStart = lubridate::floor_date(Date, unit = "week", week_start = 1)
)
first_week <- min(Data$WeekStart, na.rm = TRUE)
Data <- Data %>%
mutate(Week = as.integer(difftime(WeekStart, first_week, units = "weeks")) + 1)
# ============================================
# --- Combine Democrats and independents ---
# ============================================
Data <- Data %>%
mutate(
party = case_when(
party %in% c("Democrat", "Independent") ~ "Dem/Ind",
party == "Republican" ~ "Rep",
TRUE ~ "error"
)
)
# ============================================
# --- Optional prefilter ---
# (unchanged; left commented)
# ============================================
#
# phrases_prefilter <- c(
# "Trump",
# "the president",
# "our president",
# "White House",
# "Oval Office"
# )
#
# escaped_phrases_prefilter <- str_replace_all(
# phrases_prefilter,
# "([\\^$.|?*+()\\[\\]{}\\\\])",
# "\\\\\\1"
# )
#
# pattern_prefilter <- paste0("\\b", escaped_phrases_prefilter, "\\b", collapse = "|")
#
# Data <- Data %>%
# mutate(
# Full.Text.clean = str_squish(Full.Text),
# Prefilter = if_else(
# str_detect(Full.Text.clean, regex(pattern_prefilter, ignore_case = TRUE)),
# "Yes",
# "No"
# )
# )
#
# Data <- Data %>%
# filter(Prefilter == "Yes")
# ============================================
# --- Optional ngram analysis ---
# (unchanged; left commented)
# ============================================
#
# ngram_size <- 1 # <- change to 1, 2, 3, 4, etc.
#
# Ngram_Frequencies <- Data %>%
# mutate(Full.Text.clean = stringr::str_squish(Full.Text)) %>%
# unnest_tokens(
# output = "ngram",
# input = Full.Text.clean,
# token = "ngrams",
# n = ngram_size) %>%
# count(ngram, sort = TRUE) %>%
# filter(!is.na(ngram), ngram != "")
# ============================================
# --- Define topic labels for graphs ---
# ============================================
Topic1Label <- "Immigration"
Topic2Label <- "Economy"
Topic3Label <- "Epstein" # <<< NEW
# ============================================
# --- Flag Topic1-related posts ---
# ============================================
phrases_topic1 <- c(
"immigration",
"immigrants",
"immigrant",
"asylum seekers",
"refugee",
"refugees",
"alien",
"aliens",
"illegals",
"border crossings",
"cross the border",
"crossed the border",
"crossing the border",
"the southern border",
"border security",
"secure the border",
"secure the borders",
"securing the border",
"securing the borders",
"secure border",
"border security",
"ICE",
"I.C.E.",
"CBP",
"C.B.P.",
"Renee Good",
"Pretti"
)
escaped_phrases_topic1 <- str_replace_all(
phrases_topic1,
"([\\^$.|?*+()\\[\\]{}\\\\])",
"\\\\\\1"
)
pattern_topic1 <- paste0("\\b", escaped_phrases_topic1, "\\b", collapse = "|")
Data <- Data %>%
mutate(
Full.Text.clean = str_squish(Full.Text),
Topic1 = if_else(
str_detect(Full.Text.clean, regex(pattern_topic1, ignore_case = TRUE)),
"Yes",
"No"
)
)
# ============================================
# --- Flag Topic2-related posts ---
# ============================================
phrases_topic2 <- c(
"economy",
"economic",
"inflation",
"tariff",
"tariffs",
"trade",
"trading partners",
"prices",
"price of",
"paying more for",
"affordable",
"affordability",
"afford",
"affording"
)
escaped_phrases_topic2 <- str_replace_all(
phrases_topic2,
"([\\^$.|?*+()\\[\\]{}\\\\])",
"\\\\\\1"
)
pattern_topic2 <- paste0("\\b", escaped_phrases_topic2, "\\b", collapse = "|")
Data <- Data %>%
mutate(
Full.Text.clean = str_squish(Full.Text),
Topic2 = if_else(
str_detect(Full.Text.clean, regex(pattern_topic2, ignore_case = TRUE)),
"Yes",
"No"
)
)
# ============================================
# --- Flag Topic3-related posts --- <<< NEW
# ============================================
phrases_topic3 <- c(
"Epstein",
"release the files",
"Ghislaine Maxwell"
)
escaped_phrases_topic3 <- str_replace_all(
phrases_topic3,
"([\\^$.|?*+()\\[\\]{}\\\\])",
"\\\\\\1"
)
pattern_topic3 <- paste0("\\b", escaped_phrases_topic3, "\\b", collapse = "|")
Data <- Data %>%
mutate(
Full.Text.clean = str_squish(Full.Text),
Topic3 = if_else(
str_detect(Full.Text.clean, regex(pattern_topic3, ignore_case = TRUE)),
"Yes",
"No"
)
)
# ============================================
# --- Visualize weekly counts (stacked by party) ---
# ============================================
if (!require("plotly")) install.packages("plotly")
library(plotly)
# --- Build Week -> WeekStart lookup so hover can show a date ---
WeekDates <- Data %>%
distinct(Week, WeekStart) %>%
arrange(Week)
# --- Summarize weekly counts by Party for Topic1 ---
Topic1_weekly_party <- Data %>%
filter(party %in% c("Dem/Ind", "Rep"), Topic1 == "Yes") %>%
group_by(party, Week) %>%
summarize(Count = n(), .groups = "drop") %>%
mutate(Topic = Topic1Label)
# --- Summarize weekly counts by Party for Topic2 ---
Topic2_weekly_party <- Data %>%
filter(party %in% c("Dem/Ind", "Rep"), Topic2 == "Yes") %>%
group_by(party, Week) %>%
summarize(Count = n(), .groups = "drop") %>%
mutate(Topic = Topic2Label)
# --- Summarize weekly counts by Party for Topic3 --- <<< NEW
Topic3_weekly_party <- Data %>%
filter(party %in% c("Dem/Ind", "Rep"), Topic3 == "Yes") %>%
group_by(party, Week) %>%
summarize(Count = n(), .groups = "drop") %>%
mutate(Topic = Topic3Label)
# --- Combine Topic1 + Topic2 + Topic3 weekly counts --- <<< UPDATED
Weekly_counts_party <- bind_rows(Topic1_weekly_party, Topic2_weekly_party, Topic3_weekly_party) %>%
mutate(
# ensure all three topics exist (even if one has zero rows)
Topic = factor(Topic, levels = c(Topic1Label, Topic2Label, Topic3Label))
) %>%
tidyr::complete(
party,
Topic,
Week = full_seq(range(Data$Week, na.rm = TRUE), 1),
fill = list(Count = 0)
) %>%
left_join(WeekDates, by = "Week") %>%
arrange(party, Topic, Week)
# --- Compute a padded y max to avoid clipping the top point ---
y_max_raw <- max(Weekly_counts_party$Count, na.rm = TRUE)
y_max <- max(pretty(c(0, y_max_raw)))
# Alternative: y_max <- max(1, ceiling(y_max_raw * 1.05))
# --- Adaptive tick step for x-axis (Option A) --- <<< NEW
# Targets ~12 labels over the full week range; never less than 1.
week_range <- range(Weekly_counts_party$Week, na.rm = TRUE)
total_weeks <- diff(week_range)
tick_target <- 12
tick_step <- max(1, floor(total_weeks / tick_target))
# --- Hover template (shows week number + WeekStart date) ---
hover_tpl <- paste0(
"<b>%{fullData.name}</b><br>",
"Week: %{x}<br>",
"Week start: %{customdata|%Y-%m-%d}<br>",
"Count: %{y}<extra></extra>"
)
# --- Choose a 3-color palette (Topic1, Topic2, Topic3)
topic_colors <- c("steelblue", "firebrick", "darkgreen")
# --- Build party-specific plots ---
# Show legend ONLY on the top subplot (Dem/Ind)
p_demind <- plot_ly(
data = Weekly_counts_party %>% filter(party == "Dem/Ind"),
x = ~Week,
y = ~Count,
color = ~Topic,
colors = topic_colors,
legendgroup = ~Topic, # group by topic so toggling applies to both panels
showlegend = TRUE, # legend here (top panel only)
type = "scatter",
mode = "lines+markers",
line = list(width = 2),
marker = list(size = 6),
customdata = ~WeekStart,
hovertemplate = hover_tpl
) %>%
layout(
title = "",
xaxis = list(title = ""),
yaxis = list(title = "Number of Posts", range = c(0, y_max)),
hovermode = "x unified",
legend = list(title = list(text = "Topic"))
)
# Hide legend on the bottom subplot (Rep)
p_rep <- plot_ly(
data = Weekly_counts_party %>% filter(party == "Rep"),
x = ~Week,
y = ~Count,
color = ~Topic,
colors = topic_colors,
legendgroup = ~Topic, # same legend groups as above
showlegend = FALSE, # hide legend here (bottom panel)
type = "scatter",
mode = "lines+markers",
line = list(width = 2),
marker = list(size = 6),
customdata = ~WeekStart,
hovertemplate = hover_tpl
) %>%
layout(
title = "",
xaxis = list(
title = "Week Number (Week 1 starts at first observed week in data)"
# dtick will be set in the final layout so it applies to both panels
),
yaxis = list(title = "Number of Posts", range = c(0, y_max)),
hovermode = "x unified"
)
# --- Tile them vertically (stacked) with title + styled subtitle ---
AS_party <- subplot(
p_demind, p_rep,
nrows = 2,
shareX = TRUE,
shareY = TRUE,
titleX = TRUE,
titleY = TRUE
) %>%
layout(
title = list(
text = paste0(
"Weekly topic mentions by party",
"<br><span style='font-size:0.80em; color:#6c757d;'>",
"Top = Dem/Ind, Bottom = Rep",
"</span>"
)
),
showlegend = TRUE,
# Force identical vertical scales on both subplots with padded max
yaxis = list(title = "Number of Posts", range = c(0, y_max)),
yaxis2 = list(title = "Number of Posts", range = c(0, y_max)),
# Option A: sparser ticks + angled labels applied to shared X axes <<< NEW
xaxis = list(dtick = tick_step, tickangle = -45),
xaxis2 = list(dtick = tick_step, tickangle = -45)
)
# ============================================
# --- Show the chart ---
# ============================================
AS_party
# ============================================
# --- Total weekly post volume by party ---
# ============================================
Total_weekly_party <- Data %>%
filter(party %in% c("Dem/Ind", "Rep")) %>%
group_by(party, Week) %>%
summarize(Count = n(), .groups = "drop") %>%
left_join(WeekDates, by = "Week") %>%
arrange(party, Week)
# --- Compute padded y max ---
y_max_tot_raw <- max(Total_weekly_party$Count, na.rm = TRUE)
y_max_tot <- max(pretty(c(0, y_max_tot_raw)))
# --- Adaptive tick step for x-axis (Option A) --- <<< NEW
# Targets ~12 labels over the full week range; never less than 1.
week_range_tot <- range(Total_weekly_party$Week, na.rm = TRUE)
total_weeks_tot <- diff(week_range_tot)
tick_target_tot <- 12
tick_step_tot <- max(1, floor(total_weeks_tot / tick_target_tot))
# --- Hover template ---
hover_tot <- paste0(
"<b>%{fullData.name}</b><br>",
"Week: %{x}<br>",
"Week start: %{customdata|%Y-%m-%d}<br>",
"Count: %{y}<extra></extra>"
)
# --- Party colors (consistent with your topic palette style) ---
party_colors <- c("Dem/Ind" = "steelblue", "Rep" = "firebrick")
# ============================================
# --- Combined single-panel chart ---
# ============================================
AS_party_total_combined <- plot_ly(
data = Total_weekly_party,
x = ~Week,
y = ~Count,
color = ~party,
colors = party_colors,
type = "scatter",
mode = "lines+markers",
line = list(width = 2),
marker = list(size = 6),
customdata = ~WeekStart,
hovertemplate = hover_tot
) %>%
layout(
title = list(
text = paste0(
"Weekly total posts by party",
"<br><span style='font-size:0.80em; color:#6c757d;'>",
"Dem/Ind vs. Rep in a single combined panel",
"</span>"
)
),
xaxis = list(
title = "Week Number (Week 1 starts at first observed week in data)",
dtick = tick_step_tot, # <<< NEW: adaptive spacing
tickangle = -45 # <<< NEW: reduce collisions
),
yaxis = list(
title = "Number of Posts",
range = c(0, y_max_tot)
),
hovermode = "x unified",
legend = list(title = list(text = "Party"))
)
# ============================================
# --- Show the chart ---
# ============================================
AS_party_total_combined
# ============================================
# "Likes" by topic and party
# Step 1: Libraries
# ============================================
library(dplyr)
library(plotly)
library(scales) # for comma formatting of labels
# ============================================
# Step 2: Build derived variables (TopicCount, TopicLabel)
# ============================================
# Assumes Topic1/Topic2/Topic3 are exactly "Yes"/"No"
Data2 <- Data %>%
mutate(
TopicCount = rowSums(across(c(Topic1, Topic2, Topic3), ~ .x == "Yes")),
TopicLabel = case_when(
TopicCount == 1 & Topic1 == "Yes" ~ "Immigration",
TopicCount == 1 & Topic2 == "Yes" ~ "Economy",
TopicCount == 1 & Topic3 == "Yes" ~ "Epstein",
TRUE ~ NA_character_
)
)
# ============================================
# Step 3: Summary of TopicCount
# ============================================
TopicCountSummary <- Data2 %>%
count(TopicCount, name = "Count")
# ============================================
# Step 4: Filter to exactly one topic flagged "Yes"
# ============================================
Data3 <- Data2 %>%
filter(TopicCount == 1)
# ============================================
# Step 5: Create a 2-level party grouping (Rep vs Dem/Ind)
# ============================================
# Map "Rep" to Rep (red) and everything else (e.g., "Dem", "Ind") to Dem/Ind (blue).
Data3 <- Data3 %>%
mutate(
PartyGroup = if_else(party == "Rep", "Rep", "Dem/Ind")
)
# ============================================
# Step 6: Medians by TopicLabel and PartyGroup
# ============================================
TopicMedians <- Data3 %>%
group_by(TopicLabel, PartyGroup) %>%
summarise(
n_posts = n(),
n_likes = sum(!is.na(X.Likes)),
Median = median(Engagement.Score, na.rm = TRUE),
MedLikes = median(X.Likes, na.rm = TRUE),
.groups = "drop"
)
# ============================================
# Step 7: Diagnostics — missing likes (overall and by group)
# ============================================
likes_missing_total <- sum(is.na(Data3$X.Likes))
LikesMissingByGroup <- Data3 %>%
group_by(TopicLabel, PartyGroup) %>%
summarise(
n_posts = n(),
na_likes = sum(is.na(X.Likes)),
.groups = "drop"
) %>%
arrange(desc(na_likes))
# ============================================
# Step 8: Visualization — Median Likes (MedLikes) by TopicLabel × PartyGroup (plotly)
# ============================================
# Order TopicLabel by overall MedLikes across groups for readability
topic_order <- TopicMedians %>%
group_by(TopicLabel) %>%
summarise(
overall_medlikes = median(MedLikes, na.rm = TRUE),
.groups = "drop"
) %>%
arrange(desc(overall_medlikes)) %>%
pull(TopicLabel)
TopicMedians <- TopicMedians %>%
mutate(TopicLabel = factor(TopicLabel, levels = topic_order))
# Define colors: Rep = red, Dem/Ind = blue
party_colors <- c("Rep" = "#d62728", "Dem/Ind" = "#1f77b4")
fig <- plot_ly()
for (pg in c("Rep", "Dem/Ind")) {
df <- TopicMedians %>%
filter(PartyGroup == pg) %>%
arrange(TopicLabel)
fig <- fig %>%
add_bars(
data = df,
x = ~MedLikes,
y = ~TopicLabel,
name = pg,
orientation = "h",
marker = list(color = party_colors[[pg]]),
text = ~scales::comma(MedLikes),
textposition = "outside",
textfont = list(size = 12),
hoverinfo = "skip"
)
}
fig <- fig %>%
layout(
barmode = "group",
title = list(text = "Post 'Likes' by topic and party"),
xaxis = list(
title = "Median Likes",
rangemode = "tozero",
showline = FALSE,
zeroline = FALSE
),
yaxis = list(
title = ""
),
legend = list(
orientation = "v",
x = 1.02,
y = 1,
xanchor = "left",
yanchor = "top"
),
margin = list(l = 160, r = 140)
)
fig