0. What you will learn

By the end of this tutorial you will be able to:

  1. Read and clean messy Bluesky search exports in R.
  2. Label posts as being about Democrats, Republicans, both, or neither.
  3. Down-weight bots and heavy posters so one account cannot dominate.
  4. Score sentiment and explicit voting intent with tidytext.
  5. Turn posts into a Democratic two-party share with an honest, author-level bootstrap confidence interval.
  6. Combine that social signal with generic-ballot polls to get a vote share and a win probability.
  7. Check how much your assumptions matter, and draw ten graphs that tell the story.

A word of caution before we start. Bluesky users are not a random sample of American voters. The platform skews left, and our posts come from keyword searches. So we will 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 watch what happens.

1. Set up your analysis

You need two CSV exports from a Bluesky search: one for posts about Democrats and one for posts about Republicans. Everything you might want to change lives in the chunk below. The rest of the tutorial reads these settings, so you can reuse it for any pair of parties, candidates, or topics.

# ---- Files ------------------------------------------------------------------
dem_file <- "democrats.csv"
rep_file <- "republicans.csv"

# ---- Labels and colors ------------------------------------------------------
dem_label <- "Democrats"
rep_label <- "Republicans"
party_cols <- c(Democrats = "#2c6fbb", Republicans = "#c0392b")

# ---- How we recognize each party in a post (regular expressions) ------------
# Tip: if your search was about specific candidates, add their names here,
# for example "democrat|\\bdems?\\b|jane doe".
dem_pattern <- "democrat|\\bdems?\\b|\\bdnc\\b"
rep_pattern <- "republican|\\bgop\\b|\\brnc\\b"

2. Read and clean the data

Each row’s record column is a flattened R list that looks like this:

"app.bsky.feed.post; <createdAt>; [lang;] [list(...) blocks;] <text>"

The helper below strips the metadata and keeps only the post text. We use stringr rather than base sub() because it handles emoji and other special characters the same way on Windows, macOS, and Linux.

clean_record <- function(rec) {
  # iconv() drops any invalid UTF-8 bytes before we run the regular expressions
  body <- iconv(rec, from = "UTF-8", to = "UTF-8", sub = "")
  body <- str_remove(body, "^app\\.bsky\\.feed\\.post; [^;]+; ?")
  repeat {
    prev <- body
    body <- str_remove(body, "(?s)^list\\(.*?\\)\\)\\); ?")   # embeds / facets
    body <- str_remove(body, "^[a-z]{2,3}(-[A-Za-z]+)?; ")    # language tag
    if (identical(prev, body)) break
  }
  str_trim(body)
}

read_posts <- function(path, source_file) {
  read_csv(path, show_col_types = FALSE, locale = locale(encoding = "UTF-8")) |>
    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
    )
}

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

posts_raw <- map2(c(dem_file, rep_file), c(dem_label, rep_label), read_posts) |>
  list_rbind()

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

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

3. Which party is each post about?

A regular-expression match labels each post as about Democrats, Republicans, both, or neither. We also compute one engagement number per post.

posts <- posts |>
  mutate(
    about_dem = str_detect(text, regex(dem_pattern, ignore_case = TRUE)),
    about_rep = str_detect(text, regex(rep_pattern, ignore_case = TRUE)),
    target = case_when(
      about_dem & about_rep ~ "Both",
      about_dem             ~ dem_label,
      about_rep             ~ rep_label,
      TRUE                  ~ "Neither"
    ),
    engagement = likes + 2 * reposts + replies + quotes
  )

posts |> count(target) |>
  kbl() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
target n
Both 147
Democrats 317
Neither 141
Republicans 346

Check your counts. If “Neither” is large, your patterns are too narrow (or the search matched on words you did not list). Add terms to dem_pattern and rep_pattern in Section 1 and re-knit.

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 lists the heaviest posters so you can check whether they look like bots or news aggregators.

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() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
handle n
carmenaresearch.bsky.social 56
elusivejay.bsky.social 12
johndoffing.bsky.social 7
project-push.tk.gg 6
txt.project-push.tk.gg 6
bstnboy.bsky.social 4
jasminetee777.bsky.social 4
newsrealtime.bsky.social 4
ownfeed.bsky.social 4
ambernicole91.bsky.social 3

