1. Goal and a word of caution

We have two CSV exports of Bluesky search results: one for posts mentioning Xavier Becerra (D) and one for Steve Hilton (R). The goal is to turn those posts into (a) an estimate of each candidate’s vote share and (b) a win probability.

Bluesky users are not a random sample of California voters. The platform skews left and the posts come from keyword searches. So we treat the social-media signal as a weak, bias-adjusted input and anchor the forecast on polls. Every assumption is labeled so you can change it and see what happens.

2. Read and clean the data

Each row’s record column is a flattened R list: "app.bsky.feed.post; <createdAt>; [lang;] [list(...) blocks;] <text>". The helper below strips the metadata and keeps only the post text.

setwd("C:/Users/zxu3/Documents/R/bluesky")
path_becerra <- "post_Xavier.csv"
path_hilton  <- "post_steve.csv"
# `record` is a flattened R list: "app.bsky.feed.post; <createdAt>; [lang;]
# [list(...) blocks;] <text>". We strip the metadata and keep the text.
clean_record <- function(rec) {
  body <- sub("^app\\.bsky\\.feed\\.post; [^;]+; ?", "", rec)
  repeat {
    old  <- body
    body <- sub("(?s)^list\\(.*?\\)\\)\\); ?", "", body, perl = TRUE)  # embeds/facets
    body <- sub("^[a-z]{2,3}(-[A-Za-z]+)?; ", "", body)               # language tag
    if (identical(old, body)) break
  }
  trimws(body)
}

read_posts <- function(path, source_file) {
  read_csv(path, show_col_types = FALSE) |>
    transmute(
      uri,
      source_file = source_file,
      author_did  = str_extract(author, "^[^;]+"),
      handle      = str_trim(str_split_i(author, ";", 2)),
      created_at  = str_extract(record, "(?<=app\\.bsky\\.feed\\.post; )[^;]+"),
      text        = map_chr(record, clean_record),
      likes = like_count, reposts = repost_count, replies = reply_count,
      quotes = quote_count
    )
}

posts_raw <- bind_rows(
  read_posts(path_becerra, "Becerra search"),
  read_posts(path_hilton,  "Hilton search")
)

# 140 posts appear in both files -> keep each post once
posts <- posts_raw |>
  distinct(uri, .keep_all = TRUE) |>
  filter(nzchar(text))

Now we read both files, stack them, and keep each post once. The two searches overlap, so some posts show up in both files.

posts_raw <- bind_rows(
  read_posts(path_becerra, "Becerra search"),
  read_posts(path_hilton,  "Hilton search")
)

posts <- posts_raw |>
  distinct(uri, .keep_all = TRUE) |>
  filter(nzchar(text))

tibble(raw_rows = nrow(posts_raw), unique_posts = nrow(posts)) |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
raw_rows unique_posts
1000 845

3. Who is each post about?

A simple name match labels each post as about Becerra, Hilton, both, or neither. We also build one engagement number per post.

posts <- posts |>
  mutate(
    about_becerra = str_detect(text, regex("becerra|xavier", ignore_case = TRUE)),
    about_hilton  = str_detect(text, regex("hilton", ignore_case = TRUE)),
    target = case_when(
      about_becerra & about_hilton ~ "Both",
      about_becerra                ~ "Becerra",
      about_hilton                 ~ "Hilton",
      TRUE                         ~ "Neither"
    ),
    engagement = likes + 2 * reposts + replies + quotes
  )

4. Weights: taming bots, news feeds, and heavy posters

One account can post dozens of times. We divide each post’s weight by its author’s post count, then add a small log-scale bonus for engagement. The table shows the heaviest posters so you can eyeball whether they look like bots or news aggregators.

# (a) each author's total weight is capped so one account can't dominate
# (b) engagement adds a little weight, on a log scale
author_counts <- posts |> count(author_did, name = "n_author")

posts <- posts |>
  left_join(author_counts, by = "author_did") |>
  mutate(w = (1 / n_author) * (1 + log1p(engagement)))

