pub(super) mod episodes;
mod info;
pub(super) mod likes;
pub(super) mod posts;
pub(super) mod rating;
use crate::{
platform::naver::client::posts::Id,
stdx::{
http::{DEFAULT_USER_AGENT, IRetry},
math::MathExt,
},
};
use super::{
Type, Webtoon,
creator::{self, Creator},
errors::{ClientError, CreatorError, WebtoonError},
meta::Genre,
webtoon::{
WebtoonInner,
episode::{Episode, posts::Post},
},
};
use anyhow::Context;
use episodes::Sort;
use info::Info;
use parking_lot::RwLock;
use reqwest::{Response, redirect::Policy};
use std::{str::FromStr, sync::Arc};
use url::Url;
const EPISODES_PER_PAGE: u16 = 20;
#[derive(Debug)]
pub struct ClientBuilder {
builder: reqwest::ClientBuilder,
user_agent: Option<Arc<str>>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClientBuilder {
#[must_use]
pub fn new() -> Self {
let builder = reqwest::Client::builder()
.user_agent(DEFAULT_USER_AGENT)
.use_rustls_tls()
.https_only(true)
.brotli(true);
Self {
builder,
user_agent: None,
}
}
#[must_use]
pub fn user_agent(self, user_agent: &str) -> Self {
Self {
user_agent: Some(user_agent.into()),
builder: self.builder.user_agent(user_agent),
}
}
pub fn build(self) -> Result<Client, ClientError> {
Ok(Client {
user_agent: self.user_agent.clone(),
http: self
.builder
.build()
.map_err(|err| ClientError::Unexpected(err.into()))?,
})
}
}
#[derive(Debug, Clone)]
pub struct Client {
pub(super) http: reqwest::Client,
user_agent: Option<Arc<str>>,
}
impl Client {
#[must_use]
pub fn new() -> Self {
ClientBuilder::new().build().expect("Client::new()")
}
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
}
impl Client {
pub async fn creator(&self, profile: &str) -> Result<Option<Creator>, CreatorError> {
let Some(page) = creator::page(profile, self).await? else {
return Ok(None);
};
Ok(Some(Creator {
client: self.clone(),
profile: Some(profile.into()),
username: page.username.clone(),
page: Arc::new(RwLock::new(Some(page))),
}))
}
pub async fn webtoon(&self, id: u32) -> Result<Option<Webtoon>, WebtoonError> {
let response = self.get_webtoon_json(id).await?;
if response.status() == 404 {
return Ok(None);
}
let info: Info = serde_json::from_str(&response.text().await?) .map_err(|err| WebtoonError::Unexpected(err.into()))?;
let mut genres = Vec::new();
for genre in info.gfp_ad_custom_param.genre_types {
let genre = Genre::from_str(&genre) .map_err(|err| WebtoonError::Unexpected(err.into()))?;
genres.push(genre);
}
if genres.is_empty() {
return Err(WebtoonError::NoGenre);
}
let mut creators = Vec::new();
for creator in info.community_artists {
let profile = match creator.profile_page_url {
Some(url) => Url::parse(&url)
.expect("naver api should only have valid urls")
.path_segments()
.expect("url should have segments for the profile page")
.nth(2)
.map(|profile| profile.to_string()),
None => None,
};
creators.push(Creator {
client: self.clone(),
username: creator.name,
profile,
page: Arc::new(RwLock::new(None)),
});
}
let webtoon = Webtoon {
inner: Arc::new(WebtoonInner {
id,
r#type: Type::from_str(&info.webtoon_level_code)?,
title: info.title_name,
summary: info.synopsis,
thumbnail: info.shared_thumbnail_url,
is_new: info.new,
on_hiatus: info.rest,
is_completed: info.finished,
favorites: info.favorite_count,
schedule: info.publish_day_of_week_list,
genres,
creators,
}),
client: self.clone(),
};
Ok(Some(webtoon))
}
pub async fn webtoon_from_url(&self, url: &str) -> Result<Option<Webtoon>, WebtoonError> {
let url = url::Url::parse(url)?;
let id = url
.query_pairs()
.find(|query| query.0 == "titleId")
.ok_or(WebtoonError::InvalidUrl(
"Naver URL should have a `titleId` query: failed to find one in provided URL.",
))?
.1
.parse::<u32>()
.context("`titleId` query parameter wasn't able to parse into a u32")?;
self.webtoon(id).await
}
}
impl Client {
pub(super) async fn get_creator_page(&self, profile: &str) -> Result<Response, ClientError> {
let url = format!("https://comic.naver.com/community/u/{profile}");
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_webtoon_json(&self, id: u32) -> Result<Response, ClientError> {
let url = format!("https://comic.naver.com/api/article/list/info?titleId={id}");
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_episode_page_html(
&self,
webtoon: &Webtoon,
episode: u16,
) -> Result<Response, ClientError> {
let id = webtoon.id();
let url = format!("https://comic.naver.com/webtoon/detail?titleId={id}&no={episode}");
let client = reqwest::ClientBuilder::new()
.use_rustls_tls()
.https_only(true)
.brotli(true)
.user_agent(
self.user_agent
.as_ref()
.map_or(DEFAULT_USER_AGENT, |user_agent| &**user_agent),
)
.redirect(Policy::none())
.build()
.unwrap();
let response = client.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_episodes_json(
&self,
webtoon: &Webtoon,
page: u16,
sort: Sort,
) -> Result<Response, ClientError> {
let id = webtoon.id();
let url = format!(
"https://comic.naver.com/api/article/list?titleId={id}&page={page}&sort={sort}"
);
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_episode_info_from_json(
&self,
webtoon: &Webtoon,
episode: u16,
) -> Result<Response, ClientError> {
let id = webtoon.id();
let page = episode.in_bucket_of(EPISODES_PER_PAGE);
let url =
format!("https://comic.naver.com/api/article/list?titleId={id}&page={page}&sort=ASC");
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_likes_for_episode(
&self,
episode: &Episode,
) -> Result<Response, ClientError> {
let id = episode.webtoon.id();
let episode = episode.number;
let url =
format!("https://route-like.naver.com/v1/search/contents?q=COMIC[{id}_{episode}]");
Ok(self.http.get(&url).retry().send().await?)
}
pub(super) async fn get_rating_for_episode(
&self,
episode: &Episode,
) -> Result<Response, ClientError> {
let id = episode.webtoon.id();
let episode = episode.number;
let url = format!("https://comic.naver.com/api/userAction/info?titleId={id}&no={episode}");
Ok(self.http.get(&url).retry().send().await?)
}
pub(super) async fn get_posts_for_episode(
&self,
episode: &Episode,
cursor: Option<Id>,
stride: u8,
) -> Result<Response, ClientError> {
let scope = match episode.webtoon.r#type() {
Type::Featured => "webtoon",
Type::BestChallenge | Type::Challenge => "challenge",
};
let webtoon = episode.webtoon.id();
let episode = episode.number;
let cursor = cursor.map_or_else(String::new, |id| id.to_string());
let url = format!(
"https://comic.naver.com/comment/api/community/v2/posts?pageId={scope}_{webtoon}_{episode}&pinRepresentation=none&prevSize=0&nextSize={stride}&cursor={cursor}"
);
let response = self
.http
.get(&url)
.header("Service-Ticket-Id", "comic_webtoon")
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn get_replies_for_post(
&self,
post: &Post,
cursor: Option<Id>,
stride: u8,
) -> Result<Response, ClientError> {
let id = post.id;
let cursor = cursor.map_or_else(String::new, |id| id.to_string());
let url = format!(
"https://comic.naver.com/comment/api/community/v2/post/{id}/child-posts?sort=oldest&prevSize=0&nextSize={stride}&cursor={cursor}"
);
let response = self
.http
.get(&url)
.header("Service-Ticket-Id", "comic_webtoon")
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn get_webtoons_from_creator_page(
&self,
profile: &str,
) -> Result<Response, ClientError> {
let url = format!("https://comic.naver.com/community/api/v1/creator/{profile}/series");
let response = self
.http
.get(&url)
.header("Referer", "https://comic.naver.com/")
.retry()
.send()
.await?;
Ok(response)
}
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}