5. Sentiment and explicit voting intent

We score each post with the bing lexicon (positive vs. negative words, excluding the party names). We also flag explicit statements such as “I voted Democratic” or “vote GOP”.

bing <- get_sentiments("bing")

name_words <- c("democrat", "democrats", "democratic", "dem", "dems", "dnc",
                "republican", "republicans", "gop", "rnc")

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

intent_regex <- function(party_words) {
  regex(as.character(str_glue("(vote[ds]?|voting)( for)?( the)?( \\w+){{0,2}} ({party_words})")),
        ignore_case = TRUE)
}

posts <- posts |>
  mutate(
    intent_dem = str_detect(text, intent_regex("democrat\\w*|dems?|blue")),
    intent_rep = str_detect(text, intent_regex("republican\\w*|gop|red"))
  )

posts |>
  summarise(intent_dem = sum(intent_dem), intent_rep = sum(intent_rep)) |>
  kbl() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
intent_dem intent_rep
13 7

Caution. Many posts are neutral news headlines, and a lexicon is a crude proxy for stance. Hand-coding a random sample of 200 posts is a good way to check it.

5.1 Summary by party

two <- posts |> filter(target %in% c(dem_label, rep_label))

summary_tbl <- two |>
  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
Democrats 317 482.990 5.347 0.454 0.167 0.333
Republicans 346 625.384 5.012 0.303 0.358 -0.088

5.2 Posting volume over time

two |>
  mutate(day = ymd(str_sub(created_at, 1, 10), quiet = TRUE)) |>
  drop_na(day) |>   # a few records have no timestamp in the export
  count(day, target) |>
  ggplot(aes(day, n, color = target)) +
  geom_line(linewidth = 1) +
  scale_color_manual(values = party_cols) +
  labs(title = "Bluesky posts per day", x = NULL, y = "Posts", color = NULL) +
  theme_minimal()

6. The Bluesky-implied Democratic share

A post tilts Democratic if it is positive about Democrats or negative about Republicans (and the reverse for Republicans). Posts that mention both parties are ambiguous, so we leave them out. Because posts by the same author are not independent, the bootstrap resamples authors, not posts.

tilt_data <- two |>
  filter(tone != 0) |>
  mutate(tilt_dem = (target == dem_label & tone == 1) | (target == rep_label & tone == -1))

social_share <- function(d) {
  d_w <- sum(d$w[d$tilt_dem])
  r_w <- sum(d$w[!d$tilt_dem])
  d_w / (d_w + r_w)
}

point_est <- social_share(tilt_data)

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("Bluesky-implied Democratic two-party share: %.1f%%  (bootstrap 95%% CI %.1f%% - %.1f%%)\n",
            100 * point_est, 100 * quantile(boot_shares, .025), 100 * quantile(boot_shares, .975)))
## Bluesky-implied Democratic two-party share: 64.6%  (bootstrap 95% CI 59.3% - 69.9%)

7. Polls: the generic congressional ballot

For a national comparison of the two parties, the natural polling anchor is the generic congressional ballot, which asks voters whether they will vote for the Democratic or Republican candidate for Congress.

The rows below are example inputs from recent polls and averages found in late October 2026. Please verify each one at its original source and replace them with your own polling average. Where a sample size was not reported, we assumed 1,000.

polls <- tribble(
  ~source,                   ~n,   ~dem,  ~rep,
  "Reuters/Ipsos (Oct 26)",  1164, 53,    44,
  "Economist/YouGov (Sep)",  1000, 52,    38,
  "PollCheck avg (Oct 27)",  1000, 51.1,  42.4
) |>
  mutate(share = dem / (dem + rep))   # Democratic two-party share

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

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

polls |>
  mutate(share = round(share, 3)) |>
  kbl() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
source n dem rep share
Reuters/Ipsos (Oct 26) 1164 53.0 44.0 0.546
Economist/YouGov (Sep) 1000 52.0 38.0 0.578
PollCheck avg (Oct 27) 1000 51.1 42.4 0.547

