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")

Data Cleaning Audit

# 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")
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)")
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

Validation: My Labels vs. the Bing Lexicon

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 >

Prediction and Bootstrap

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")
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")

Prediction Summary

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.

Reflection: Limits of This Forecast

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.