posts |>
  count(handle, sort = TRUE) |>
  slice_head(n = 10) |>
  kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
handle n
andyheppelle.bsky.social 74
bluegitana.bsky.social 37
davidsicairos.bsky.social 23
christopretensism.bsky.social 19
nypost.com 17
porcellato.bsky.social 15
bsfavourite.bsky.social 13
plaintanjane.bsky.social 13
judgement-bot.bsky.social 12
aldxyz.bsky.social 9

5. Sentiment and explicit voting intent

We score each post with the bing lexicon (positive vs. negative words, excluding the candidates’ names). We also flag explicit statements such as “voted for Becerra”.

Caution. Many posts are neutral news headlines, so a lexicon is a crude measure of stance. Hand-coding a sample of posts would be a stronger check.

bing <- get_sentiments("bing")

name_words <- c("becerra", "xavier", "hilton", "steve", "california", "governor")

post_sentiment <- posts |>
  select(uri, text) |>
  unnest_tokens(word, text) |>
  filter(!word %in% name_words) |>
  inner_join(bing, by = "word") |>
  count(uri, sentiment) |>
  pivot_wider(names_from = sentiment, values_from = n, values_fill = 0) |>
  mutate(net = (positive - negative) / (positive + negative))

posts <- posts |>
  left_join(post_sentiment, by = "uri") |>
  mutate(
    across(c(positive, negative), ~ replace_na(.x, 0)),
    net  = replace_na(net, 0),
    tone = case_when(net > 0 ~ 1, net < 0 ~ -1, TRUE ~ 0)
  )

# Explicit intent: "vote Hilton", "I voted for Becerra", ...
vote_for <- function(name) {
  regex(as.character(str_glue("(vote[ds]?|voting)( for)?( \\w+){{0,2}} {name}")),
        ignore_case = TRUE)
}
posts <- posts |>
  mutate(
    intent_becerra = str_detect(text, vote_for("(xavier )?becerra")),
    intent_hilton  = str_detect(text, vote_for("(steve )?hilton"))
  )

posts |>
  summarise(intent_becerra = sum(intent_becerra), intent_hilton = sum(intent_hilton)) |>
  kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
intent_becerra intent_hilton
56 21

5.1 Summary by candidate

summary_tbl <- posts |>
  filter(target %in% c("Becerra", "Hilton")) |>
  group_by(target) |>
  summarise(
    posts         = n(),
    weighted_posts = sum(w),
    mean_likes    = mean(likes),
    pct_positive  = mean(tone == 1),
    pct_negative  = mean(tone == -1),
    net_tone      = weighted.mean(tone, w),
    .groups = "drop"
  )
summary_tbl |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
target posts weighted_posts mean_likes pct_positive pct_negative net_tone
Becerra 296 219.608 3.172 0.240 0.307 0.216
Hilton 293 367.467 6.945 0.307 0.300 -0.010

5.2 Posting volume over time

# Daily volume
posts |>
  filter(target %in% c("Becerra", "Hilton")) |>
  mutate(day = ymd(str_sub(created_at, 1, 10), quiet = TRUE)) |>
  drop_na(day) |>   # ~2% of records have no timestamp in the export
  count(day, target) |>
  ggplot(aes(day, n, color = target)) +
  geom_line(linewidth = 1) +
  scale_color_manual(values = c(Becerra = "#2c6fbb", Hilton = "#c0392b")) +
  labs(title = "Bluesky posts per day", x = NULL, y = "Posts", color = NULL) +
  theme_minimal()

6. Bluesky-implied vote share

A post tilts toward Becerra if it is positive about him or negative about Hilton (and the reverse for Hilton). Posts that mention both candidates are ambiguous and are excluded. Because posts by the same author are not independent, the bootstrap resamples authors, not posts.