Note on double counting. Averages already contain individual polls. Mixing both, as we do here for illustration, overstates how much independent information you have. The 3-point allowance in poll_sd keeps that from mattering much.

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.

Here “win” means that Democratic candidates win the national House popular vote (a two-party share above 50%). That is related to, but not the same as, winning a majority of House seats, which depends on district lines.

platform_bias_mean <- 0.10   # Bluesky overstates the Democratic 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)

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"),
  dem_share          = c(poll_mean, social_adj_mean, post_mean),
  sd                 = c(poll_sd, social_adj_sd, post_sd),
  p_dem_wins         = pnorm(0.5, dem_share, sd, lower.tail = FALSE),
  weight_in_combined = c(w_poll, w_social, NA) / (w_poll + w_social)
)

result |>
  mutate(across(c(dem_share, sd, p_dem_wins, weight_in_combined), ~ round(.x, 3))) |>
  kbl() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
source dem_share sd p_dem_wins weight_in_combined
Polls only 0.556 0.031 0.964 0.88
Bluesky only (bias-adjusted) 0.546 0.085 0.706 0.12
Combined 0.555 0.029 0.970 NA
cat(sprintf("Combined: Democrats %.1f%% vs Republicans %.1f%% (two-party), P(Democrats win the popular vote) = %.3f\n",
            100 * post_mean, 100 * (1 - post_mean), mean(draws > 0.5)))
## Combined: Democrats 55.5% vs Republicans 44.5% (two-party), P(Democrats win the popular vote) = 0.970

9. Sensitivity: how much do the assumptions matter?

We rebuild the combined estimate for several values of the assumed Bluesky bias and the assumed poll error.

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_dem_wins = pnorm(0.5, m, s, lower.tail = FALSE),
    dem_share  = m
  ) |>
  select(bias, poll_err, dem_share, p_dem_wins) |>
  mutate(across(c(dem_share, p_dem_wins), ~ round(.x, 3)))

sens |> kbl() |> kable_styling(bootstrap_options = c("striped", "hover"), full_width = FALSE)
bias poll_err dem_share p_dem_wins
0.00 0.02 0.562 0.998
0.00 0.03 0.567 0.989
0.00 0.05 0.580 0.967
0.05 0.02 0.559 0.997
0.05 0.03 0.561 0.981
0.05 0.05 0.567 0.938
0.10 0.02 0.556 0.996
0.10 0.03 0.555 0.970
0.10 0.05 0.554 0.891
0.15 0.02 0.553 0.993
0.15 0.03 0.549 0.953
0.15 0.05 0.540 0.823
0.20 0.02 0.549 0.990
0.20 0.03 0.543 0.929
0.20 0.05 0.527 0.733

10. Ten graphs

10.1 Posterior distribution

tibble(share = draws) |>
  ggplot(aes(share)) +
  geom_density(fill = party_cols[[dem_label]], alpha = .4) +
  geom_vline(xintercept = 0.5, linetype = "dashed") +
  scale_x_continuous(labels = scales::percent) +
  labs(title = "Posterior: Democratic two-party vote share",
       subtitle = "Generic-ballot polls + bias-adjusted Bluesky signal",
       x = "Democratic share (two-party)", y = NULL) +
  theme_minimal()

10.2 Share of voice

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

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 = party_cols) +
  scale_y_continuous(labels = scales::percent) +
  labs(title = "Share of voice on Bluesky", x = NULL, y = NULL, fill = NULL) +
  theme_minimal()

10.3 Tone of posts

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 party", x = NULL, y = NULL, fill = NULL) +
  theme_minimal()

10.4 Daily tilt toward Democrats

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

