Searching Local News Without a Search Engine
Introduction
Building a collection of URLs from Google or DuckDuckGo has become an increasingly brittle endeavor. Search-result markup and access rules change, automated requests get blocked, and endpoints disappear. Commercial search APIs are more stable, but they introduce keys, usage fees, and dependence on changing terms of service.
The loss matters especially for local news. Local outlets
cover school boards, county commissions, rural hospitals,
water systems, fires, festivals, and development disputes that
rarely enter a coherent national stream. The 2026
Local Journalist Index found severe reporting shortages in
70% of U.S. counties, with most counties producing no local
education or health coverage during the period studied. The
local reporting that remains is scattered across individual
outlet sites and difficult to search as a collection. RSS
provides a practical way to bring it together: choose the
publishers, collect what they publish, and search the
resulting material locally. Feed URLs also move or disappear
as publishers change platforms, so the source list has to be
maintained. We combine local-rags-rss,
a directory of local publishers and validated feeds, with
textpress for collection, processing, and
search.
The directory starts with the outlet registry assembled by 3DLNews and the Local Memory Project, then validates inherited feeds, discovers replacements, and reconciles outlet identities. The result is a national set of working RSS and Atom endpoints with county and Census geography. We use it to collect a recent national news snapshot, search RSS descriptions with a BM25 index, narrow results by place, and retrieve full articles for a small RAG example.
A National Directory of Local Feeds
We begin with the validated feed directory produced by
local-rags-rss.
local_rags_path <- "../packages/local-rags-rss"
local_rags <- readr::read_csv(
file.path(local_rags_path, "output/local_rags_rss.csv"),
col_types = readr::cols(fips = readr::col_character())
)Each row pairs a local outlet and validated feed with county and Census geography. The feed URL tells us where to collect recent items; the geographic fields let us map coverage and filter search results by region, state, county, or metro status.
Coverage by Census division
The directory reaches every state and Washington, DC, but it is not evenly distributed. The first map counts validated feeds in the nine Census divisions.
options(tigris_use_cache = TRUE)
conus <- c(state.abb, "DC") |> setdiff(c("AK", "HI"))
states_sf <- tigris::states(
cb = TRUE,
resolution = "20m",
year = 2020
) |>
filter(STUSPS %in% conus) |>
sf::st_transform(5070) |>
select(state_abbr = STUSPS, geometry)
division_counts <- local_rags |>
filter(state_abbr %in% conus) |>
count(census_division, name = "feeds")
division_sf <- states_sf |>
left_join(
local_rags |> distinct(state_abbr, census_division),
by = "state_abbr"
) |>
group_by(census_division) |>
summarise(.groups = "drop") |>
left_join(division_counts, by = "census_division")
division_colors <- c(
"New England" = "#9467bd",
"Middle Atlantic" = "#5ba3a0",
"South Atlantic" = "#DD8E58",
"East North Central" = "#708A81",
"East South Central" = "#C2956E",
"West North Central" = "#B08968",
"West South Central" = "#A8757B",
"Mountain" = "#C48A5A",
"Pacific" = "#7A8B99"
)
ggplot2::ggplot(division_sf) +
ggplot2::geom_sf(ggplot2::aes(fill = census_division), color = "white", linewidth = 0.25) +
ggplot2::geom_sf_text(
ggplot2::aes(label = paste0(census_division, "\n(", feeds, ")")),
color = "white",
size = 2.7,
fontface = "bold"
) +
ggplot2::scale_fill_manual(values = division_colors, guide = "none") +
ggplot2::labs(title = "Validated RSS Feeds by Census Division") +
ggplot2::theme_void() +
ggplot2::theme(plot.title = ggplot2::element_text(size = 16))County-level gaps
Division totals make the collection look denser than it is. County geography shows both the reach of the directory and the large parts of the country for which no validated feed is present.
conus_state_fips <- local_rags |>
filter(state_abbr %in% conus, !is.na(fips)) |>
transmute(STATEFP = stringr::str_sub(fips, 1, 2)) |>
distinct() |>
pull(STATEFP)
counties_sf <- tigris::counties(
cb = TRUE,
resolution = "20m",
year = 2020
) |>
filter(STATEFP %in% conus_state_fips) |>
sf::st_transform(5070) |>
select(fips = GEOID, geometry)
county_counts <- local_rags |>
filter(!is.na(fips)) |>
count(fips, name = "feeds")
counties_sf |>
left_join(county_counts, by = "fips") |>
mutate(has_feed = !is.na(feeds)) |>
ggplot2::ggplot() +
ggplot2::geom_sf(ggplot2::aes(fill = has_feed), color = "white", linewidth = 0.03) +
ggplot2::geom_sf(data = states_sf, fill = NA, color = "white", linewidth = 0.3) +
ggplot2::scale_fill_manual(
values = c(`TRUE` = "#708A81", `FALSE` = "grey85"),
labels = c(`TRUE` = "Has a feed", `FALSE` = "No feed"),
name = NULL
) +
ggplot2::labs(title = "County Coverage in the RSS Directory") +
ggplot2::theme_void() +
ggplot2::theme(plot.title = ggplot2::element_text(size = 16), legend.position = "bottom")Metro and nonmetro outlets
County FIPS connects the directory to the 2023 Rural-Urban Continuum Codes. We use their broad metro/nonmetro distinction rather than all nine codes.
rucc <- readr::read_csv(
file.path(local_rags_path, "config/rucc_2023.csv"),
col_types = readr::cols(fips = readr::col_character())
)
local_rags <- local_rags |>
left_join(
rucc |> select(fips, rucc_2023, metro_status),
by = "fips"
)
local_rags |>
filter(!is.na(metro_status)) |>
summarise(
feeds = n(),
counties = n_distinct(fips),
.by = metro_status
) |>
DT::datatable(rownames = FALSE, options = list(dom = "t"))Metro status describes an outlet’s home county, not the full reach of its reporting. A paper may cover neighboring counties, and one feed may serve several publications. Even so, the distinction supports useful comparisons between metro and nonmetro results.
Build the National RSS Snapshot
textpress::fetch_rss() retrieves recent
entries from every feed in the local directory. The
descriptions arrive with the feeds, so this does not scrape
the linked articles or download their full text.
The collection step is shown below. The analysis uses a saved snapshot so the same set of stories is searched each time the post is rendered.
rss_items <- textpress::fetch_rss(
local_rags$feed_url,
cores = 4
) |>
left_join(
local_rags |>
select(
feed_url, outlet, state_abbr, census_region,
census_division, county, fips, metro_status
),
by = "feed_url"
)The saved snapshot contains 31,385 feed items collected on August 27, 2026.
rss_items <- rss_items |>
mutate(
published_at = lubridate::ymd_hms(published_at, quiet = TRUE),
description = description |>
stringr::str_remove_all("<[^>]+>") |>
stringr::str_remove(stringr::regex("The post .* appeared first on .*", ignore_case = TRUE)) |>
stringr::str_replace_all("&#[0-9]+;|&[A-Za-z]+;", " ") |>
stringr::str_squish()
)A few cleaned feed items look like this.
A Local BM25 Index
textpress also supports search_regex(),
search_dict(),
and search_vector().
Here we use search_index()
and BM25: a good middle ground for exploratory keyword
searches when the wording is not known exactly but the query
terms should still appear in the result. BM25 ranks documents
using the query terms they contain, how often those terms
occur, how unusual the terms are across the collection, and
document length. Here the searchable document is only the
publisher-provided RSS description.
tokens <- search_items |>
transmute(search_id, text = description) |>
textpress::nlp_tokenize_text(
by = "search_id",
id_col = "search_id",
include_spans = FALSE
)
index <- textpress::nlp_index_tokens(
tokens,
stem = TRUE
)Each query calls textpress::search_index()
directly. We join all matching descriptions to the feed
metadata before filtering by geography, then keep the
highest-ranked local results.
show_hits <- function(hits, caption) {
hits |>
mutate(
date = as.Date(published_at),
story = paste0(
'<a href="', url,
'" target="_blank" rel="noopener noreferrer">',
title,
"</a>"
)
) |>
select(date, outlet, state_abbr, county, metro_status, story) |>
DT::datatable(
rownames = FALSE,
escape = FALSE,
caption = caption,
options = list(pageLength = 5, scrollX = TRUE, dom = "tip")
)
}What Is Local News Talking About?
A national search can surface subjects that are locally important but diffuse across thousands of publishers.
county_fair_hits <- textpress::search_index(
index, "county fair livestock",
n = nrow(search_items), stem = TRUE
) |>
left_join(search_items, by = "search_id") |>
slice_head(n = 15)
county_fair_hits |>
show_hits("National results: county fair livestock")The geographic metadata makes more pointed comparisons possible. The same index can ask about wildfire in western states or water across the Mountain division.
wildfire_hits <- textpress::search_index(
index, "wildfire evacuation",
n = nrow(search_items), stem = TRUE
) |>
left_join(search_items, by = "search_id") |>
filter(state_abbr %in% c("CA", "OR", "WA", "ID", "MT", "NM", "AZ", "CO")) |>
slice_head(n = 15)
wildfire_hits |>
show_hits("Western states: wildfire evacuation")water_hits <- textpress::search_index(
index, "water drought irrigation",
n = nrow(search_items), stem = TRUE
) |>
left_join(search_items, by = "search_id") |>
filter(census_division == "Mountain") |>
slice_head(n = 15)
water_hits |>
show_hits("Mountain division: water drought irrigation")Metro status adds another useful comparison. The spread of data centers into rural counties connects a national industry to local questions about infrastructure, land, water, taxes, and public services.
data_center_hits <- textpress::search_index(
index, "data center",
n = nrow(search_items), stem = TRUE
) |>
left_join(search_items, by = "search_id") |>
filter(
metro_status == "Nonmetro",
stringr::str_detect(
paste(title, description),
stringr::regex("\\bdata centers?\\b", ignore_case = TRUE)
)
) |>
slice_head(n = 15)
data_center_hits |>
show_hits("Nonmetro counties: data centers")From descriptions to full articles
Descriptions are enough to find the stories. Once the rural
data-center search has reduced the national collection to a
small set of URLs, textpress::read_urls() can
retrieve the full articles while preserving the RSS metadata
and document IDs.
Full text was available for ten of the 15 selected stories. The rendered post uses that saved article text.
For the RAG step, we keep each article intact and attach its title, outlet, county, state, publication date, author, article URL, and feed URL. The model receives the complete text of each selected article.
A small RAG synthesis
The final step uses ellmer
and OpenAI’s Responses
API to organize the full articles by theme. Counties and
states appear in the prose, while parenthetical citations name
the publishing outlets.
rag_context <- rag_documents |>
transmute(
source = paste0(
"OUTLET: ", outlet, "\n",
"TITLE: ", title, "\n",
"LOCATION: ", county, ", ", state_abbr, "\n",
"PUBLISHED: ", published_at, "\n",
"AUTHOR: ", coalesce(author, "Not listed"), "\n",
"ARTICLE URL: ", url, "\n",
"FEED URL: ", feed_url, "\n",
"FULL ARTICLE:\n", article_text
)
) |>
pull(source) |>
paste(collapse = "\n\n")
chat <- ellmer::chat_openai(
system_prompt = "Answer using the supplied local-news articles.",
model = "gpt-5-mini-2025-08-07",
params = ellmer::params(reasoning_effort = "low"),
echo = "none"
)
rag_answer <- chat$chat(paste(
"Write cohesive prose for publication. Start directly with the topic and",
"its central tension. Organize the main patterns under short Markdown",
"subheadings formatted as `#### Heading`, followed by developed paragraphs.",
"Write",
"location names naturally into sentences and use",
"publishing outlet names for parenthetical citations.\n\n",
rag_context
))LLM-generated synthesis across local reporting
The surge of AI-driven data centers has pitted promises of tax revenue and modernization against concerns about water use, power strain, local control and long-term community costs — a conflict playing out from Jefferson County, Arkansas, to Pahrump, Nevada.
Economic promises versus measurable local gains
Proponents cast data centers as fiscal game-changers: they can generate substantial property tax revenue, support construction jobs and, advocates say, even relieve local tax burdens (McKenzie County Farmer). Research suggests the picture is nuanced. A University of Arkansas Division of Agriculture study modeled 100 direct jobs in three industries and found data centers compare favorably with manufacturing in statewide output, but the size and locality of indirect and induced benefits vary widely — smaller rural counties tend to see a larger share of those ripples leave the community because of limited local suppliers and spending options (Pine Bluff Commercial). Developers also press the point: firms argue data centers have minimal demand for services like schools and emergency response while delivering steady municipal revenue (Lebanon News).
Water, power and the environmental unknowns
Concern about resource use is a recurring theme. In Georgia, the Chamber’s messaging campaign defends data centers’ water and energy footprints even as researchers and river advocates point to large withdrawals and evaporation at power plants that support them; closed-loop cooling reduces water discharge but increases electricity demand, shifting the trade-off rather than eliminating it (Statesboro Herald). Local activists in Russell County worry about daily water needs and potential strain on nearby Lake Bonaventure and well systems after a developer signaled interest in the Moss 3 site (Lebanon News). Some states have responded with tighter standards or pauses to force clearer answers about water and grid impacts (Freestone County Times; Explore Venango).
Local control, zoning and preemptive regulation
Communities have moved quickly to codify how — or whether — data centers may arrive. Hayden in Moffat County, Colorado, rewrote its development code to explicitly permit data centers only in heavy industrial zones and to cap electrical demand, require enclosed facilities, and demand energy and water-supply plans as part of a conditional-use review, reflecting a desire to be “proactive versus reactive” amid coal-plant closures and fiscal risk (Craig Daily Press). Pennsylvania’s executive order tightened statewide permitting and tied tax incentives to environmental and transparency benchmarks, aiming to curb speculative proposals and reinforce local zoning power (Explore Venango). And in Pahrump, Nevada, citizen pressure culminated in a countywide ban after public meetings and outreach exposed distrust of developer claims and opaque outreach tactics (Pahrump Valley Times).
Public distrust, transparency and political backlash
Across regions, skepticism has hardened into political action. Polling shows growing public opposition to new data centers, and community meetings regularly spotlight fears about hidden costs, nondisclosure agreements, and promises that may not materialize (Statesboro Herald; Pahrump Valley Times). Critics call for surety bonds, clearer disclosure of what will be hosted, and limits on the ability of utilities to shift costs to ratepayers; proponents counter that misinformation and fear of the unknown are driving backlash (Statesboro Herald; Pahrump Valley Times).
Developer strategies and state-level responses
Developers and site selectors continue pitching projects to jurisdictions offering power, land and incentives — from Russell County, Virginia, where a firm highlighted a 236-acre site with available utility infrastructure (Lebanon News), to North Dakota boosters urging rapid buildout and local-first energy arrangements (McKenzie County Farmer). States, meanwhile, are charting divergent paths: some impose moratoriums or strict guardrails and conditional incentives (Texas and Pennsylvania examples), while industry-aligned groups organize campaigns to emphasize jobs and revenue (Statesboro Herald). The result is a patchwork of rules that shifts where and how data centers can feasibly locate.
The core trade-off for rural places
For many rural communities, the question comes down to whether short-term financial gains — construction activity, large assessed values and potential tax windfalls — outweigh long-term costs in water, energy, environmental change and community character. That calculus is being made in town halls and county commissions from Jefferson County, Arkansas, to Nye County, Nevada, as residents and officials demand data, impose standards and, in some cases, say no (Pine Bluff Commercial; Pahrump Valley Times; Craig Daily Press). The decisions that follow will shape not just tax rolls but the resilience of local economies and resources for decades to come.
Summary
Together, these tools build a searchable national
collection from known local publishers.
local-rags-rss supplies the publishers and
geography, RSS supplies a recent stream of stories, and
textpress indexes their descriptions on a local
machine. Full articles enter only after a geographic BM25
search has narrowed the collection to a manageable set.
The larger value is visibility. At a time when local reporting is thinning, this collection makes work scattered across hundreds of small outlets easier to find and compare without stripping away its source or geography. It keeps reporting about local institutions, conflicts, and everyday civic life in view, while making patterns across communities easier to see.