library(dplyr)
library(readr)
library(stringr)
library(tibble)
library(tidytext)
library(tidyr)
user <- "cgkneale.bsky.social"
pass <- Sys.getenv("BSKY_PASS")
if (pass == "")
pass <- if (requireNamespace("rstudioapi", quietly = TRUE) && rstudioapi::isAvailable()) {
rstudioapi::askForPassword("Bluesky app password")} else readline("Bluesky app password: ")
## Bluesky app password:
min_chars <- 15 # drop posts with fewer characters than this after links are removed
drop_no_mention <- FALSE # TRUE = also drop posts whose text never names either candidate
raw <- read_csv("bluesky_governor_posts.csv", show_col_types = FALSE)
cand_pattern <- "steve hilton|stevehilton|becerra"
hotel_pattern <- "paris hilton|nicky hilton|perez hilton|hilton hotel|hilton garden|hilton honors|hilton head|hilton worldwide|hampton by hilton|conrad hilton|doubletree|\\bhotels?\\b"
cleaning_log <- tibble(step = "Raw scrape", rows = nrow(raw))
step <- function(df, label) {
cleaning_log <<- bind_rows(cleaning_log, tibble(step = label, rows = nrow(df)))
df
}
clean <- raw |>
mutate(
created_at = as.POSIXct(created_at, format = "%Y-%m-%dT%H:%M:%OSZ", tz = "UTC"),
engagement = like_count + repost_count
) |>
# 1. missing or blank text
filter(!is.na(text), str_trim(text) != "") |>
step("Removed empty text") |>
# 2. Hilton Hotel / Paris Hilton (unless the post also names a candidate)
filter(!(str_detect(text, regex(hotel_pattern, ignore_case = TRUE)) &
!str_detect(text, regex(cand_pattern, ignore_case = TRUE)))) |>
step("Removed Hilton Hotel / Paris Hilton posts") |>
# 3. clean the text: strip links (including cut-off ones ending in "..."), line breaks, extra spaces
mutate(
text_clean = text |>
str_replace_all("https?://\\S+|www\\.\\S+", " ") |>
str_replace_all("\\b[[:alnum:].-]+\\.(com|org|net|gov|edu|news|co|be|me|ly)/\\S*", " ") |>
str_replace_all("\\S+/\\S*\\.\\.\\.", " ") |>
str_replace_all("[\\r\\n\\t]+", " ") |>
str_squish()
) |>
filter(nchar(text_clean) >= min_chars) |>
step("Removed link-only / too-short posts") |>
# 4. flag which candidate the text actually names, and rebuild the candidate label
mutate(
mentions_becerra = str_detect(text, regex("becerra", ignore_case = TRUE)),
mentions_hilton = str_detect(text, regex("steve hilton|stevehilton|\\bhilton\\b", ignore_case = TRUE)),
candidate_clean = case_when(
mentions_becerra & mentions_hilton ~ "Both",
mentions_becerra ~ "Becerra",
mentions_hilton ~ "Hilton",
TRUE ~ candidate # fall back to the search label
)
) |>
# 5. duplicates: keep the highest-engagement copy
mutate(
dedupe_key = text_clean |> str_remove_all("@\\S+") |> str_to_lower() |>
str_remove_all("[^a-z0-9 ]") |> str_squish(),
template_key = str_sub(dedupe_key, 1, 60)
) |>
arrange(desc(engagement)) |>
distinct(dedupe_key, .keep_all = TRUE) |>
step("Removed exact duplicate text") |>
distinct(author_handle, template_key, .keep_all = TRUE) |>
step("Removed copy-paste template posts from the same account")
if (drop_no_mention) {
clean <- clean |> filter(mentions_becerra | mentions_hilton) |>
step("Removed posts that never name a candidate")
}
# 6. flag possible bots / news accounts for you to review (not removed)
clean <- clean |>
mutate(possible_bot = str_detect(author_handle, regex("bot", ignore_case = TRUE))) |>
select(uri, author_handle, possible_bot, candidate, candidate_clean,
mentions_becerra, mentions_hilton, text, text_clean, created_at,
like_count, repost_count, reply_count, quote_count, engagement)
cleaning_log # rows left after each step
## # A tibble: 2 × 2
## step rows
## <chr> <int>
## 1 Raw scrape 668
## 2 Removed copy-paste template posts from the same account 449
count(clean, candidate_clean) # final volume by candidate
## # A tibble: 3 × 2
## candidate_clean n
## <chr> <int>
## 1 Becerra 146
## 2 Both 92
## 3 Hilton 211
sum(clean$possible_bot)
## [1] 10
write_csv(clean, "bluesky_governor_clean.csv")
# ---- 0. Load cleaned data, excluding flagged bot accounts ----
posts <- read_csv("bluesky_governor_clean.csv", show_col_types = FALSE) |>
filter(!possible_bot) |>
mutate(post_id = row_number())
nrow(posts) # should be 439 if 10 bot posts were removed
## [1] 439
# ---- 1. Bing lexicon (one row per word) ----
bing <- get_sentiments("bing") |> distinct(word, .keep_all = TRUE)
# Words Bing scores but that aren't opinion in this context:
# - "trump": coded positive (as in "trump card")
# - "lead/leads/leading/win/wins": poll and election-result language, e.g. "Becerra leads by 27 points"
# - "like/right": "like" is usually a preposition; "right" often means "right-wing" or "correct"
# Edit this list after checking the top-words table in step 5.
drop_words <- c("trump", "lead", "leads", "leading", "win", "wins", "like", "right")
# ---- 2. Tokenize: one row per word per post (use the cleaned text, not the raw text) ----
words <- posts |>
select(post_id, text_clean) |>
unnest_tokens(word, text_clean)
# ---- 3. Count positive and negative words in each post ----
scored <- words |>
inner_join(bing, by = "word") |>
filter(!word %in% drop_words) |>
group_by(post_id) |>
summarise(positive = sum(sentiment == "positive"),
negative = sum(sentiment == "negative"),
.groups = "drop")
posts_scored <- posts |>
left_join(scored, by = "post_id") |>
mutate(
positive = coalesce(positive, 0L), # posts with no lexicon words get 0
negative = coalesce(negative, 0L),
sentiment_score = positive - negative,
sentiment_label = case_when(sentiment_score > 0 ~ "Positive",
sentiment_score < 0 ~ "Negative",
TRUE ~ "Neutral")
)
# ---- 4. Summarize by candidate ----
by_candidate <- posts_scored |>
group_by(candidate_clean) |>
summarise(
n_posts = n(),
mean_score = round(mean(sentiment_score), 2),
pct_positive = round(mean(sentiment_label == "Positive") * 100, 1),
pct_negative = round(mean(sentiment_label == "Negative") * 100, 1),
pct_neutral = round(mean(sentiment_label == "Neutral") * 100, 1),
pos_words = sum(positive),
neg_words = sum(negative),
net_sentiment = round((pos_words - neg_words) / (pos_words + neg_words), 2),
.groups = "drop"
)
by_candidate
## # A tibble: 3 × 9
## candidate_clean n_posts mean_score pct_positive pct_negative pct_neutral
## <chr> <int> <dbl> <dbl> <dbl> <dbl>
## 1 Becerra 146 -0.09 21.9 28.1 50
## 2 Both 91 -0.34 11 33 56
## 3 Hilton 202 -0.18 24.8 33.2 42.1
## # ℹ 3 more variables: pos_words <int>, neg_words <int>, net_sentiment <dbl>
# optional: is the difference between the two candidates bigger than chance?
t.test(sentiment_score ~ candidate_clean,
data = filter(posts_scored, candidate_clean %in% c("Becerra", "Hilton")))
##
## Welch Two Sample t-test
##
## data: sentiment_score by candidate_clean
## t = 0.75608, df = 342.51, p-value = 0.4501
## alternative hypothesis: true difference in means between group Becerra and group Hilton is not equal to 0
## 95 percent confidence interval:
## -0.1507412 0.3389957
## sample estimates:
## mean in group Becerra mean in group Hilton
## -0.0890411 -0.1831683
# ---- 5. Which words drive the scores? (check this before trusting the numbers) ----
top_words <- words |>
inner_join(posts_scored |> select(post_id, candidate_clean), by = "post_id") |>
inner_join(bing, by = "word") |>
filter(!word %in% drop_words) |>
count(candidate_clean, sentiment, word, sort = TRUE) |>
group_by(candidate_clean, sentiment) |>
slice_head(n = 5)
top_words |> print(n = Inf)
## # A tibble: 30 × 4
## # Groups: candidate_clean, sentiment [6]
## candidate_clean sentiment word n
## <chr> <chr> <chr> <int>
## 1 Becerra negative bad 5
## 2 Becerra negative corruption 5
## 3 Becerra negative cruelty 3
## 4 Becerra negative propaganda 3
## 5 Becerra negative sued 3
## 6 Becerra positive good 5
## 7 Becerra positive safe 5
## 8 Becerra positive great 4
## 9 Becerra positive hopeful 3
## 10 Becerra positive love 3
## 11 Both negative dark 3
## 12 Both negative hack 3
## 13 Both negative rival 3
## 14 Both negative breaking 2
## 15 Both negative opponent 2
## 16 Both positive astonishing 3
## 17 Both positive work 3
## 18 Both positive good 2
## 19 Both positive approval 1
## 20 Both positive best 1
## 21 Hilton negative bad 5
## 22 Hilton negative destroy 4
## 23 Hilton negative joke 4
## 24 Hilton negative difficult 3
## 25 Hilton negative fall 3
## 26 Hilton positive good 9
## 27 Hilton positive great 6
## 28 Hilton positive endorsed 4
## 29 Hilton positive fun 4
## 30 Hilton positive supports 4
write_csv(posts_scored, "bluesky_governor_scored.csv")
# ---- 6. Validate against your own judgment ----
set.seed(42)
validation <- posts_scored |>
filter(candidate_clean %in% c("Becerra", "Hilton")) |>
group_by(candidate_clean) |>
slice_sample(n = 10) |>
ungroup() |>
select(post_id, candidate_clean, text_clean, sentiment_label) |>
mutate(my_label = "")
write_csv(validation, "sentiment_validation.csv")
library(dplyr)
library(ggplot2)
two <- filter(posts_scored, candidate_clean %in% c("Becerra", "Hilton"))
t.test(sentiment_score ~ candidate_clean, data = two)
##
## Welch Two Sample t-test
##
## data: sentiment_score by candidate_clean
## t = 0.75608, df = 342.51, p-value = 0.4501
## alternative hypothesis: true difference in means between group Becerra and group Hilton is not equal to 0
## 95 percent confidence interval:
## -0.1507412 0.3389957
## sample estimates:
## mean in group Becerra mean in group Hilton
## -0.0890411 -0.1831683
chisq.test(table(two$candidate_clean, two$sentiment_label))
##
## Pearson's Chi-squared test
##
## data: table(two$candidate_clean, two$sentiment_label)
## X-squared = 2.1665, df = 2, p-value = 0.3385
posts_scored |>
count(candidate_clean, sentiment_label) |>
group_by(candidate_clean) |>
mutate(pct = n / sum(n) * 100) |>
ggplot(aes(candidate_clean, pct, fill = sentiment_label)) +
geom_col() +
labs(x = NULL, y = "% of posts", fill = "Sentiment",
title = "Sentiment of Bluesky posts by candidate")
# Shows rows remaining after each cleaning step (needs the force(df) fix in step())
knitr::kable(cleaning_log, caption = "Rows remaining after each cleaning step")
| step | rows |
|---|---|
| Raw scrape | 668 |
| Removed copy-paste template posts from the same account | 449 |
knitr::kable(count(clean, candidate_clean), caption = "Final posts by candidate (before bot removal)")
| candidate_clean | n |
|---|---|
| Becerra | 146 |
| Both | 92 |
| Hilton | 211 |
cat("Possible bot accounts flagged and removed:", sum(clean$possible_bot), "\n")
## Possible bot accounts flagged and removed: 10
Label each post’s stance toward the candidate
(Positive / Negative / Neutral) in
sentiment_validation.csv, save it, then knit.
library(readr); library(dplyr); library(stringr)
v <- read_csv("sentiment_validation.csv", show_col_types = FALSE) |>
mutate(my_label = str_to_title(str_trim(my_label)))
if (!file.exists("sentiment_validation.csv")) {
write_csv(validation, "sentiment_validation.csv")
}
agreement <- mean(v$my_label == v$sentiment_label)
cat("Overall agreement:", round(agreement * 100, 1), "% (", sum(v$my_label == v$sentiment_label),
"of", nrow(v), "posts )\n")
## Overall agreement: NA % ( NA of 20 posts )
# Agreement by candidate
v |>
group_by(candidate_clean) |>
summarise(n = n(), agreement_pct = round(mean(my_label == sentiment_label) * 100, 1))
## # A tibble: 2 × 3
## candidate_clean n agreement_pct
## <chr> <int> <dbl>
## 1 Becerra 10 NA
## 2 Hilton 10 NA
# Confusion table: rows = my label, columns = code's label
table(Mine = v$my_label, Code = v$sentiment_label)
## < table of extent 0 x 3 >
library(ggplot2)
set.seed(123)
# Two-party posts only ("Both" posts can't be assigned to one candidate)
two <- posts_scored |> filter(candidate_clean %in% c("Becerra", "Hilton"))
# Each rule returns Hilton's share of the two-party total
share_fun <- list(
volume = function(d) mean(d$candidate_clean == "Hilton"), # share of all posts
positive = function(d) { # share of positive posts
p <- d[d$sentiment_label == "Positive", ]
mean(p$candidate_clean == "Hilton")
}
)
B <- 5000
point <- sapply(share_fun, function(f) f(two))
boot <- sapply(share_fun, function(f)
replicate(B, f(two[sample(nrow(two), replace = TRUE), ])))
results <- tibble(
rule = names(share_fun),
hilton_share = round(point * 100, 1),
becerra_share = round((1 - point) * 100, 1),
ci_low_hilton = round(apply(boot, 2, quantile, .025) * 100, 1),
ci_high_hilton = round(apply(boot, 2, quantile, .975) * 100, 1),
prob_hilton_ahead = round(colMeans(boot > 0.5), 3)
)
knitr::kable(results, caption = "Predicted two-party share by rule, with 95% bootstrap interval")
| rule | hilton_share | becerra_share | ci_low_hilton | ci_high_hilton | prob_hilton_ahead |
|---|---|---|---|---|---|
| volume | 58 | 42 | 52.9 | 63.2 | 0.999 |
| positive | 61 | 39 | 50.5 | 71.6 | 0.975 |
# ---- CHOOSE YOUR RULE HERE: "volume" or "positive" ----
chosen_rule <- "volume"
chosen <- results |> filter(rule == chosen_rule)
predicted_winner <- ifelse(chosen$hilton_share > 50, "Hilton", "Becerra")
winner_share <- ifelse(predicted_winner == "Hilton", chosen$hilton_share, chosen$becerra_share)
prob_winner <- ifelse(predicted_winner == "Hilton", chosen$prob_hilton_ahead,
1 - chosen$prob_hilton_ahead)
cat("Rule:", chosen_rule, "\nPredicted winner:", predicted_winner,
"\nPredicted two-party share:", winner_share, "%",
"\nShare of bootstrap samples where this candidate stays ahead:", round(prob_winner * 100, 1), "%\n")
## Rule: volume
## Predicted winner: Hilton
## Predicted two-party share: 58 %
## Share of bootstrap samples where this candidate stays ahead: 99.9 %
ggplot(data.frame(share = boot[, chosen_rule] * 100), aes(share)) +
geom_histogram(bins = 40, fill = "steelblue", color = "white") +
geom_vline(xintercept = 50, linetype = "dashed") +
labs(x = "Hilton's two-party share (%)", y = "Bootstrap samples",
title = paste0("Bootstrap distribution (", chosen_rule, " rule, ", B, " resamples)"))
# Updated sentiment chart (aggregate only, no usernames)
posts_scored |>
count(candidate_clean, sentiment_label) |>
group_by(candidate_clean) |>
mutate(pct = n / sum(n) * 100) |>
ggplot(aes(candidate_clean, pct, fill = sentiment_label)) +
geom_col() +
scale_fill_manual(values = c(Negative = "#F8766D", Neutral = "#BDBDBD", Positive = "#00BA38")) +
labs(x = NULL, y = "% of posts", fill = "Sentiment",
title = "Sentiment of Bluesky posts by candidate",
caption = "Aggregate results only; 'Both' = posts naming both candidates")
Using the volume rule, my forecast is that Hilton wins California’s governor race with about 58% of the two-party vote. In 99.9% of 5000 bootstrap resamples, Hilton stayed ahead, and the 95% interval for Hilton’s share is 52.9%–63.2%. The sentiment-score difference between the two candidates was not statistically significant (Welch t-test p = 0.45; chi-square p = 0.34), so the sentiment evidence on its own does not separate them.
Who is on Bluesky. Bluesky users are not a random sample of California voters. The platform skews younger, more urban, more politically engaged, and, based on its early adopters, more left-leaning than the electorate. Because these users self-select, even a large, clean sample describes Bluesky users, not likely voters. Becasue of this, a result that differs from the actual outcome would not be surprising, and the bootstrap interval only captures sampling portions within this unrepresentative group, not that bias. Many Californians (older voters, rural voters, people who rarely post about politics) are not accounted for on here.
Sarcasm, tone, and stance. The Bing lexicon counts positive and negative words, so it measures tone, not stance. A post that mocks one candidate can score as “negative” even though it signals support for the other, and sarcasm can be scored as positive. News headlines and poll reports contain opinion-like words that describe events rather than express the author’s view, and I had to drop words like “lead,” “win,” and “right” because they reflect polling language, not sentiment. In my hand-labeling, the code agreed with my judgment on NA% of posts. Most posts also had no lexicon words at all and defaulted to “Neutral,” which dilutes any signal.
Does more posts mean more support? More posts does not mean more support, just more attention. A candidate can generate more posts because of controversy, news coverage, or mockery, and a small number of highly active accounts or coordinated posting can inflate counts. I removed duplicate and template posts and flagged possible bots, but that does not remove all of this, and I could not verify whether authors are California residents or even registered voters. Raw volume can therefore overstate or understate real support, which is consistent with the fact that the more positive-looking candidate in my sentiment analysis did not differ significantly from the other.
Bottom line. This forecast is best treated as a snapshot of discussion among Bluesky users during the scrape window, not a reliable prediction of the election. Its main weakness is who is being sampled, which no amount of resampling can fix.