coomer: creator feeds are a bare array; single-word queries that are creator ids go to the creator
This commit is contained in:
@@ -8,7 +8,7 @@ use wreq::Version;
|
|||||||
use crate::{
|
use crate::{
|
||||||
DbPool,
|
DbPool,
|
||||||
api::ClientVersion,
|
api::ClientVersion,
|
||||||
providers::{Provider, requester_or_default},
|
providers::{Provider, probe_first_archive_with, requester_or_default},
|
||||||
status::{Channel, ChannelOption, FilterOption},
|
status::{Channel, ChannelOption, FilterOption},
|
||||||
util::cache::VideoCache,
|
util::cache::VideoCache,
|
||||||
videos::{ServerOptions, VideoFormat, VideoItem},
|
videos::{ServerOptions, VideoFormat, VideoItem},
|
||||||
@@ -147,6 +147,43 @@ impl CoomerProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single-word query that is an existing creator id (the profile
|
||||||
|
/// endpoint answers 404 otherwise) goes to that creator's posts, trying the
|
||||||
|
/// filtered service first, else keyword search.
|
||||||
|
async fn resolve_keyword(
|
||||||
|
query: &str,
|
||||||
|
service_filter: Option<&str>,
|
||||||
|
options: &ServerOptions,
|
||||||
|
) -> Option<Target> {
|
||||||
|
let name = query.trim().trim_start_matches('@');
|
||||||
|
if name.is_empty()
|
||||||
|
|| !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut services = vec![Service::Onlyfans, Service::Fansly, Service::Candfans];
|
||||||
|
if let Some(preferred) = Self::resolve_service(service_filter) {
|
||||||
|
services.retain(|s| s.as_str() != preferred.as_str());
|
||||||
|
services.insert(0, preferred);
|
||||||
|
}
|
||||||
|
let urls: Vec<String> = services
|
||||||
|
.iter()
|
||||||
|
.map(|s| format!("{BASE_URL}/api/v1/{}/user/{name}/profile", s.as_str()))
|
||||||
|
.collect();
|
||||||
|
let mut requester = requester_or_default(options, CHANNEL_ID, "resolve_keyword");
|
||||||
|
let (index, _) =
|
||||||
|
probe_first_archive_with(&mut requester, &urls, Self::json_headers(), |body| {
|
||||||
|
body.contains("\"id\"")
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Some(Target::Uploader {
|
||||||
|
service: services[index].clone(),
|
||||||
|
username: name.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn json_headers() -> Vec<(String, String)> {
|
fn json_headers() -> Vec<(String, String)> {
|
||||||
vec![
|
vec![
|
||||||
("Referer".to_string(), REFERER.to_string()),
|
("Referer".to_string(), REFERER.to_string()),
|
||||||
@@ -290,13 +327,16 @@ impl CoomerProvider {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let posts: ApiPostsResponse = serde_json::from_str(&body).map_err(|err| {
|
let posts: ApiPostsPayload = serde_json::from_str(&body).map_err(|err| {
|
||||||
Error::from(format!(
|
Error::from(format!(
|
||||||
"json parse failed for {url}: {err}; body[:200]={}",
|
"json parse failed for {url}: {err}; body[:200]={}",
|
||||||
&body.chars().take(200).collect::<String>()
|
&body.chars().take(200).collect::<String>()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
Ok(posts.posts)
|
Ok(match posts {
|
||||||
|
ApiPostsPayload::List(posts) => posts,
|
||||||
|
ApiPostsPayload::Wrapped(wrapped) => wrapped.posts,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch_items(
|
async fn fetch_items(
|
||||||
@@ -543,6 +583,14 @@ struct ApiError {
|
|||||||
error: String,
|
error: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The global feed wraps posts in `{posts: [...]}`; a creator's feed is a bare array.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum ApiPostsPayload {
|
||||||
|
List(Vec<ApiPost>),
|
||||||
|
Wrapped(ApiPostsResponse),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct ApiPostsResponse {
|
struct ApiPostsResponse {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -601,8 +649,13 @@ impl Provider for CoomerProvider {
|
|||||||
.clamp(1, 64);
|
.clamp(1, 64);
|
||||||
|
|
||||||
let service_filter = options.categories.as_deref().or(options.sites.as_deref());
|
let service_filter = options.categories.as_deref().or(options.sites.as_deref());
|
||||||
let target =
|
let mut target =
|
||||||
Self::resolve_target(&sort_value, query.as_deref(), service_filter);
|
Self::resolve_target(&sort_value, query.as_deref(), service_filter);
|
||||||
|
if let Target::Search { query } = &target {
|
||||||
|
if let Some(found) = Self::resolve_keyword(query, service_filter, &options).await {
|
||||||
|
target = found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bump the ignored `Sort` variant warning off by referencing it.
|
// Bump the ignored `Sort` variant warning off by referencing it.
|
||||||
let _ = Sort::New;
|
let _ = Sort::New;
|
||||||
@@ -849,4 +902,18 @@ mod tests {
|
|||||||
let post: ApiPost = serde_json::from_str(json).expect("parse");
|
let post: ApiPost = serde_json::from_str(json).expect("parse");
|
||||||
assert!(CoomerProvider::build_video_item(post).is_none());
|
assert!(CoomerProvider::build_video_item(post).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_bare_array_and_wrapped_post_payloads() {
|
||||||
|
let bare = r#"[{"id":"1","user":"u","service":"onlyfans","title":"t","file":{},"attachments":[]}]"#;
|
||||||
|
match serde_json::from_str::<ApiPostsPayload>(bare).expect("bare array") {
|
||||||
|
ApiPostsPayload::List(posts) => assert_eq!(posts.len(), 1),
|
||||||
|
other => panic!("expected list, got {other:?}"),
|
||||||
|
}
|
||||||
|
let wrapped = r#"{"posts":[{"id":"1"}],"count":1}"#;
|
||||||
|
match serde_json::from_str::<ApiPostsPayload>(wrapped).expect("wrapped") {
|
||||||
|
ApiPostsPayload::Wrapped(w) => assert_eq!(w.posts.len(), 1),
|
||||||
|
other => panic!("expected wrapped, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -644,10 +644,26 @@ pub async fn probe_first_archive(
|
|||||||
urls: &[String],
|
urls: &[String],
|
||||||
referer: &str,
|
referer: &str,
|
||||||
has_items: impl Fn(&str) -> bool,
|
has_items: impl Fn(&str) -> bool,
|
||||||
|
) -> Option<(usize, String)> {
|
||||||
|
probe_first_archive_with(
|
||||||
|
requester,
|
||||||
|
urls,
|
||||||
|
vec![("Referer".to_string(), referer.to_string())],
|
||||||
|
has_items,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`probe_first_archive`] with caller-supplied request headers (JSON APIs
|
||||||
|
/// that demand a special `Accept`, extra tokens, ...).
|
||||||
|
pub async fn probe_first_archive_with(
|
||||||
|
requester: &mut Requester,
|
||||||
|
urls: &[String],
|
||||||
|
headers: Vec<(String, String)>,
|
||||||
|
has_items: impl Fn(&str) -> bool,
|
||||||
) -> Option<(usize, String)> {
|
) -> Option<(usize, String)> {
|
||||||
for (index, url) in urls.iter().enumerate() {
|
for (index, url) in urls.iter().enumerate() {
|
||||||
let headers = vec![("Referer".to_string(), referer.to_string())];
|
if let Ok(Some(body)) = requester.get_probe(url, headers.clone()).await {
|
||||||
if let Ok(Some(body)) = requester.get_probe(url, headers).await {
|
|
||||||
if has_items(&body) {
|
if has_items(&body) {
|
||||||
return Some((index, body));
|
return Some((index, body));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user