By the end of this tutorial you will be able to:
tidytext.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.
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"
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 |
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_patternandrep_patternin Section 1 and re-knit.
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 |
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.
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 |
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()
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_sdkeeps that from mattering much.
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
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 |
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()
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()
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()
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()
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()
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()
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()
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()
platform_bias_mean to 0, then to 0.2, and re-knit. How far
does the combined share move?dem_pattern and rep_pattern. Does the
“Neither” count in Section 3 shrink?bing with
get_sentiments("afinn") (you will need the
textdata package). Do the party tone numbers change?