steam_user/services/
comments.rs1use std::sync::OnceLock;
4
5use scraper::{Html, Selector};
6use steamid::SteamID;
7
8static SEL_COMMENT_THREAD: OnceLock<Selector> = OnceLock::new();
9fn sel_comment_thread() -> &'static Selector {
10 SEL_COMMENT_THREAD.get_or_init(|| Selector::parse(".commentthread_comment").expect("valid CSS selector"))
11}
12
13static SEL_COMMENT_TEXT: OnceLock<Selector> = OnceLock::new();
14fn sel_comment_text() -> &'static Selector {
15 SEL_COMMENT_TEXT.get_or_init(|| Selector::parse(".commentthread_comment_text").expect("valid CSS selector"))
16}
17
18static SEL_COMMENT_TIMESTAMP: OnceLock<Selector> = OnceLock::new();
19fn sel_comment_timestamp() -> &'static Selector {
20 SEL_COMMENT_TIMESTAMP.get_or_init(|| Selector::parse(".commentthread_comment_timestamp").expect("valid CSS selector"))
21}
22
23static SEL_COMMENT_AVATAR_IMG: OnceLock<Selector> = OnceLock::new();
24fn sel_comment_avatar_img() -> &'static Selector {
25 SEL_COMMENT_AVATAR_IMG.get_or_init(|| Selector::parse(".commentthread_comment_avatar a img").expect("valid CSS selector"))
26}
27
28static SEL_COMMENT_AUTHOR: OnceLock<Selector> = OnceLock::new();
29fn sel_comment_author() -> &'static Selector {
30 SEL_COMMENT_AUTHOR.get_or_init(|| Selector::parse(".commentthread_comment_author a").expect("valid CSS selector"))
31}
32
33use crate::{
34 client::SteamUser,
35 endpoint::steam_endpoint,
36 error::SteamUserError,
37 types::{CommentAuthor, UserComment},
38 utils::avatar::{extract_custom_url, get_avatar_hash_from_url},
39};
40
41impl SteamUser {
42 #[tracing::instrument(skip(self))]
64 pub async fn get_my_comments(&self) -> Result<Vec<UserComment>, SteamUserError> {
65 let steam_id = self.session.steam_id.ok_or(SteamUserError::NotLoggedIn)?;
66 self.get_user_comments(steam_id).await
67 }
68
69 #[steam_endpoint(GET, host = Community, path = "/comment/Profile/render/{steam_id}/-1/", kind = Read)]
99 pub async fn get_user_comments(&self, steam_id: SteamID) -> Result<Vec<UserComment>, SteamUserError> {
100 let path = format!("/comment/Profile/render/{}/-1/", steam_id.steam_id64());
101
102 let response: serde_json::Value = self.get_path(&path).send().await?.json().await?;
103
104 let comments_html = response.get("comments_html").and_then(|v| v.as_str()).unwrap_or("");
105 let mut comments = parse_comments(comments_html);
106
107 let total_count = response.get("total_count").and_then(|v| v.as_u64()).unwrap_or(0);
108 let pagesize = response.get("pagesize").and_then(|v| v.as_u64()).unwrap_or(50);
109
110 if (comments.len() as u64) < total_count {
111 let mut start = comments.len() as u64;
112 while start < total_count {
113 let form = [("start", start.to_string()), ("totalcount", total_count.to_string()), ("count", pagesize.to_string()), ("feature2", "-1".to_string())];
114
115 let page_response: serde_json::Value = self.post_path(&path).form(&form).send().await?.json().await?;
116 if let Some(html) = page_response.get("comments_html").and_then(|v| v.as_str()) {
117 comments.extend(parse_comments(html));
118 }
119 start += pagesize;
120 }
121 }
122
123 Ok(comments)
124 }
125
126 #[steam_endpoint(POST, host = Community, path = "/comment/Profile/post/{steam_id}/-1/", kind = Write)]
158 pub async fn post_comment(&self, steam_id: SteamID, message: &str) -> Result<Option<UserComment>, SteamUserError> {
159 let form = [("comment", message), ("count", "6"), ("feature2", "-1")];
160
161 let response: serde_json::Value = self.post_path(format!("/comment/Profile/post/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;
162
163 if let Some(html) = response.get("comments_html").and_then(|v| v.as_str()) {
164 let comments = parse_comments(html);
165 let last_post = response.get("timelastpost").and_then(|v| v.as_u64());
166
167 if let Some(ts) = last_post {
168 return Ok(comments.into_iter().find(|c| c.timestamp == ts));
169 }
170 return Ok(comments.first().cloned());
171 }
172
173 Ok(None)
174 }
175
176 #[steam_endpoint(POST, host = Community, path = "/comment/Profile/delete/{steam_id}/-1/", kind = Write)]
201 pub async fn delete_comment(&self, steam_id: SteamID, gidcomment: &str) -> Result<(), SteamUserError> {
202 let form = [("gidcomment", gidcomment), ("start", "0"), ("count", "6"), ("feature2", "-1")];
203
204 let response: serde_json::Value = self.post_path(format!("/comment/Profile/delete/{}/-1/", steam_id.steam_id64())).form(&form).send().await?.json().await?;
205
206 let success = response.get("success").and_then(|v| v.as_bool()).unwrap_or(false) || response.get("success").and_then(|v| v.as_i64()).map(|n| n == 1).unwrap_or(false);
207
208 if success {
209 Ok(())
210 } else {
211 Err(SteamUserError::SteamError("Failed to delete comment".into()))
212 }
213 }
214}
215
216fn parse_comments(html: &str) -> Vec<UserComment> {
218 let document = Html::parse_document(&format!("<div>{}</div>", html));
219 let mut comments = Vec::new();
220
221 for element in document.select(sel_comment_thread()) {
222 let id = element.value().attr("id").unwrap_or("").replace("comment_", "");
223 let content = element.select(sel_comment_text()).next().map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
224
225 let timestamp = element.select(sel_comment_timestamp()).next().and_then(|el| el.value().attr("data-timestamp")).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
226
227 let avatar_el = element.select(sel_comment_avatar_img()).next();
228 let avatar = avatar_el.and_then(|el| el.value().attr("src")).unwrap_or("").to_string();
229 let avatar_hash = get_avatar_hash_from_url(&avatar).unwrap_or_default();
230
231 let author_el = element.select(sel_comment_author()).next();
232 let profile_url = author_el.and_then(|el| el.value().attr("href")).map(|s| s.to_string());
233 let name = author_el.map(|el| el.text().collect::<String>().trim().to_string()).unwrap_or_default();
234 let miniprofile = author_el.and_then(|el| el.value().attr("data-miniprofile")).and_then(|s| s.parse::<u64>().ok());
235
236 let steam_id = miniprofile.and_then(|mp| u32::try_from(mp).ok()).map(SteamID::from_individual_account_id);
239 let custom_url = profile_url.as_deref().and_then(extract_custom_url);
240
241 comments.push(UserComment {
242 id,
243 content,
244 timestamp,
245 author: CommentAuthor { steam_id, name, avatar, avatar_hash, profile_url, custom_url, miniprofile },
246 });
247 }
248
249 comments
250}