roll_mean <- function(x, k) {
  map_dbl(seq_along(x), \(i) mean(x[max(1, i - k + 1):i], na.rm = TRUE))
}

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_dem]) / sum(w), .groups = "drop") |>
  arrange(day) |>
  mutate(roll = roll_mean(share, 3)) |>
  ggplot(aes(day)) +
  geom_hline(yintercept = .5, linetype = "dashed", color = "grey50") +
  geom_point(aes(y = share, size = n), alpha = .35, color = party_cols[[dem_label]]) +
  geom_line(aes(y = roll), linewidth = 1.1, color = party_cols[[dem_label]]) +
  scale_y_continuous(labels = scales::percent, limits = c(0, 1)) +
  labs(title = "Daily Bluesky tilt toward Democrats",
       x = NULL, y = "Democratic share of tilted posts", size = "Posts") +
  theme_minimal()

10.5 How uncertain is the Bluesky signal?

tibble(share = boot_shares) |>
  ggplot(aes(share)) +
  geom_histogram(bins = 40, fill = party_cols[[dem_label]], 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 = "2,000 author-level bootstrap resamples",
       subtitle = "Solid = estimate; dashed = 95% interval",
       x = "Democratic two-party share", y = "Resamples") +
  theme_minimal()

10.6 Engagement

A few viral posts drive most of the likes.

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 = party_cols) +
  labs(title = "Likes per post (log scale)", x = NULL, y = "Likes + 1") +
  theme_minimal()

10.7 Distinctive words

Which words best separate the two conversations? We use a log-odds ratio.

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) |>
  rename(d = all_of(dem_label), r = all_of(rep_label)) |>
  filter(d + r >= 8) |>
  mutate(
    logodds = log((d + .5) / (sum(d) + .5)) - log((r + .5) / (sum(r) + .5)),
    leans   = if_else(logodds > 0, dem_label, rep_label)
  )

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 = party_cols) +
  labs(title = "Words that distinguish each party's conversation",
       subtitle = "Log-odds ratio (words used at least 8 times)",
       x = paste0("<- ", rep_label, "   |   ", dem_label, " ->"), y = NULL) +
  theme_minimal()

10.8 Top hashtags by party

two |>
  mutate(tag = str_extract_all(str_to_lower(text), "#\\w+")) |>
  unnest(tag) |>
  count(target, tag) |>
  group_by(target) |>
  slice_max(n, n = 8, with_ties = FALSE) |>
  ungroup() |>
  ggplot(aes(n, reorder_within(tag, n, target), fill = target)) +
  geom_col(show.legend = FALSE) +
  scale_y_reordered() +
  scale_fill_manual(values = party_cols) +
  facet_wrap(~ target, scales = "free_y") +
  labs(title = "Most-used hashtags", x = "Posts", y = NULL) +
  theme_minimal()

10.9 Forest plot: where does each source put the Democrats?

bind_rows(
  polls |> transmute(source, 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 = "Democratic two-party share by source",
       subtitle = "Points = estimate; bars = 95% interval", x = NULL, y = NULL, color = NULL) +
  theme_minimal()

10.10 Sensitivity heatmap

sens |>
  ggplot(aes(factor(bias), factor(poll_err), fill = dem_share)) +
  geom_tile(color = "white") +
  geom_text(aes(label = scales::percent(dem_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-Democratic bias", y = "Assumed poll error (SD)",
       fill = "Democratic share") +
  theme_minimal()

11. Try it yourself

  1. Change the bias. Set platform_bias_mean to 0, then to 0.2, and re-knit. How far does the combined share move?
  2. Widen the vocabulary. Add three words to dem_pattern and rep_pattern. Does the “Neither” count in Section 3 shrink?
  3. Swap the lexicon. Replace bing with get_sentiments("afinn") (you will need the textdata package). Do the party tone numbers change?
  4. Validate. Once results are in, compare the combined share with the actual national House vote. Was the polling anchor or the Bluesky signal closer?

12. Limits to remember

  • The polls dominate the combined estimate; Bluesky adds only a little information.
  • Bluesky is not a voter sample, so the platform-bias assumption matters. The sensitivity table and heatmap show how much.
  • Shares are two-party shares, so independents and third-party voters are excluded.
  • A popular-vote win is not the same as a House-seat majority.
  • Lexicon sentiment is a rough proxy for stance. Hand-coded or model-based stance labels would improve the estimate.