Rewrite a search only after X denylists that exact query.

Remember each rejected string so the next search skips the failure.
This commit is contained in:
4cuck committed 2026-10-06 01:44:09 +00:00
1 parent 571f94eea7
commit 48b067736b
8 files changed
+232 -14

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ session-proxies.json
config/teapot.toml
sessions.jsonl
session-limits.json
search-denylist.json
# Documented dummy list only — never a real vendor file
!proxies.example.txt
+15 -9
View File
@@ -290,6 +290,7 @@ pub struct ApiClient {
tid: TidClient,
budget: ClientBudget,
tid_enabled: bool,
denylist: super::search_denylist::SearchDenylist,
}
impl ApiClient {
@@ -308,6 +309,9 @@ impl ApiClient {
let proxies = proxies.map(Arc::new);
let tid = TidClient::new(client.clone(), sessions.clone(), proxies.clone());
let denylist_path = std::env::var("TEAPOT_SEARCH_DENYLIST")
.unwrap_or_else(|_| "search-denylist.json".to_owned());
Self {
client,
sessions,
@@ -315,6 +319,7 @@ impl ApiClient {
tid,
budget: ClientBudget::new(config.config.client_budget),
tid_enabled: !config.config.disable_tid,
denylist: super::search_denylist::SearchDenylist::load(denylist_path),
}
}
@@ -380,15 +385,16 @@ impl ApiClient {
return Ok(());
};
if error.code == 214 {
if error.message.contains("Unknown request cursor") {
return Err(Error::InvalidUrl(
"This page has expired. Go back and try again.".into(),
));
}
if error.message.contains("denylist") {
return Err(Error::InvalidUrl(error.message.clone()));
}
if error.message.contains("QueryDenylistedFailure")
|| error.message.contains("Query is denylisted")
{
return Err(Error::SearchDenylisted);
}
if error.code == 214 && error.message.contains("Unknown request cursor") {
return Err(Error::InvalidUrl(
"This page has expired. Go back and try again.".into(),
));
}
if let Some(twitter_err) = TwitterError::from_code(error.code) {
+59 -1
View File
@@ -3,6 +3,7 @@ use std::{
time::Duration,
};
use super::super::search_denylist::bypass_denylist;
use super::{
AboutAccountData,
AccountContext,
@@ -569,11 +570,41 @@ impl ApiClient {
}
/// Search tweets.
///
/// A query X has already rejected is rewritten before the request. A new
/// rejection is saved and retried once with that rewrite.
pub async fn search(
&self,
query: &str,
cursor: Option<&str>,
product: &str,
) -> Result<Timeline> {
let sent = self.denylist.prepare(query);
match self.fetch_search(&sent, cursor, product).await {
Err(Error::SearchDenylisted) => {
tracing::info!(query = sent, "X denylisted this search, remembering it");
self.denylist.remember(&sent);
let retry = bypass_denylist(&sent);
if retry == sent {
return Err(Error::SearchDenylisted);
}
match self.fetch_search(&retry, cursor, product).await {
Err(Error::SearchDenylisted) => {
self.denylist.remember(&retry);
Err(Error::SearchDenylisted)
},
other => other,
}
},
other => other,
}
}
async fn fetch_search(
&self,
query: &str,
cursor: Option<&str>,
product: &str,
) -> Result<Timeline> {
let data = self
.graphql_request::<SearchTimelineData>(
@@ -598,11 +629,38 @@ impl ApiClient {
Ok(timeline)
}
/// Search users.
/// Search users. A denylisted string is remembered and rewritten the same
/// way a post search is.
pub async fn search_users(
&self,
query: &str,
cursor: Option<&str>,
) -> Result<PaginatedResult<User>> {
let sent = self.denylist.prepare(query);
match self.fetch_user_search(&sent, cursor).await {
Err(Error::SearchDenylisted) => {
tracing::info!(query = sent, "X denylisted this search, remembering it");
self.denylist.remember(&sent);
let retry = bypass_denylist(&sent);
if retry == sent {
return Err(Error::SearchDenylisted);
}
match self.fetch_user_search(&retry, cursor).await {
Err(Error::SearchDenylisted) => {
self.denylist.remember(&retry);
Err(Error::SearchDenylisted)
},
other => other,
}
},
other => other,
}
}
async fn fetch_user_search(
&self,
query: &str,
cursor: Option<&str>,
) -> Result<PaginatedResult<User>> {
let data = self
.graphql_request::<SearchTimelineData>(
+1
View File
@@ -7,6 +7,7 @@ mod filters;
pub mod http;
mod parser;
mod proxy_pool;
mod search_denylist;
pub mod schema;
mod tid;
+131
View File
@@ -0,0 +1,131 @@
//! Queries X's Search Content Control tool has already rejected.
//!
//! The list is learned from `QueryDenylistedFailure` and kept on disk, so the
//! next search for the same string is rewritten before it is sent.
use std::{
collections::HashSet,
fs,
path::{
Path,
PathBuf,
},
sync::{
Arc,
RwLock,
},
};
/// `include:nativeretweets` is what Shitter prepends so the raw query is no
/// longer the string on X's list. Result rows are deduped when rendered.
pub fn bypass_denylist(query: &str) -> String {
let query = query.trim();
if query.is_empty() || query.contains("include:nativeretweets") {
return query.to_owned();
}
format!("include:nativeretweets {query}")
}
#[derive(Clone)]
pub struct SearchDenylist {
path: Arc<PathBuf>,
inner: Arc<RwLock<HashSet<String>>>,
}
impl SearchDenylist {
pub fn load(path: impl Into<PathBuf>) -> Self {
let path = path.into();
let inner = fs::read_to_string(&path)
.ok()
.and_then(|text| serde_json::from_str::<Vec<String>>(&text).ok())
.unwrap_or_default()
.into_iter()
.map(|query| query.trim().to_owned())
.filter(|query| !query.is_empty())
.collect();
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(inner)),
}
}
pub fn contains(&self, query: &str) -> bool {
self.inner
.read()
.is_ok_and(|set| set.contains(query.trim()))
}
/// The string to send. A known-bad query is rewritten unless that rewrite
/// has also been rejected.
pub fn prepare(&self, query: &str) -> String {
let query = query.trim();
if !self.contains(query) {
return query.to_owned();
}
let rewritten = bypass_denylist(query);
if rewritten != query && !self.contains(&rewritten) {
rewritten
} else {
query.to_owned()
}
}
pub fn remember(&self, query: &str) {
let query = query.trim();
if query.is_empty() {
return;
}
let Ok(mut set) = self.inner.write() else {
return;
};
if !set.insert(query.to_owned()) {
return;
}
if self.path.as_os_str().is_empty() {
return;
}
let mut listed = set.iter().cloned().collect::<Vec<_>>();
drop(set);
listed.sort();
let Ok(body) = serde_json::to_string_pretty(&listed) else {
return;
};
let temporary = Path::new(&*self.path).with_extension("json.tmp");
if fs::write(&temporary, body).is_ok() {
let _ = fs::rename(&temporary, &*self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_rejected_query_is_rewritten_on_the_next_search_and_saved() {
let path = std::env::temp_dir().join(format!(
"teapot-search-denylist-{}-{}.json",
std::process::id(),
"remember"
));
let _ = fs::remove_file(&path);
let list = SearchDenylist::load(&path);
assert_eq!(list.prepare("teen"), "teen");
list.remember("teen");
assert_eq!(list.prepare("teen"), "include:nativeretweets teen");
let loaded = SearchDenylist::load(&path);
assert!(loaded.contains("teen"));
assert_eq!(loaded.prepare("teen"), "include:nativeretweets teen");
let _ = fs::remove_file(&path);
}
#[test]
fn a_rewrite_that_was_also_rejected_is_not_sent_again() {
let list = SearchDenylist::load(PathBuf::new());
list.remember("teen");
list.remember("include:nativeretweets teen");
assert_eq!(list.prepare("teen"), "teen");
}
}
+11
View File
@@ -151,6 +151,10 @@ pub enum Error {
#[error("Invalid URL: {0}")]
InvalidUrl(String),
/// X's Search Content Control tool rejected this exact query string.
#[error("Search query denylisted")]
SearchDenylisted,
#[error("HMAC verification failed")]
HmacVerification,
@@ -199,6 +203,13 @@ impl Error {
)
},
Self::InvalidUrl(ref message) => (StatusCode::BAD_REQUEST, "Bad request", message),
Self::SearchDenylisted => {
(
StatusCode::BAD_REQUEST,
"Search blocked",
"X will not run that search.",
)
},
Self::HmacVerification => {
(
StatusCode::FORBIDDEN,
+10 -1
View File
@@ -29,6 +29,8 @@ use crate::{
},
types::{
Profile,
Query as SearchQuery,
QueryKind,
Tweet,
},
views::rss as rss_view,
@@ -197,7 +199,14 @@ async fn search_rss(
return Ok(cached);
}
let timeline = state.api.search(search_query, None, "Latest").await?;
let timeline = state
.api
.search(
&SearchQuery::parse(search_query, QueryKind::Posts).build(),
None,
"Latest",
)
.await?;
let tweets = timeline.content.into_iter().flatten().collect::<Vec<_>>();
let rss = rss_view::render_search_rss(search_query, &tweets, &state.config);
+4 -3
View File
@@ -95,7 +95,8 @@ impl Query {
// Only force-include retweets for from:user searches (a profile-style
// feed). Keyword search would otherwise repeat the same viral tweet
// once per retweeter.
// once per retweeter. A query X has denylisted is rewritten later, and
// only that query, so the extra operator is not on every search.
if !self.from_user.is_empty() && !self.excludes.contains(&"nativeretweets".to_owned()) {
param.push_str("include:nativeretweets ");
}
@@ -338,8 +339,8 @@ mod tests {
#[test]
fn keyword_search_does_not_force_nativeretweets() {
let query = Query::parse("marefair", QueryKind::Posts);
assert!(!query.build().contains("include:nativeretweets"));
let query = Query::parse("teen", QueryKind::Posts);
assert_eq!(query.build(), "teen");
}
#[test]