pub(super) mod likes;
pub(super) mod posts;
pub mod search;
use crate::stdx::http::{DEFAULT_USER_AGENT, IRetry};
use super::{
Language, Type, Webtoon,
canvas::{self, Sort},
creator::{self, Creator},
errors::{
CanvasError, ClientError, CreatorError, OriginalsError, PostError, SearchError,
WebtoonError,
},
meta::Scope,
originals::{self},
webtoon::episode::{
Episode,
posts::{Post, Reaction},
},
};
use anyhow::{Context, anyhow};
use parking_lot::RwLock;
use posts::id::Id;
use reqwest::Response;
use search::Item;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{collections::HashMap, ops::RangeBounds, str::FromStr, sync::Arc};
#[derive(Debug)]
pub struct ClientBuilder {
builder: reqwest::ClientBuilder,
session: Option<Arc<str>>,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClientBuilder {
#[inline]
#[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,
session: None,
}
}
#[inline]
#[must_use]
pub fn with_session(mut self, session: &str) -> Self {
self.session = Some(Arc::from(session));
self
}
#[inline]
#[must_use]
pub fn user_agent(self, user_agent: &str) -> Self {
let builder = self.builder.user_agent(user_agent);
Self { builder, ..self }
}
pub fn build(self) -> Result<Client, ClientError> {
Ok(Client {
http: self
.builder
.build()
.map_err(|err| ClientError::Unexpected(err.into()))?,
session: self.session,
})
}
}
#[derive(Debug, Clone)]
pub struct Client {
pub(super) http: reqwest::Client,
pub(super) session: Option<Arc<str>>,
}
impl Client {
#[must_use]
pub fn new() -> Self {
ClientBuilder::new().build().expect("Client::new()")
}
#[inline]
#[must_use]
pub fn with_session(session: &str) -> Self {
ClientBuilder::new()
.with_session(session)
.build()
.expect("Client::with_session()")
}
#[inline]
#[must_use]
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
}
impl Client {
pub async fn creator(
&self,
profile: &str,
language: Language,
) -> Result<Option<Creator>, CreatorError> {
if matches!(language, Language::Zh | Language::De | Language::Fr) {
return Err(CreatorError::UnsupportedLanguage);
}
let Some(page) = creator::page(language, profile, self).await? else {
return Ok(None);
};
Ok(Some(Creator {
client: self.clone(),
language,
profile: Some(profile.into()),
username: page.username.clone(),
page: Arc::new(RwLock::new(Some(page))),
}))
}
#[allow(clippy::too_many_lines)]
pub async fn search(&self, query: &str, language: Language) -> Result<Vec<Item>, SearchError> {
if query.is_empty() {
return Ok(Vec::new());
}
let mut webtoons = Vec::new();
let lang = match language {
Language::En => "ENGLISH",
Language::Zh => "TRADITIONAL_CHINESE",
Language::Th => "THAI",
Language::Id => "INDONESIAN",
Language::Es => "SPANISH",
Language::Fr => "FRENCH",
Language::De => "GERMAN",
};
let url = format!(
"https://www.webtoons.com/p/api/community/v1/content/TITLE/GW/search?criteria=KEYWORD_SEARCH&contentSubType=WEBTOON&nextSize=50&language={lang}&query={query}"
);
let response = self.http.get(&url).retry().send().await?;
let api = serde_json::from_str::<search::Api>(&response.text().await?)
.context("Failed to deserialize search api response")?;
let Some(originals) = api.result.webtoon_title_list else {
return Err(SearchError::Unexpected(anyhow!(
"Original search result didnt have `webtoonTitleList` field in result"
)));
};
for data in originals.data {
let id: u32 = data
.content_id
.parse()
.context("Failed to parse webtoon id to u32")?;
let webtoon = Item {
client: self.clone(),
id,
r#type: Type::Original,
title: data.name,
thumbnail: format!("https://swebtoon-phinf.pstatic.net{}", data.thumbnail.path),
creator: data.extra.writer.nickname,
};
webtoons.push(webtoon);
}
let mut next = originals.pagination.next;
while let Some(ref cursor) = next {
let url = format!(
"https://www.webtoons.com/p/api/community/v1/content/TITLE/GW/search?criteria=KEYWORD_SEARCH&contentSubType=WEBTOON&nextSize=50&language={lang}&query={query}&cursor={cursor}"
);
let response = self.http.get(&url).retry().send().await?;
let api = serde_json::from_str::<search::Api>(&response.text().await?)
.context("Failed to deserialize search api response")?;
let Some(originals) = api.result.webtoon_title_list else {
return Err(SearchError::Unexpected(anyhow!(
"Original search result didnt have `webtoonTitleList` field in result"
)));
};
for data in originals.data {
let id: u32 = data
.content_id
.parse()
.context("Failed to parse webtoon id to u32")?;
let webtoon = Item {
client: self.clone(),
id,
r#type: Type::Original,
title: data.name,
thumbnail: format!("https://swebtoon-phinf.pstatic.net{}", data.thumbnail.path),
creator: data.extra.writer.nickname,
};
webtoons.push(webtoon);
}
next = originals.pagination.next;
}
let url = format!(
"https://www.webtoons.com/p/api/community/v1/content/TITLE/GW/search?criteria=KEYWORD_SEARCH&contentSubType=CHALLENGE&nextSize=50&language={lang}&query={query}"
);
let response = self.http.get(&url).retry().send().await?;
let api = serde_json::from_str::<search::Api>(&response.text().await?)
.context("Failed to deserialize search api response")?;
let Some(canvas) = api.result.challenge_title_list else {
return Err(SearchError::Unexpected(anyhow!(
"Canvas search result didnt have `challengeTitleList` field in result"
)));
};
for data in canvas.data {
let id: u32 = data
.content_id
.parse()
.context("Failed to parse webtoon id to u32")?;
let webtoon = Item {
client: self.clone(),
id,
r#type: Type::Canvas,
title: data.name,
thumbnail: format!("https://swebtoon-phinf.pstatic.net{}", data.thumbnail.path),
creator: data.extra.writer.nickname,
};
webtoons.push(webtoon);
}
let mut next = canvas.pagination.next;
while let Some(ref cursor) = next {
let url = format!(
"https://www.webtoons.com/p/api/community/v1/content/TITLE/GW/search?criteria=KEYWORD_SEARCH&contentSubType=CHALLENGE&nextSize=50&language={lang}&query={query}&cursor={cursor}"
);
let response = self.http.get(&url).retry().send().await?;
let api = serde_json::from_str::<search::Api>(&response.text().await?)
.context("Failed to deserialize search api response")?;
let Some(canvas) = api.result.challenge_title_list else {
return Err(SearchError::Unexpected(anyhow!(
"Canvas search result didnt have `challengeTitleList` field in result"
)));
};
for data in canvas.data {
let id: u32 = data
.content_id
.parse()
.context("Failed to parse webtoon id to u32")?;
let webtoon = Item {
client: self.clone(),
id,
r#type: Type::Canvas,
title: data.name,
thumbnail: format!("https://swebtoon-phinf.pstatic.net{}", data.thumbnail.path),
creator: data.extra.writer.nickname,
};
webtoons.push(webtoon);
}
next = canvas.pagination.next;
}
Ok(webtoons)
}
pub async fn originals(&self, language: Language) -> Result<Vec<Webtoon>, OriginalsError> {
originals::scrape(self, language).await
}
pub async fn canvas(
&self,
language: Language,
pages: impl RangeBounds<u16> + Send,
sort: Sort,
) -> Result<Vec<Webtoon>, CanvasError> {
canvas::scrape(self, language, pages, sort).await
}
pub async fn webtoon(&self, id: u32, r#type: Type) -> Result<Option<Webtoon>, WebtoonError> {
let url = format!(
"https://www.webtoons.com/*/{}/*/list?title_no={id}",
match r#type {
Type::Original => "*",
Type::Canvas => "canvas",
}
);
let response = self.http.get(&url).retry().send().await?;
if response.status() == 404 {
return Ok(None);
}
let mut segments = response
.url()
.path_segments()
.ok_or(WebtoonError::InvalidUrl(
"Webtoon url should have segments separated by `/`; this url did not.",
))?;
let segment = segments
.next()
.ok_or(WebtoonError::InvalidUrl(
"Webtoon URL was found to have segments, but for some reason failed to extract that first segment, which should be a language code: e.g `en`",
))?;
let language = Language::from_str(segment)
.context("Failed to parse return URL segment into `Language` enum")?;
let segment = segments.next().ok_or(
WebtoonError::InvalidUrl("Url was found to have segments, but didn't have a second segment, representing the scope of the webtoon.")
)?;
let scope = Scope::from_str(segment) .context("Failed to parse URL scope path to a `Scope`")?;
let slug = segments
.next()
.ok_or( WebtoonError::InvalidUrl( "Url was found to have segments, but didn't have a third segment, representing the slug name of the Webtoon."))?
.to_string();
let webtoon = Webtoon {
client: self.clone(),
id,
language,
scope,
slug: Arc::from(slug),
page: Arc::new(RwLock::new(None)),
};
Ok(Some(webtoon))
}
pub fn webtoon_from_url(&self, url: &str) -> Result<Webtoon, WebtoonError> {
let url = url::Url::parse(url)?;
let mut segments = url.path_segments().ok_or(WebtoonError::InvalidUrl(
"Webtoon url should have segments separated by `/`; this url did not.",
))?;
let segment = segments
.next()
.ok_or(WebtoonError::InvalidUrl(
"Webtoon URL was found to have segments, but for some reason failed to extract that first segment, which should be a language code: e.g `en`",
))?;
let language = Language::from_str(segment)
.context("Failed to parse URL language code into `Language` enum")?;
let segment = segments.next().ok_or(
WebtoonError::InvalidUrl("Url was found to have segments, but didn't have a second segment, representing the scope of the webtoon.")
)?;
let scope = Scope::from_str(segment) .context("Failed to parse URL scope path to a `Scope`")?;
let slug = segments
.next()
.ok_or( WebtoonError::InvalidUrl( "Url was found to have segments, but didn't have a third segment, representing the slug name of the Webtoon."))?
.to_string();
let id = url
.query()
.ok_or(WebtoonError::InvalidUrl(
"Webtoon URL should have a `title_no` query: failed to find one in provided URL.",
))?
.split('=')
.nth(1)
.context("`title_no` should always have a `=` separator")?
.parse::<u32>()
.context("`title_no` query parameter wasn't able to parse into a u32")?;
let webtoon = Webtoon {
client: self.clone(),
language,
scope,
slug: Arc::from(slug),
id,
page: Arc::new(RwLock::new(None)),
};
Ok(webtoon)
}
pub async fn user_info_for_session(&self, session: &str) -> Result<UserInfo, ClientError> {
let response = self
.http
.get("https://www.webtoons.com/en/member/userInfo")
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
let user_info: UserInfo = serde_json::from_str(&response.text().await?).map_err(|err| {
ClientError::Unexpected(anyhow!("failed to deserialize `userInfo` endpoint: {err}"))
})?;
Ok(user_info)
}
#[inline]
#[must_use]
pub fn has_session(&self) -> bool {
self.session.is_some()
}
pub async fn has_valid_session(&self) -> Result<bool, ClientError> {
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let user_info = self.user_info_for_session(session).await?;
Ok(user_info.is_logged_in)
}
}
impl Client {
pub(super) async fn get_originals_page(
&self,
lang: Language,
day: &str,
) -> Result<Response, ClientError> {
let url = format!("https://www.webtoons.com/{lang}/originals/{day}");
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_canvas_page(
&self,
lang: Language,
page: u16,
sort: Sort,
) -> Result<Response, ClientError> {
let url = format!(
"https://www.webtoons.com/{lang}/canvas/list?genreTab=ALL&sortOrder={sort}&page={page}"
);
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_creator_page(
&self,
lang: Language,
profile: &str,
) -> Result<Response, ClientError> {
let url = format!("https://www.webtoons.com/p/community/{lang}/u/{profile}");
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn get_webtoon_page(
&self,
webtoon: &Webtoon,
page: Option<u16>,
) -> Result<Response, ClientError> {
let id = webtoon.id;
let lang = webtoon.language;
let scope = webtoon.scope.as_slug();
let slug = &webtoon.slug;
let url = if let Some(page) = page {
format!("https://www.webtoons.com/{lang}/{scope}/{slug}/list?title_no={id}&page={page}")
} else {
format!("https://www.webtoons.com/{lang}/{scope}/{slug}/list?title_no={id}")
};
let response = self.http.get(&url).retry().send().await?;
Ok(response)
}
pub(super) async fn post_subscribe_to_webtoon(
&self,
webtoon: &Webtoon,
) -> Result<(), ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let session = self.session.as_ref().unwrap();
let mut form = HashMap::new();
form.insert("titleNo", webtoon.id.to_string());
form.insert("currentStatus", false.to_string());
let url = match webtoon.scope {
Scope::Original(_) => "https://www.webtoons.com/setFavorite",
Scope::Canvas => "https://www.webtoons.com/challenge/setFavorite",
};
self.http
.post(url)
.header("Referer", "https://www.webtoons.com/")
.header("Service-Ticket-Id", "epicom")
.header("Cookie", format!("NEO_SES={session}"))
.form(&form)
.retry()
.send()
.await?;
Ok(())
}
pub(super) async fn post_unsubscribe_to_webtoon(
&self,
webtoon: &Webtoon,
) -> Result<(), ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let session = self.session.as_ref().unwrap();
let mut form = HashMap::new();
form.insert("titleNo", webtoon.id.to_string());
form.insert("currentStatus", true.to_string());
let url = match webtoon.scope {
Scope::Original(_) => "https://www.webtoons.com/setFavorite",
Scope::Canvas => "https://www.webtoons.com/challenge/setFavorite",
};
self.http
.post(url)
.header("Referer", "https://www.webtoons.com/")
.header("Service-Ticket-Id", "epicom")
.header("Cookie", format!("NEO_SES={session}"))
.form(&form)
.retry()
.send()
.await?;
Ok(())
}
pub(super) async fn get_episodes_dashboard(
&self,
webtoon: &Webtoon,
page: u16,
) -> Result<Response, ClientError> {
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let id = webtoon.id;
let url = format!(
"https://www.webtoons.com/*/challenge/dashboardEpisode?titleNo={id}&page={page}"
);
let response = self
.http
.get(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn get_stats_dashboard(
&self,
webtoon: &Webtoon,
) -> Result<Response, ClientError> {
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let lang = webtoon.language;
let scope = match webtoon.scope {
Scope::Canvas => "challenge",
Scope::Original(_) => "*",
};
let id = webtoon.id;
let url = format!(r"https://www.webtoons.com/{lang}/{scope}/titleStat?titleNo={id}");
let response = self
.http
.get(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
Ok(response)
}
#[cfg(feature = "rss")]
pub(super) async fn get_rss_for_webtoon(
&self,
webtoon: &Webtoon,
) -> Result<Response, ClientError> {
let id = webtoon.id;
let language = webtoon.language;
let slug = &webtoon.slug;
let scope = match webtoon.scope {
Scope::Original(genre) => genre.as_slug(),
Scope::Canvas => "challenge",
};
let url = format!("https://www.webtoons.com/{language}/{scope}/{slug}/rss?title_no={id}");
let response = self.http.get(url).send().await?;
Ok(response)
}
pub(super) async fn get_episode(
&self,
webtoon: &Webtoon,
episode: u16,
) -> Result<Response, ClientError> {
let id = webtoon.id;
let scope = webtoon.scope.as_slug();
let url = format!(
"https://www.webtoons.com/*/{scope}/*/*/viewer?title_no={id}&episode_no={episode}"
);
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 session = self
.session
.as_ref()
.map(|session| session.as_ref())
.unwrap_or_default();
let scope = match episode.webtoon.scope {
Scope::Original(_) => "w",
Scope::Canvas => "c",
};
let webtoon = episode.webtoon.id;
let episode = episode.number;
let url = format!(
"https://www.webtoons.com/api/v1/like/search/counts?serviceId=LINEWEBTOON&contentIds={scope}_{webtoon}_{episode}"
);
let response = self
.http
.get(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn like_episode(&self, episode: &Episode) -> Result<(), ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let session = self
.session
.as_ref()
.ok_or(ClientError::NoSessionProvided)?;
let webtoon = episode.webtoon.id;
let r#type = episode.webtoon.scope.as_single_letter();
let number = episode.number;
let response = self.get_react_token().await?;
if response.success {
let token = response
.result
.guest_token
.context("`guestToken` should be some if `success` is true")?;
let timestamp = response
.result
.timestamp
.context("`timestamp` should be some if `success` is true")?;
let language = episode.webtoon.language;
let url = format!(
"https://www.webtoons.com/api/v1/like/services/LINEWEBTOON/contents/{type}_{webtoon}_{number}?menuLanguageCode={language}×tamp={timestamp}&guestToken={token}"
);
self.http
.post(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
}
Ok(())
}
pub(super) async fn unlike_episode(&self, episode: &Episode) -> Result<(), ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let session = self
.session
.as_ref()
.ok_or(ClientError::NoSessionProvided)?;
let webtoon = episode.webtoon.id;
let r#type = episode.webtoon.scope.as_single_letter();
let number = episode.number;
let response = self.get_react_token().await?;
if response.success {
let token = response
.result
.guest_token
.context("`guestToken` should be some if `success` is true")?;
let timestamp = response
.result
.timestamp
.context("`timestamp` should be some if `success` is true")?;
let language = episode.webtoon.language;
let url = format!(
"https://www.webtoons.com/api/v1/like/services/LINEWEBTOON/contents/{type}_{webtoon}_{number}?menuLanguageCode={language}×tamp={timestamp}&guestToken={token}"
);
self.http
.delete(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
}
Ok(())
}
pub(super) async fn get_posts_for_episode(
&self,
episode: &Episode,
cursor: Option<Id>,
stride: u8,
) -> Result<Response, ClientError> {
let session = self
.session
.as_ref()
.map(|session| session.as_ref())
.unwrap_or_default();
let scope = match episode.webtoon.scope {
Scope::Original(_) => "w",
Scope::Canvas => "c",
};
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://www.webtoons.com/p/api/community/v2/posts?pageId={scope}_{webtoon}_{episode}&pinRepresentation=none&prevSize=0&nextSize={stride}&cursor={cursor}&withCursor=true"
);
let response = self
.http
.get(&url)
.header("Service-Ticket-Id", "epicom")
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn get_upvotes_and_downvotes_for_post(
&self,
post: &Post,
) -> Result<Response, ClientError> {
let session = self
.session
.as_ref()
.map(|session| session.as_ref())
.unwrap_or_default();
let page_id = format!(
"{}_{}_{}",
match post.episode.webtoon.scope {
Scope::Original(_) => "w",
Scope::Canvas => "c",
},
post.episode.webtoon.id,
post.episode.number
);
let url = format!(
"https://www.webtoons.com/p/api/community/v2/reaction/post_like/channel/{page_id}/content/{}/emotion/count",
post.id
);
let response = self
.http
.get(&url)
.header("Service-Ticket-Id", "epicom")
.header("Cookie", format!("NEO_SES={session}"))
.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 session = self
.session
.as_ref()
.map(|session| session.as_ref())
.unwrap_or_default();
let post_id = post.id;
let cursor = cursor.map_or_else(String::new, |id| id.to_string());
let url = format!(
"https://www.webtoons.com/p/api/community/v2/post/{post_id}/child-posts?sort=oldest&displayBlindCommentAsService=false&prevSize=0&nextSize={stride}&cursor={cursor}&withCursor=false"
);
let response = self
.http
.get(&url)
.header("Service-Ticket-Id", "epicom")
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
Ok(response)
}
pub(super) async fn post_reply(
&self,
post: &Post,
body: &str,
is_spoiler: bool,
) -> Result<(), ClientError> {
let page_id = format!(
"{}_{}_{}",
match post.episode.webtoon.scope {
Scope::Original(_) => "w",
Scope::Canvas => "c",
},
post.episode.webtoon.id,
post.episode.number
);
let parent_id = post.id.to_string();
let spoiler_filter = if is_spoiler { "ON" } else { "OFF" };
let body = json![
{
"pageId": page_id,
"parentId": parent_id,
"settings": { "reply": "OFF", "reaction": "ON", "spoilerFilter": spoiler_filter },
"title":"",
"body": body
}
];
let token = self.get_api_token().await?;
let session = self
.session
.as_ref()
.map(|session| session.as_ref())
.ok_or(ClientError::NoSessionProvided)?;
self.http
.post("https://www.webtoons.com/p/api/community/v2/post")
.json(&body)
.header("Api-Token", token.clone())
.header("Cookie", format!("NEO_SES={session}"))
.header("Service-Ticket-Id", "epicom")
.retry()
.send()
.await?;
Ok(())
}
pub(super) async fn delete_post(&self, post: &Post) -> Result<(), PostError> {
let token = self.get_api_token().await?;
let session = self
.session
.as_ref()
.map(|session| session.as_ref())
.ok_or(ClientError::NoSessionProvided)?;
self.http
.delete(format!(
"https://www.webtoons.com/p/api/community/v2/post/{}",
post.id
))
.header("Api-Token", token.clone())
.header("Cookie", format!("NEO_SES={session}"))
.header("Service-Ticket-Id", "epicom")
.retry()
.send()
.await?;
Ok(())
}
pub(super) async fn put_react_to_post(
&self,
post: &Post,
reaction: Reaction,
) -> Result<(), PostError> {
let page_id = format!(
"{}_{}_{}",
match post.episode.webtoon.scope {
Scope::Original(_) => "w",
Scope::Canvas => "c",
},
post.episode.webtoon.id,
post.episode.number
);
let url = match reaction {
Reaction::Upvote => format!(
"https://www.webtoons.com/p/api/community/v2/reaction/post_like/channel/{page_id}/content/{}/emotion/like",
post.id
),
Reaction::Downvote => format!(
"https://www.webtoons.com/p/api/community/v2/reaction/post_like/channel/{page_id}/content/{}/emotion/dislike",
post.id
),
Reaction::None => unreachable!("Should never be used with `Reaction::None`"),
};
let token = self.get_api_token().await?;
let session = self
.session
.as_ref()
.map(|session| session.as_ref())
.ok_or(ClientError::NoSessionProvided)?;
self.http
.put(&url)
.header("Service-Ticket-Id", "epicom")
.header("Referer", "https://www.webtoons.com/")
.header("Cookie", format!("NEO_SES={session}"))
.header("Api-Token", token.clone())
.retry()
.send()
.await?;
Ok(())
}
pub(super) async fn get_user_info_for_webtoon(
&self,
webtoon: &Webtoon,
) -> Result<WebtoonUserInfo, ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let url = match webtoon.scope {
Scope::Original(_) => format!(
"https://www.webtoons.com/getTitleUserInfo?titleNo={}",
webtoon.id
),
Scope::Canvas => {
format!(
"https://www.webtoons.com/canvas/getTitleUserInfo?titleNo={}",
webtoon.id
)
}
};
let response = self
.http
.get(&url)
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
let text = response.text().await?;
let title_user_info = serde_json::from_str(&text).context(text)?;
Ok(title_user_info)
}
async fn get_react_token(&self) -> Result<ReactToken, ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let response = self
.http
.get("https://www.webtoons.com/api/v1/like/react-token")
.header("Cookie", format!("NEO_SES={session}"))
.header("Referer", "https://www.webtoons.com")
.retry()
.send()
.await?;
let text = response.text().await?;
let api_token = serde_json::from_str::<ReactToken>(&text).context(text)?;
Ok(api_token)
}
pub(super) async fn get_api_token(&self) -> Result<String, ClientError> {
if !self.has_valid_session().await? {
return Err(ClientError::InvalidSession);
};
let Some(session) = &self.session else {
return Err(ClientError::NoSessionProvided);
};
let response = self
.http
.get("https://www.webtoons.com/p/api/community/v1/api-token")
.header("Cookie", format!("NEO_SES={session}"))
.retry()
.send()
.await?;
let text = response.text().await?;
let api_token = serde_json::from_str::<ApiToken>(&text).context(text)?;
Ok(api_token.result.token)
}
}
impl Default for Client {
fn default() -> Self {
Self::new()
}
}
#[derive(Deserialize, Debug)]
pub struct UserInfo {
#[serde(rename = "loginUser")]
is_logged_in: bool,
#[serde(rename = "nickname")]
username: Option<String>,
#[serde(rename = "profileUrl")]
profile: Option<String>,
}
impl UserInfo {
#[inline]
pub fn is_logged_in(&self) -> bool {
self.is_logged_in
}
#[inline]
pub fn username(&self) -> Option<&str> {
self.username.as_deref()
}
#[inline]
pub fn profile(&self) -> Option<&str> {
self.profile.as_deref()
}
}
#[allow(unused)]
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
pub(super) struct WebtoonUserInfo {
author: bool,
pub(super) favorite: bool,
}
impl WebtoonUserInfo {
pub fn is_webtoon_creator(&self) -> bool {
self.author
}
#[allow(unused)]
pub fn did_rate(&self) -> bool {
self.favorite
}
}
#[allow(unused)]
#[derive(Deserialize, Debug)]
pub(super) struct ApiToken {
status: String,
result: Token,
}
#[derive(Deserialize, Debug)]
pub(super) struct Token {
token: String,
}
#[derive(Debug, Serialize, Deserialize)]
struct ReactToken {
result: ReactResult,
success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ReactResult {
guest_token: Option<String>,
timestamp: Option<i64>,
status_code: Option<u16>,
}
#[derive(Debug, Serialize, Deserialize)]
struct NewLikesResponse {
result: NewLikesResult,
success: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct NewLikesResult {
count: u32,
}