# A post tilts toward Becerra if it is positive about him OR negative about Hilton
# (and vice versa). Posts mentioning both are ambiguous and excluded here.
tilt_data <- posts |>
  filter(target %in% c("Becerra", "Hilton"), tone != 0) |>
  mutate(
    tilt_becerra = (target == "Becerra" & tone == 1) | (target == "Hilton" & tone == -1)
  )

social_share <- function(d) {
  b <- sum(d$w[d$tilt_becerra])
  h <- sum(d$w[!d$tilt_becerra])
  b / (b + h)
}

point_est <- social_share(tilt_data)

# resample AUTHORS (not posts), because posts by one author are not independent
authors <- unique(tilt_data$author_did)
by_author <- split(tilt_data, tilt_data$author_did)

boot_shares <- map_dbl(1:2000, \(i) {
  picked <- sample(authors, length(authors), replace = TRUE)
  social_share(bind_rows(by_author[picked]))
})

social_sd <- sd(boot_shares)
cat(sprintf("\nBluesky-implied Becerra two-candidate share: %.1f%%  (bootstrap 95%% CI %.1f%% - %.1f%%)\n",
            100 * point_est, 100 * quantile(boot_shares, .025), 100 * quantile(boot_shares, .975)))
## 
## Bluesky-implied Becerra two-candidate share: 57.3%  (bootstrap 95% CI 49.3% - 65.4%)

7. Polls

The three rows below are example inputs taken from poll posts inside your data. Verify them at the original source, or replace them with your preferred polling average.

The poll standard deviation adds a 3-point allowance for house effects and late shifts on top of pure sampling error. That is a judgment call.

polls <- tribble(
  ~pollster,       ~n,   ~becerra, ~hilton,
  "Berkeley IGS",  1477, 61,       34,
  "PPIC",          1086, 61,       37,
  "SurveyUSA",      799, 54,       32
) |>
  mutate(share = becerra / (becerra + hilton))   # two-candidate share

poll_mean <- weighted.mean(polls$share, polls$n)

# Sampling error alone is too optimistic. Add a polling-error term (SD, in share
# points) for house effects and late shifts. 0.03 is a judgment call; change it.
poll_sd <- sqrt(1 / sum(polls$n) * 0.25 + 0.03^2)

8. Combine polls and Bluesky

Bluesky skews left, so we subtract an assumed 10-point bias from the social estimate and give it a large uncertainty. These are assumptions, not estimates. The two sources are then combined with precision weights (a normal-normal model), and Monte Carlo draws give the win probability.

# and check how much the answer moves (sensitivity table below).
platform_bias_mean <- 0.10   # Bluesky overstates Becerra share by ~10 pts (assumed)
platform_bias_sd   <- 0.08

social_adj_mean <- point_est - platform_bias_mean
social_adj_sd   <- sqrt(social_sd^2 + platform_bias_sd^2)

# Precision-weighted (normal-normal) combination
w_poll   <- 1 / poll_sd^2
w_social <- 1 / social_adj_sd^2
post_mean <- (w_poll * poll_mean + w_social * social_adj_mean) / (w_poll + w_social)
post_sd   <- sqrt(1 / (w_poll + w_social))

draws <- rnorm(1e5, post_mean, post_sd)

result <- tibble(
  source              = c("Polls only", "Bluesky only (bias-adjusted)", "Combined"),
  becerra_share       = c(poll_mean, social_adj_mean, post_mean),
  sd                  = c(poll_sd, social_adj_sd, post_sd),
  p_becerra_wins      = pnorm(0.5, becerra_share, sd, lower.tail = FALSE),
  weight_in_combined  = c(NA, NA, NA)
)
result$weight_in_combined[1:2] <- c(w_poll, w_social) / (w_poll + w_social)

result |>
  mutate(across(c(becerra_share, sd, p_becerra_wins, weight_in_combined), ~ round(.x, 3))) |>
  kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
