This report uses scraped Bluesky posts about the two candidates in the 2026 California governor’s race, Xavier Becerra (D) and Steve Hilton (R), to measure each candidate’s share of the conversation and to estimate vote share and the likely winner.
Important: Volume share is not vote share. Bluesky users are not a representative sample of California voters, and a candidate can get many posts for negative reasons. For that reason, the final prediction blends the Bluesky results with a poll average, and the Bluesky-only result is shown separately.
The files Steve_Hilton.csv and
Xavier_Becerra.csv must be in the same folder as this
document.
Some posts appear in both files, so the data is reduced to one row
per post (uri).
hilton_raw <- read_csv("Steve Hilton.csv", show_col_types = FALSE) %>% mutate(source_file = "hilton")
becerra_raw <- read_csv("Xavier Becerra.csv", show_col_types = FALSE) %>% mutate(source_file = "becerra")
posts <- bind_rows(hilton_raw, becerra_raw) %>%
group_by(uri) %>%
summarise(
across(c(text, author_handle, post_data, ends_with("_count")), first),
found_in = paste(sort(unique(source_file)), collapse = "+"),
.groups = "drop"
)
nrow(posts)
## [1] 347
created_at column is cut off in the CSV files, so
the full timestamp is read from post_data.posts <- posts %>%
mutate(
posted_at = as.POSIXct(str_extract(post_data, "\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"),
format = "%Y-%m-%dT%H:%M:%S", tz = "UTC"),
text_lc = str_to_lower(coalesce(text, "")),
b_text = str_count(text_lc, "becerra"),
h_text = str_count(text_lc, "hilton"),
b_n = case_when(b_text + h_text > 0 ~ b_text,
str_detect(found_in, "becerra") ~ 1L, TRUE ~ 0L),
h_n = case_when(b_text + h_text > 0 ~ h_text,
str_detect(found_in, "hilton") ~ 1L, TRUE ~ 0L),
w_becerra = b_n / (b_n + h_n),
w_hilton = h_n / (b_n + h_n),
engagement = like_count + repost_count + reply_count + quote_count
)
range(posts$posted_at, na.rm = TRUE)
## [1] "2026-10-24 13:23:51 UTC" "2026-10-29 21:04:07 UTC"
A simple word-list approach (Bing lexicon) labels each post as positive, negative, or neutral. It cannot detect sarcasm, or whether a negative post is aimed at the candidate or at the opponent.
sentiment_by_post <- posts %>%
select(uri, text_lc) %>%
unnest_tokens(word, text_lc) %>%
inner_join(get_sentiments("bing"), by = "word") %>%
count(uri, sentiment) %>%
pivot_wider(names_from = sentiment, values_from = n, values_fill = 0)
for (col in c("positive", "negative")) {
if (!col %in% names(sentiment_by_post)) sentiment_by_post[[col]] <- 0
}
posts <- posts %>%
left_join(sentiment_by_post, by = "uri") %>%
mutate(
positive = coalesce(positive, 0),
negative = coalesce(negative, 0),
net_sentiment = positive - negative,
tone = case_when(net_sentiment > 0 ~ "positive",
net_sentiment < 0 ~ "negative",
TRUE ~ "neutral")
)
table(posts$tone)
##
## negative neutral positive
## 126 168 53
The same likely-voter polls used in the earlier forecast. Newer and larger polls get more weight. Verify the numbers against the original sources before relying on them.
polls <- tribble(
~end_date, ~n, ~becerra, ~hilton,
"2026-10-20", 800, 55, 39,
"2026-10-19", 1000, 60, 33,
"2026-10-16", 400, 63, 37,
"2026-10-14", 909, 55, 35,
"2026-10-01", 1000, 57, 39,
"2026-09-28", 2906, 48, 38,
"2026-09-20", 4512, 58, 33,
"2026-09-20", 742, 55, 33,
"2026-09-10", 1103, 60, 38
) %>%
mutate(
end_date = as.Date(end_date),
days_out = as.numeric(as.Date("2026-11-03") - end_date),
weight = exp(-days_out / 14) * sqrt(n),
becerra_2p = becerra / (becerra + hilton)
)
poll_becerra <- weighted.mean(polls$becerra_2p, polls$weight)
round(poll_becerra, 3)
## [1] 0.612
Two settings here are assumptions, not measured values. Change them to test how the results respond.
poll_weight: how much to trust polls versus Bluesky (0
to 1).poll_error: typical polling error in vote share.poll_weight <- 0.85
poll_error <- 0.035
n_sims <- 10000
sim_poll <- rnorm(n_sims, mean = poll_becerra, sd = poll_error)
sim_social <- sample(boot, n_sims, replace = TRUE)
sim_blend <- poll_weight * sim_poll + (1 - poll_weight) * sim_social
results <- tibble(
candidate = c("Becerra (D)", "Hilton (R)"),
social_positive_share = c(mean(boot), 1 - mean(boot)),
poll_share = c(poll_becerra, 1 - poll_becerra),
predicted_vote_share = c(mean(sim_blend), 1 - mean(sim_blend)),
low_90 = c(quantile(sim_blend, 0.05), 1 - quantile(sim_blend, 0.95)),
high_90 = c(quantile(sim_blend, 0.95), 1 - quantile(sim_blend, 0.05)),
win_prob = c(mean(sim_blend > 0.5), mean(sim_blend < 0.5))
)
results %>% mutate(across(where(is.numeric), ~ round(.x, 3)))
cat("Predicted winner (polls + Bluesky):",
results$candidate[which.max(results$predicted_vote_share)], "\n")
## Predicted winner (polls + Bluesky): Becerra (D)
cat("Winner by Bluesky positive share alone:",
ifelse(mean(boot) > 0.5, "Becerra (D)", "Hilton (R)"), "\n")
## Winner by Bluesky positive share alone: Hilton (R)
ggplot(tibble(becerra = sim_blend * 100), aes(x = becerra)) +
geom_histogram(bins = 40, fill = "steelblue", color = "white") +
geom_vline(xintercept = 50, linetype = "dashed", color = "red") +
labs(title = "Simulated Becerra two-party vote share",
x = "Vote share (%)", y = "Simulations") +
theme_minimal()
5. Social-Only Vote Share Estimate
The estimate is Becerra’s share of all positive posts. A bootstrap (resampling posts 5,000 times) shows how much the number could move from random sampling alone.