mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 19:04:35 +00:00
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:
1 parent
571f94eea7
commit
48b067736b
8 files changed
+232
-14
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -7,6 +7,7 @@ mod filters;
|
||||
pub mod http;
|
||||
mod parser;
|
||||
mod proxy_pool;
|
||||
mod search_denylist;
|
||||
pub mod schema;
|
||||
mod tid;
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user