source becerra_share sd p_becerra_wins weight_in_combined
Polls only 0.632 0.031 1.000 0.892
Bluesky only (bias-adjusted) 0.473 0.090 0.384 0.108
Combined 0.615 0.029 1.000 NA
cat(sprintf("\nCombined: Becerra %.1f%% vs Hilton %.1f%% (two-candidate), P(Becerra wins) = %.3f\n",
            100 * post_mean, 100 * (1 - post_mean), mean(draws > 0.5)))
## 
## Combined: Becerra 61.5% vs Hilton 38.5% (two-candidate), P(Becerra wins) = 1.000

9. Sensitivity: how much do the assumptions matter?

sens <- crossing(bias = c(0, .05, .10, .15, .20), poll_err = c(.02, .03, .05)) |>
  mutate(
    ps  = sqrt(1 / sum(polls$n) * 0.25 + poll_err^2),
    ss  = sqrt(social_sd^2 + platform_bias_sd^2),
    m   = ((point_est - bias) / ss^2 + poll_mean / ps^2) / (1 / ss^2 + 1 / ps^2),
    s   = sqrt(1 / (1 / ss^2 + 1 / ps^2)),
    p_becerra_wins = pnorm(0.5, m, s, lower.tail = FALSE),
    becerra_share  = m
  ) |>
  select(bias, poll_err, becerra_share, p_becerra_wins) |>
  mutate(across(c(becerra_share, p_becerra_wins), ~ round(.x, 3)))
sens |> kbl(digits = 3) |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
bias poll_err becerra_share p_becerra_wins
0.00 0.02 0.629 1.000
0.00 0.03 0.626 1.000
0.00 0.05 0.618 0.996
0.05 0.02 0.626 1.000
0.05 0.03 0.621 1.000
0.05 0.05 0.606 0.992
0.10 0.02 0.624 1.000
0.10 0.03 0.615 1.000
0.10 0.05 0.594 0.983
0.15 0.02 0.621 1.000
0.15 0.03 0.610 1.000
0.15 0.05 0.582 0.968
0.20 0.02 0.618 1.000
0.20 0.03 0.605 1.000
0.20 0.05 0.570 0.943

10. Graphs

10.1 Posterior distribution

tibble(share = draws) |>
  ggplot(aes(share)) +
  geom_density(fill = "#2c6fbb", alpha = .4) +
  geom_vline(xintercept = 0.5, linetype = "dashed") +
  scale_x_continuous(labels = scales::percent) +
  labs(title = "Posterior: Becerra two-candidate vote share",
       subtitle = "Polls + bias-adjusted Bluesky signal",
       x = "Becerra share (two-candidate)", y = NULL) +
  theme_minimal()

10.2 Share of voice

Weighting by author and engagement can change who seems to dominate the conversation.

cols <- c(Becerra = "#2c6fbb", Hilton = "#c0392b")
two <- posts |> filter(target %in% c("Becerra", "Hilton"))

# 10a. Share of voice: raw vs. weighted ---------------------------------------
p_voice <- two |>
  group_by(target) |>
  summarise(Raw = n(), Weighted = sum(w), .groups = "drop") |>
  pivot_longer(-target, names_to = "measure", values_to = "value") |>
  group_by(measure) |>
  mutate(share = value / sum(value)) |>
  ggplot(aes(measure, share, fill = target)) +
  geom_col(width = .6) +
  geom_text(aes(label = scales::percent(share, accuracy = 1)),
            position = position_stack(vjust = .5), color = "white", fontface = "bold") +
  scale_fill_manual(values = cols) +
  scale_y_continuous(labels = scales::percent) +
  labs(title = "Share of voice on Bluesky",
       subtitle = "Weighting by author/engagement changes who 'dominates' the conversation",
       x = NULL, y = NULL, fill = NULL) +
  theme_minimal()
p_voice

10.3 Tone of posts

