packages <- c("atrrr", "dplyr", "tidyr", "stringr", "tidytext",
"ggplot2", "knitr", "rmarkdown")
missing <- packages[!vapply(packages, requireNamespace, logical(1),
quietly = TRUE)]
if (length(missing)) {
install.packages(missing, repos = "https://cloud.r-project.org")
}
library(atrrr)
library(dplyr)
# Authentication happens interactively, never inside the rendered report.
# Use a Bluesky App Password: https://bsky.app/settings/app-passwords
auth("quynhunguyen.bsky.social")
# Replace dates if the instructor specifies another collection window.
# 'since' is inclusive; 'until' is exclusive. Both candidates use the same window.
since_date <- "2026-10-01"
until_date <- "2026-11-04"
limit_per_candidate <- 1000L
post_Becerra <- search_post(
q = '"Xavier Becerra"', limit = limit_per_candidate,
sort = "latest", since = since_date, until = until_date, lang = "en"
)
post_Hilton <- search_post(
q = '"Steve Hilton"', limit = limit_per_candidate,
sort = "latest", since = since_date, until = until_date, lang = "en"
)
post <- bind_rows(Becerra = post_Becerra, Hilton = post_Hilton,
.id = "search_candidate")
# atrrr normally returns text. Accept the older variable name if present.
if (!"text" %in% names(post) && "post_text" %in% names(post)) {
post <- rename(post, text = post_text)
}
if (!nrow(post)) stop("No posts returned. Try a wider date range.")
if (!"text" %in% names(post)) {
stop("Missing text column. Inspect names(post) before continuing.")
}
# Save the collection, including provenance, for reproducible knitting.
collection <- list(
posts = post,
metadata = list(
since = since_date, until = until_date,
collected_at = format(Sys.time(), tz = "UTC", usetz = TRUE),
limit = limit_per_candidate,
atrrr_version = as.character(packageVersion("atrrr")),
query_Becerra = '"Xavier Becerra"', query_Hilton = '"Steve Hilton"',
language = "en", sort = "latest"
)
)
saveRDS(collection, "California_Election_Collection.rds")
# Flatten list columns for CSV only; retain structured RDS for analysis.
post_flat <- post %>% mutate(across(where(is.list), ~ vapply(
.x, function(x) paste(x, collapse = "; "), character(1)
)))
write.csv(post_flat, "California_Election_Posts.csv", row.names = FALSE,
fileEncoding = "UTF-8")
print(count(post, search_candidate))
print(head(select(post, search_candidate, text), 6))
cat("Files saved in:", getwd(), "\n")
Can the tone of English-language Bluesky posts mentioning Xavier Becerra and Steve Hilton produce an experimental two-candidate election forecast? I use Bing word sentiment to classify posts, select net sentiment as the prediction rule, and bootstrap the resulting share estimates. These percentages are sentiment-based proxies, not estimates calibrated to a representative voter poll.
data_path <- "California_Election_Collection.rds"
if (!file.exists(data_path)) {
stop("Run install-packages and collect-posts manually first. Keep the RDS and Rmd together.")
}
collection <- readRDS(data_path)
post <- collection$posts
meta <- collection$metadata
knitr::kable(tibble(
setting = c("Start (inclusive)", "End (exclusive)", "Collected at",
"Maximum requested per query", "Language", "Search ranking"),
value = as.character(c(meta$since, meta$until, meta$collected_at,
meta$limit, meta$language, meta$sort))
), caption = "Collection settings")
| setting | value |
|---|---|
| Start (inclusive) | 2026-10-01 |
| End (exclusive) | 2026-11-04 |
| Collected at | 2026-11-03 23:31:01 UTC |
| Maximum requested per query | 1000 |
| Language | en |
| Search ranking | latest |
knitr::kable(count(post, search_candidate, name = "posts_returned"))
| search_candidate | posts_returned |
|---|---|
| Becerra | 879 |
| Hilton | 1053 |
knitr::kable(head(post %>% transmute(search_candidate,
text = str_trunc(text, 160)), 6),
caption = "Raw text preview")
| search_candidate | text |
|---|---|
| Becerra | Curious about where Becerra and Hilton overlap and differ on California’s most precious resource? Here’s what the two candidates have said about #CAwater cal… |
| Becerra | And XB has had a good working relationship with current LAPD Chief Jim McDonnell going back to when he was AG. |
“Attorney General Becerra and LA County Sher… | |Becerra |“(Perhaps a distinction without a difference to local TV viewers seeing them side by side).” @melmason.bsky.social
“Imagine being so desperate and clueless… | |Becerra |Xavier Becerra said while he’s hoping to make history as the first Latino to be elected governor of California, the reason he’s running is to make a difference. | |Becerra |California gubernatorial candidate Xavier Becerra and his wife Dr. Carolina Reyes cast their ballots at the Belle Cooledge Community Center in Sacramento on … | |Becerra |Vote 🗳️
#MidTerms2026 #California #VoteBlue2026 #SanDiego #SanFrancisco #OrangeCounty #LongBeach #SanGabrielValley #SanFernandoValley #VenturaCounty #Sou… |
Search results are capped and ranked by recency. Equal request limits do not guarantee equal coverage of each candidate’s conversation. Exact-name queries also miss posts using only surnames, nicknames, or images. API language labels are used to select English because Bing is an English lexicon.
I remove empty text, repeated text, and posts mentioning Paris Hilton or the Hilton hotel brand. As requested, even a post also mentioning a candidate is excluded if it matches this noise rule. This conservative filter can remove some relevant posts, so exclusions are recorded for inspection. Posts mentioning both candidates are excluded because overall tone cannot be assigned to just one.
nonempty <- post %>%
mutate(text = str_squish(as.character(text))) %>%
filter(!is.na(text), text != "")
unique_posts <- nonempty %>% distinct(text, .keep_all = TRUE)
noise_pattern <- regex(
"\\bparis\\s+hilton\\b|\\bhilton\\s+hotels?\\b|\\bhotels?\\s+hilton\\b|\\bhilton\\s+(worldwide|honors|honours|resorts?)\\b",
ignore_case = TRUE
)
classified <- unique_posts %>%
mutate(
hotel_or_paris = str_detect(text, noise_pattern),
becerra = str_detect(text, regex("\\bbecerra\\b", ignore_case = TRUE)),
hilton = str_detect(text, regex("\\bsteve\\s+hilton\\b", ignore_case = TRUE)),
disposition = case_when(
hotel_or_paris ~ "Excluded: Paris Hilton / Hilton hotel brand",
becerra & hilton ~ "Excluded: both candidates",
becerra ~ "Becerra",
hilton ~ "Hilton",
TRUE ~ "Excluded: neither candidate in text"
)
)
audit <- bind_rows(
tibble(stage = c("Raw search results", "After removing empty text",
"After removing duplicate text"),
posts = c(nrow(post), nrow(nonempty), nrow(unique_posts))),
classified %>% count(disposition, name = "posts") %>%
rename(stage = disposition)
)
knitr::kable(audit, caption = "Cleaning audit (stage totals and final categories)")
| stage | posts |
|---|---|
| Raw search results | 1932 |
| After removing empty text | 1919 |
| After removing duplicate text | 1616 |
| Becerra | 452 |
| Excluded: both candidates | 167 |
| Excluded: neither candidate in text | 401 |
| Hilton | 596 |
excluded <- classified %>% filter(!disposition %in% candidate_names) %>%
select(search_candidate, text, disposition)
write.csv(excluded, "California_Election_Excluded.csv", row.names = FALSE)
knitr::kable(head(excluded %>% transmute(
reason = disposition, text = str_trunc(text, 160)
), 6), caption = "Examples of excluded posts")
| reason | text |
|---|---|
| Excluded: neither candidate in text | “(Perhaps a distinction without a difference to local TV viewers seeing them side by side).” @melmason.bsky.social “Imagine being so desperate and clueless y… |
| Excluded: both candidates | But about 3-4 million people are set to vote for Steve Hilton to be Governor of California (and 6-7 million for his opponent Xavier Becerra) if the polls are… |
| Excluded: neither candidate in text | Perhaps he just forgot to endorse her? nypost.com/2026/11/03/u… |
| Excluded: neither candidate in text | calmatters.org/politics/202… |
| Excluded: neither candidate in text | California voters, here is an impartial guide and helper tool: https://calmatters.org/california-voter-guide-2026 Please vote. |
| Excluded: neither candidate in text | Unsure about some of the races? Check out our full ballot guide here or reply with your questions and we’ll answer them! |
clean_data <- classified %>%
filter(disposition %in% candidate_names) %>%
transmute(post_id = row_number(), candidate = disposition, text)
if (!all(candidate_names %in% clean_data$candidate)) {
stop("Both candidates need usable posts. Inspect exclusions or widen the search dates.")
}
knitr::kable(count(clean_data, candidate, name = "usable_posts"))
| candidate | usable_posts |
|---|---|
| Becerra | 452 |
| Hilton | 596 |
knitr::kable(head(clean_data %>% mutate(text = str_trunc(text, 160)), 6))
| post_id | candidate | text |
|---|---|---|
| 1 | Becerra | Curious about where Becerra and Hilton overlap and differ on California’s most precious resource? Here’s what the two candidates have said about #CAwater cal… |
| 2 | Becerra | And XB has had a good working relationship with current LAPD Chief Jim McDonnell going back to when he was AG. “Attorney General Becerra and LA County Sherif… |
| 3 | Becerra | Xavier Becerra said while he’s hoping to make history as the first Latino to be elected governor of California, the reason he’s running is to make a difference. |
| 4 | Becerra | California gubernatorial candidate Xavier Becerra and his wife Dr. Carolina Reyes cast their ballots at the Belle Cooledge Community Center in Sacramento on … |
| 5 | Becerra | Vote 🗳️ #MidTerms2026 #California #VoteBlue2026 #SanDiego #SanFrancisco #OrangeCounty #LongBeach #SanGabrielValley #SanFernandoValley #VenturaCounty #SouthBa… |
| 6 | Becerra | Xavier Becerra fought Trump in court as California’s Attorney General. As HHS Secretary, he defended health care for millions of Americans. Now, he’s running… |
write.csv(clean_data, "California_Election_Clean.csv", row.names = FALSE,
fileEncoding = "UTF-8")
Each Bing positive word receives +1 and each negative word receives -1. A post is positive if its word-score total exceeds zero, negative if below zero, and zero-score otherwise. Zero-score posts include balanced matches and posts with no dictionary matches; these do not establish a neutral political stance. Post IDs are assigned before tokenization, and unmatched posts remain in the data.
tokens <- clean_data %>%
mutate(text = str_remove_all(text, regex("https?://\\S+|www\\.\\S+",
ignore_case = TRUE))) %>%
unnest_tokens(word, text)
matches <- tokens %>% inner_join(get_sentiments("bing"), by = "word")
word_counts <- matches %>% group_by(post_id) %>% summarise(
positive_words = sum(sentiment == "positive"),
negative_words = sum(sentiment == "negative"), .groups = "drop"
)
scores <- clean_data %>% left_join(word_counts, by = "post_id") %>%
mutate(
positive_words = coalesce(positive_words, 0L),
negative_words = coalesce(negative_words, 0L),
score = positive_words - negative_words,
matched_words = positive_words + negative_words,
post_tone = case_when(score > 0 ~ "Positive", score < 0 ~ "Negative",
TRUE ~ "Zero-score"),
tone_value = sign(score)
)
summary_sentiment <- scores %>% group_by(candidate) %>% summarise(
total_posts = n(),
positive_posts = sum(score > 0), negative_posts = sum(score < 0),
zero_score_posts = sum(score == 0),
posts_without_matches = sum(matched_words == 0),
positive_words = sum(positive_words), negative_words = sum(negative_words),
net_word_score = sum(score),
net = mean(tone_value), .groups = "drop"
)
knitr::kable(summary_sentiment, digits = 3,
caption = "Word counts, post classifications, and bounded net sentiment")
| candidate | total_posts | positive_posts | negative_posts | zero_score_posts | posts_without_matches | positive_words | negative_words | net_word_score | net |
|---|---|---|---|---|---|---|---|---|---|
| Becerra | 452 | 153 | 77 | 222 | 170 | 387 | 248 | 139 | 0.168 |
| Hilton | 596 | 166 | 206 | 224 | 147 | 468 | 593 | -125 | -0.067 |
if (!nrow(matches)) {
warning("No Bing matches. The report has no sentiment evidence for a winner.")
}
write.csv(scores, "California_Election_Post_Scores.csv", row.names = FALSE,
fileEncoding = "UTF-8")
write.csv(summary_sentiment, "California_Election_Sentiment.csv", row.names = FALSE)
tone_counts <- scores %>% count(candidate, post_tone) %>%
complete(candidate = candidate_names,
post_tone = c("Positive", "Negative", "Zero-score"), fill = list(n = 0))
p_tone <- ggplot(tone_counts, aes(candidate, n, fill = post_tone)) +
geom_col(position = "dodge") +
scale_fill_manual(values = c(Positive = "#298C83", Negative = "#CD647E",
`Zero-score` = "#A6ADB4")) +
labs(title = "Tone of Posts Mentioning Each Candidate", x = NULL,
y = "Number of posts", fill = "Bing post tone") +
theme_minimal(base_size = 12)
print(p_tone)
ggsave("Bluesky_Post_Tone.png", p_tone, width = 8, height = 5, dpi = 300)
I select net sentiment, rather than volume share, to incorporate both positive and negative post classifications. This is a rule for an exploratory assignment, not evidence that tone reliably measures candidate support.
For each candidate, I calculate:
\[ N_c = \frac{\text{positive posts}_c - \text{negative posts}_c} {\text{all usable posts}_c}. \]
This bounded score ranges from -1 to +1. To obtain an experimental two-candidate vote-share proxy that sums to 100%, I apply the same shift-and-normalize approach illustrated in the reference example:
\[ \widehat V_c = 100\frac{1+N_c}{(1+N_B)+(1+N_H)}. \]
The addition of 1 is an arbitrary modeling choice, not a calibrated relationship between sentiment and votes. If both net scores equal -1, the denominator is zero; the code returns a 50-50 tie by convention. Equal scores also produce a tie.
share_rule <- function(b_net, h_net) {
weights <- c(Becerra = 1 + b_net, Hilton = 1 + h_net)
if (any(!is.finite(weights)) || any(weights < -1e-12)) {
stop("Net sentiment must be finite and between -1 and 1.")
}
if (any(c(b_net, h_net) > 1 + 1e-12)) stop("Net sentiment exceeds 1.")
weights <- pmax(weights, 0)
if (sum(weights) <= .Machine$double.eps) {
return(c(Becerra = 50, Hilton = 50))
}
100 * weights / sum(weights)
}
b_net <- summary_sentiment$net[summary_sentiment$candidate == "Becerra"]
h_net <- summary_sentiment$net[summary_sentiment$candidate == "Hilton"]
predicted_shares <- share_rule(b_net, h_net)
is_tie <- abs(predicted_shares[1] - predicted_shares[2]) < 1e-10
predicted_winner <- if (is_tie) "Tie (no unique winner)" else
names(which.max(predicted_shares))
prediction <- tibble(candidate = names(predicted_shares),
predicted_vote_share = as.numeric(predicted_shares))
knitr::kable(prediction, digits = 2,
caption = "Experimental predicted two-candidate vote share (%)")
| candidate | predicted_vote_share |
|---|---|
| Becerra | 55.6 |
| Hilton | 44.4 |
Prediction from the selected rule: Becerra. The experimental predicted vote shares are Becerra: 55.60% and Hilton: 44.40%. These are two-candidate sentiment proxies; other candidates and turnout are not modeled.
This analysis gives a reproducible way to turn online conversation into an experimental forecast, but the sample is shaped by who uses Bluesky and who chooses to post about politics. Users may include journalists, activists, campaign accounts, and people outside California or ineligible to vote. People who are absent from the platform or remain silent are not represented. Therefore, the data should not be treated as a random sample of California voters.
Bing measures word tone rather than candidate stance. Sarcasm can use positive words to criticize a candidate, while a supportive post can contain negative words about an opponent or a problem the candidate hopes to solve. Negation, quotations, and political context can also change meaning. Reading a sample of posts manually would help evaluate these classification errors; that review has not been performed by this script.
More posts do not necessarily mean more support. A candidate may receive attention because of controversy, criticism, news coverage, or repeated posting by a few accounts. I therefore selected net sentiment instead of volume share, although net sentiment still cannot resolve the difference between attention, tone, and actual voting intention. Duplicate-text removal does not make the remaining posts independent or ensure one post per person.
The bootstrap shows how the chosen rule varies when the observed posts are resampled. It does not remove platform bias, correct sentiment errors, or validate the arbitrary conversion from net sentiment to vote share. Repeated authors and related news events can make the post-level independence assumption unrealistic and the intervals too narrow. An author-cluster bootstrap would be a useful extension. The requested search limits and date window also affect which posts enter the sample. I interpret the predicted winner as the output of this classroom rule and the reported share as an illustrative two-candidate proxy, rather than a reliable election prediction.
The saved RDS preserves the collection used in this report, so knitting does not silently retrieve a different sample. CSV exports include raw/cleaned text and should be kept private for coursework; avoid publishing handles or identifiable examples unnecessarily. Public availability does not imply permission to republish everything.
Method references: atrrr search_post documentation, atrrr authentication, and tidytext sentiment lexicons.
sessionInfo()
## R version 4.5.3 (2026-03-11 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 22631)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## time zone: America/Los_Angeles
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] ggplot2_4.0.3 tidytext_0.4.3 stringr_1.6.0 tidyr_1.3.2 dplyr_1.2.1
##
## loaded via a namespace (and not attached):
## [1] Matrix_1.7-4 gtable_0.3.6 jsonlite_2.0.0 janeaustenr_1.0.0
## [5] compiler_4.5.3 tidyselect_1.2.1 Rcpp_1.1.2 jquerylib_0.1.4
## [9] textshaping_1.0.5 systemfonts_1.3.2 scales_1.4.0 yaml_2.3.12
## [13] fastmap_1.2.0 lattice_0.22-9 R6_2.6.1 labeling_0.4.3
## [17] SnowballC_0.7.1 generics_0.1.4 knitr_1.52 tibble_3.3.1
## [21] RColorBrewer_1.1-3 bslib_0.12.0 pillar_1.11.1 tokenizers_0.3.0
## [25] rlang_1.3.0 cachem_1.1.0 stringi_1.8.9 xfun_0.62
## [29] S7_0.2.2 sass_0.4.10 otel_0.2.0 cli_3.6.6
## [33] withr_3.0.3 magrittr_2.0.5 digest_0.6.39 grid_4.5.3
## [37] rstudioapi_0.19.0 lifecycle_1.0.5 vctrs_0.7.3 evaluate_1.0.5
## [41] glue_1.8.1 farver_2.1.2 ragg_1.5.2 rmarkdown_2.32
## [45] purrr_1.2.2 tools_4.5.3 pkgconfig_2.0.3 htmltools_0.5.9