Skip to main content

capmonstercloud_client/requests/
hunt.rs

1use serde::ser::{Serialize, SerializeMap, Serializer};
2use serde::Serialize as DeriveSerialize;
3
4use super::{require_non_empty, CaptchaRequest};
5use crate::proxy::ProxyInfo;
6use crate::timeouts::GetResultTimeouts;
7
8/// What the HUNT solver should do. `data` and `widgetUrl` are mutually exclusive.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum HuntMode {
11    /// Only generate a fingerprint (just `apiGetLib` is sent).
12    Fingerprint,
13    /// Solve the captcha via the `meta.token` value received from the website (`data`).
14    Token(String),
15    /// Solve the captcha via the full URL of the HUNT widget, including domain, path and query
16    /// (`widgetUrl`), e.g. `https://captcha.example.com/widget?hash=<widget-hash>`.
17    WidgetUrl(String),
18}
19
20/// HUNT parameters. Values are dynamic: extract them right before creating the task.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct HuntMetadata {
23    /// Full URL of the `api.js` file (look for `hd-api` or `api.js` in the Network tab), e.g.
24    /// `https://example.com/hd-api/external/apps/<app-hash>/api.js`.
25    pub api_get_lib: String,
26    pub mode: HuntMode,
27}
28
29impl HuntMetadata {
30    /// Fingerprint generation: only `apiGetLib` is sent.
31    pub fn fingerprint(api_get_lib: impl Into<String>) -> Self {
32        Self {
33            api_get_lib: api_get_lib.into(),
34            mode: HuntMode::Fingerprint,
35        }
36    }
37
38    /// Captcha solving via the `meta.token` value (`data`).
39    pub fn with_token(api_get_lib: impl Into<String>, data: impl Into<String>) -> Self {
40        Self {
41            api_get_lib: api_get_lib.into(),
42            mode: HuntMode::Token(data.into()),
43        }
44    }
45
46    /// Captcha solving via the full widget URL (`widgetUrl`).
47    pub fn with_widget_url(api_get_lib: impl Into<String>, widget_url: impl Into<String>) -> Self {
48        Self {
49            api_get_lib: api_get_lib.into(),
50            mode: HuntMode::WidgetUrl(widget_url.into()),
51        }
52    }
53}
54
55impl Serialize for HuntMetadata {
56    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
57        let len = if self.mode == HuntMode::Fingerprint {
58            1
59        } else {
60            2
61        };
62        let mut map = serializer.serialize_map(Some(len))?;
63        map.serialize_entry("apiGetLib", &self.api_get_lib)?;
64        match &self.mode {
65            HuntMode::Fingerprint => {}
66            HuntMode::Token(data) => map.serialize_entry("data", data)?,
67            HuntMode::WidgetUrl(url) => map.serialize_entry("widgetUrl", url)?,
68        }
69        map.end()
70    }
71}
72
73/// HUNT captcha (`CustomTask`, class `HUNT`). Your own proxy is required.
74#[derive(Debug, Clone, DeriveSerialize)]
75pub struct HuntCustomTaskRequest {
76    /// Full URL of the page where HUNT is used.
77    #[serde(rename = "websiteURL")]
78    pub website_url: String,
79    pub metadata: HuntMetadata,
80    /// Required proxy (non-transparent, not local).
81    #[serde(flatten)]
82    pub proxy: ProxyInfo,
83    /// Browser User-Agent (current Windows UA supported by CapMonster Cloud).
84    #[serde(rename = "userAgent", skip_serializing_if = "Option::is_none")]
85    pub user_agent: Option<String>,
86}
87
88impl HuntCustomTaskRequest {
89    pub fn new(website_url: impl Into<String>, metadata: HuntMetadata, proxy: ProxyInfo) -> Self {
90        Self {
91            website_url: website_url.into(),
92            metadata,
93            proxy,
94            user_agent: None,
95        }
96    }
97
98    pub fn with_user_agent(mut self, value: impl Into<String>) -> Self {
99        self.user_agent = Some(value.into());
100        self
101    }
102}
103
104impl CaptchaRequest for HuntCustomTaskRequest {
105    const TASK_TYPE: &'static str = "CustomTask";
106    const CLASS: Option<&'static str> = Some("HUNT");
107
108    fn timeouts(&self) -> GetResultTimeouts {
109        GetResultTimeouts::secs(1.0, 0.0, 3.0, 180.0)
110    }
111
112    fn validate(&self) -> crate::error::Result<()> {
113        require_non_empty("websiteURL", &self.website_url)?;
114        require_non_empty("metadata.apiGetLib", &self.metadata.api_get_lib)?;
115        match &self.metadata.mode {
116            HuntMode::Fingerprint => {}
117            HuntMode::Token(data) => require_non_empty("metadata.data", data)?,
118            HuntMode::WidgetUrl(url) => require_non_empty("metadata.widgetUrl", url)?,
119        }
120        Ok(())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::proxy::ProxyType;
128    use serde_json::json;
129
130    fn proxy() -> ProxyInfo {
131        ProxyInfo::new(ProxyType::Http, "1.2.3.4", 8080).with_auth("u", "p")
132    }
133
134    #[test]
135    fn minimal_fingerprint_task() {
136        let task = HuntCustomTaskRequest::new(
137            "https://example.com",
138            HuntMetadata::fingerprint("https://example.com/api.js"),
139            proxy(),
140        )
141        .to_task()
142        .unwrap();
143        assert_eq!(
144            task,
145            json!({
146                "type": "CustomTask", "class": "HUNT", "websiteURL": "https://example.com",
147                "metadata": {"apiGetLib": "https://example.com/api.js"},
148                "proxyType": "http", "proxyAddress": "1.2.3.4", "proxyPort": 8080,
149                "proxyLogin": "u", "proxyPassword": "p"
150            })
151        );
152    }
153
154    #[test]
155    fn full_token_task() {
156        let task = HuntCustomTaskRequest::new(
157            "https://example.com",
158            HuntMetadata::with_token("L", "TOKEN"),
159            proxy(),
160        )
161        .with_user_agent("UA")
162        .to_task()
163        .unwrap();
164        assert_eq!(task["metadata"], json!({"apiGetLib": "L", "data": "TOKEN"}));
165        assert_eq!(task["userAgent"], "UA");
166    }
167
168    #[test]
169    fn widget_url_task() {
170        let task = HuntCustomTaskRequest::new(
171            "https://example.com",
172            HuntMetadata::with_widget_url("L", "https://c.example.com/widget?hash=h"),
173            proxy(),
174        )
175        .to_task()
176        .unwrap();
177        assert_eq!(
178            task["metadata"],
179            json!({"apiGetLib": "L", "widgetUrl": "https://c.example.com/widget?hash=h"})
180        );
181    }
182
183    #[test]
184    fn empty_required_strings_are_rejected() {
185        let err = HuntCustomTaskRequest::new("", HuntMetadata::fingerprint("L"), proxy())
186            .to_task()
187            .unwrap_err();
188        assert!(matches!(err, crate::error::Error::Validation(ref m) if m.contains("websiteURL")));
189        let err = HuntCustomTaskRequest::new(
190            "https://example.com",
191            HuntMetadata::fingerprint(""),
192            proxy(),
193        )
194        .to_task()
195        .unwrap_err();
196        assert!(
197            matches!(err, crate::error::Error::Validation(ref m) if m.contains("metadata.apiGetLib"))
198        );
199        let err = HuntCustomTaskRequest::new(
200            "https://example.com",
201            HuntMetadata::with_token("L", ""),
202            proxy(),
203        )
204        .to_task()
205        .unwrap_err();
206        assert!(
207            matches!(err, crate::error::Error::Validation(ref m) if m.contains("metadata.data"))
208        );
209        let err = HuntCustomTaskRequest::new(
210            "https://example.com",
211            HuntMetadata::with_widget_url("L", ""),
212            proxy(),
213        )
214        .to_task()
215        .unwrap_err();
216        assert!(
217            matches!(err, crate::error::Error::Validation(ref m) if m.contains("metadata.widgetUrl"))
218        );
219    }
220}