p_tone <- two |>
  mutate(tone_lab = factor(tone, c(-1, 0, 1), c("Negative", "Neutral", "Positive"))) |>
  count(target, tone_lab) |>
  group_by(target) |>
  mutate(pct = n / sum(n)) |>
  ggplot(aes(target, pct, fill = tone_lab)) +
  geom_col(width = .6) +
  geom_text(aes(label = scales::percent(pct, accuracy = 1)),
            position = position_stack(vjust = .5), size = 3.5) +
  scale_fill_manual(values = c(Negative = "#d98880", Neutral = "grey85", Positive = "#82c99b")) +
  scale_y_continuous(labels = scales::percent) +
  labs(title = "Tone of posts about each candidate", x = NULL, y = NULL, fill = NULL) +
  theme_minimal()
p_tone

10.4 Daily tilt toward Becerra

Dots are daily shares (size = number of posts); the line is a 3-day rolling mean.

# tiny rolling mean so no extra package is needed
roll_mean <- function(x, k) {
  map_dbl(seq_along(x), \(i) mean(x[max(1, i - k + 1):i], na.rm = TRUE))
}

# 10c. Daily Bluesky "Becerra tilt" with a 3-day rolling mean ------------------
daily_tilt <- tilt_data |>
  mutate(day = ymd(str_sub(created_at, 1, 10), quiet = TRUE)) |>
  drop_na(day) |>
  group_by(day) |>
  summarise(n = n(), share = sum(w[tilt_becerra]) / sum(w), .groups = "drop") |>
  arrange(day) |>
  mutate(roll = roll_mean(share, 3))

p_trend <- ggplot(daily_tilt, aes(day)) +
  geom_hline(yintercept = .5, linetype = "dashed", color = "grey50") +
  geom_point(aes(y = share, size = n), alpha = .35, color = cols["Becerra"]) +
  geom_line(aes(y = roll), linewidth = 1.1, color = cols["Becerra"]) +
  scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
  labs(title = "Daily Bluesky tilt toward Becerra",
       subtitle = "Dots = daily share (size = posts); line = 3-day rolling mean",
       x = NULL, y = "Becerra share of tilted posts", size = "Posts") +
  theme_minimal()
p_trend

10.5 How uncertain is the Bluesky signal?

p_boot <- tibble(share = boot_shares) |>
  ggplot(aes(share)) +
  geom_histogram(bins = 40, fill = cols["Becerra"], alpha = .6, color = "white") +
  geom_vline(xintercept = point_est, linewidth = 1) +
  geom_vline(xintercept = quantile(boot_shares, c(.025, .975)), linetype = "dashed") +
  geom_vline(xintercept = .5, color = "grey40") +
  scale_x_continuous(labels = scales::percent) +
  labs(title = "How uncertain is the Bluesky signal?",
       subtitle = "2,000 author-level bootstrap resamples; dashed = 95% interval",
       x = "Becerra two-candidate share", y = "Resamples") +
  theme_minimal()
p_boot

10.6 Engagement

A few viral posts drive most of the likes.

p_eng <- two |>
  ggplot(aes(target, likes + 1, fill = target)) +
  geom_boxplot(outlier.alpha = .3, width = .5, show.legend = FALSE) +
  scale_y_log10() +
  scale_fill_manual(values = cols) +
  labs(title = "Likes per post (log scale)",
       subtitle = "A few viral posts drive most engagement",
       x = NULL, y = "Likes + 1") +
  theme_minimal()
p_eng

10.7 Distinctive words

Some of the words on the Becerra side come from a few accounts repeating hashtags. Raise the minimum word count or drop hashtags for a cleaner view.

stop_extra <- c(name_words, "https", "t.co", "amp", "bsky.app", "www", "com", "gov")

word_odds <- two |>
  select(uri, target, text) |>
  unnest_tokens(word, text) |>
  filter(!word %in% c(stop_words$word, stop_extra), !str_detect(word, "^\\d+$"),
         nchar(word) > 2) |>
  count(target, word) |>
  pivot_wider(names_from = target, values_from = n, values_fill = 0) |>
  filter(Becerra + Hilton >= 8) |>
  mutate(
    logodds = log((Becerra + .5) / (sum(Becerra) + .5)) -
              log((Hilton  + .5) / (sum(Hilton)  + .5)),
    leans = if_else(logodds > 0, "Becerra", "Hilton")
  )

p_words <- word_odds |>
  group_by(leans) |>
  slice_max(abs(logodds), n = 12) |>
  ungroup() |>
  ggplot(aes(logodds, reorder(word, logodds), fill = leans)) +
  geom_col(show.legend = FALSE) +
  scale_fill_manual(values = cols) +
  labs(title = "Words that distinguish each candidate's conversation",
       subtitle = "Log-odds ratio (words used >= 8 times)", x = "<- Hilton   |   Becerra ->", y = NULL) +
  theme_minimal()
p_words

10.8 Forest plot: where does each source put Becerra?

p_forest <- bind_rows(
  polls |> transmute(source = pollster, est = share,
                     lo = share - 1.96 * sqrt(share * (1 - share) / n),
                     hi = share + 1.96 * sqrt(share * (1 - share) / n), type = "Poll"),
  tibble(source = "Bluesky (raw)", est = point_est,
         lo = quantile(boot_shares, .025), hi = quantile(boot_shares, .975), type = "Bluesky"),
  tibble(source = "Bluesky (bias-adj.)", est = social_adj_mean,
         lo = social_adj_mean - 1.96 * social_adj_sd,
         hi = social_adj_mean + 1.96 * social_adj_sd, type = "Bluesky"),
  tibble(source = "Combined", est = post_mean,
         lo = post_mean - 1.96 * post_sd, hi = post_mean + 1.96 * post_sd, type = "Combined")
) |>
  mutate(source = fct_inorder(source) |> fct_rev()) |>
  ggplot(aes(est, source, color = type)) +
  geom_vline(xintercept = .5, linetype = "dashed", color = "grey50") +
  geom_pointrange(aes(xmin = lo, xmax = hi), linewidth = .9) +
  scale_x_continuous(labels = scales::percent) +
  scale_color_manual(values = c(Poll = "#444444", Bluesky = "#8e44ad", Combined = "#2c6fbb")) +
  labs(title = "Becerra two-candidate share by source",
       subtitle = "Points = estimate; bars = 95% interval", x = NULL, y = NULL, color = NULL) +
  theme_minimal()
p_forest

10.9 Sensitivity heatmap

p_sens <- sens |>
  ggplot(aes(factor(bias), factor(poll_err), fill = becerra_share)) +
  geom_tile(color = "white") +
  geom_text(aes(label = scales::percent(becerra_share, accuracy = .1))) +
  scale_fill_gradient2(low = "#c0392b", mid = "white", high = "#2c6fbb", midpoint = .5,
                       labels = scales::percent) +
  labs(title = "Sensitivity of the combined forecast",
       x = "Assumed Bluesky pro-Becerra bias", y = "Assumed poll error (SD)",
       fill = "Becerra share") +
  theme_minimal()
p_sens

10.10 Most-engaged posts

p_top <- two |>
  slice_max(engagement, n = 10, with_ties = FALSE) |>
  mutate(label = str_trunc(str_replace_all(text, "\\s+", " "), 70)) |>
  ggplot(aes(engagement, reorder(label, engagement), fill = target)) +
  geom_col() +
  scale_fill_manual(values = cols) +
  labs(title = "Top 10 most-engaged posts", x = "Likes + 2*reposts + replies + quotes",
       y = NULL, fill = NULL) +
  theme_minimal()
p_top

11. Takeaways and limits

  • The polls dominate the combined forecast; Bluesky adds only a small amount of information.
  • Bluesky is not a voter sample, so the platform-bias assumption matters. The sensitivity table shows how little the answer moves across reasonable values.
  • Shares here are two-candidate shares, so undecided and third-party voters are excluded.
  • Lexicon sentiment is a rough proxy for stance. Hand-coded or model-based stance labels would improve the estimate.