Skip to main content

ncm_api_rs/
request.rs

1/// 请求模块 - 对应 Node.js 版本的 util/request.js
2///
3/// 核心功能:构造加密请求、Cookie 管理、UA 伪装
4use crate::crypto;
5use crate::error::{NcmError, Result};
6use crate::util::config::*;
7use crate::util::cookie::{cookie_obj_to_string, cookie_to_json};
8use crate::util::device::{generate_device_id, generate_wnmcid, random_hex};
9use crate::util::ip::generate_random_chinese_ip;
10
11use reqwest::header::{HeaderMap, HeaderValue, COOKIE, REFERER, USER_AGENT};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::sync::LazyLock;
16
17/// 特殊状态码集合(视为 200)
18static SPECIAL_STATUS_CODES: LazyLock<std::collections::HashSet<i64>> =
19    LazyLock::new(|| [201, 302, 400, 502, 800, 801, 802, 803].into());
20
21/// 全局设备 ID(进程生命周期内固定)
22static DEVICE_ID: LazyLock<String> = LazyLock::new(generate_device_id);
23/// 全局 WNMCID
24static WNMCID: LazyLock<String> = LazyLock::new(generate_wnmcid);
25/// Cookie Domain 移除正则(编译一次,全局复用)
26static DOMAIN_REGEX: LazyLock<regex_lite::Regex> =
27    LazyLock::new(|| regex_lite::Regex::new(r"\s*Domain=[^;]+;?").unwrap());
28
29/// 安全创建 HeaderValue,无效字符会被过滤
30fn header_value(s: &str) -> HeaderValue {
31    HeaderValue::from_str(s).unwrap_or_else(|_| {
32        // 过滤掉非 ASCII 可见字符
33        let safe: String = s
34            .chars()
35            .filter(|c| c.is_ascii() && !c.is_ascii_control())
36            .collect();
37        HeaderValue::from_str(&safe).unwrap_or_else(|_| HeaderValue::from_static(""))
38    })
39}
40
41/// 加密类型
42#[derive(Debug, Clone, PartialEq, Default)]
43pub enum CryptoType {
44    Weapi,
45    #[default]
46    Eapi,
47    Linuxapi,
48    Api, // 明文
49}
50
51impl CryptoType {
52    pub fn as_str(&self) -> &str {
53        match self {
54            CryptoType::Weapi => "weapi",
55            CryptoType::Eapi => "eapi",
56            CryptoType::Linuxapi => "linuxapi",
57            CryptoType::Api => "api",
58        }
59    }
60}
61
62impl From<&str> for CryptoType {
63    fn from(s: &str) -> Self {
64        match s {
65            "weapi" => CryptoType::Weapi,
66            "linuxapi" => CryptoType::Linuxapi,
67            "api" => CryptoType::Api,
68            _ => CryptoType::Eapi,
69        }
70    }
71}
72
73/// 请求选项
74#[derive(Debug, Clone, Default)]
75pub struct RequestOption {
76    pub crypto: CryptoType,
77    pub cookie: Option<String>,
78    pub ua: Option<String>,
79    pub proxy: Option<String>,
80    pub real_ip: Option<String>,
81    pub random_cn_ip: bool,
82    pub e_r: Option<bool>,
83    pub domain: Option<String>,
84    pub check_token: bool,
85}
86
87/// API 响应
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct ApiResponse {
90    pub status: i64,
91    pub body: Value,
92    #[serde(default)]
93    pub cookie: Vec<String>,
94}
95
96/// API 客户端
97#[derive(Debug, Clone)]
98pub struct ApiClient {
99    pub(crate) client: reqwest::Client,
100    cookie: Option<String>,
101    anonymous_token: Option<String>,
102    /// 自定义设备 ID,None 则使用全局默认值
103    device_id: Option<String>,
104}
105
106impl ApiClient {
107    /// 创建新的 API 客户端
108    pub fn new(cookie: Option<String>) -> Self {
109        let client = reqwest::Client::builder()
110            .build()
111            .expect("Failed to create HTTP client");
112
113        Self {
114            client,
115            cookie,
116            anonymous_token: None,
117            device_id: None,
118        }
119    }
120
121    /// 创建带代理的 API 客户端
122    pub fn with_proxy(cookie: Option<String>, proxy_url: &str) -> Result<Self> {
123        let proxy = reqwest::Proxy::all(proxy_url)
124            .map_err(|e| NcmError::Unknown(format!("Invalid proxy URL: {}", e)))?;
125        let client = reqwest::Client::builder()
126            .proxy(proxy)
127            .build()
128            .map_err(NcmError::Http)?;
129
130        Ok(Self {
131            client,
132            cookie,
133            anonymous_token: None,
134            device_id: None,
135        })
136    }
137
138    /// 设置 cookie
139    pub fn set_cookie(&mut self, cookie: String) {
140        self.cookie = Some(cookie);
141    }
142
143    /// 设置匿名 token
144    pub fn set_anonymous_token(&mut self, token: String) {
145        self.anonymous_token = Some(token);
146    }
147
148    /// 设置自定义设备 ID
149    ///
150    /// 不同客户端实例可使用不同设备 ID,避免共享全局 ID 触发风控
151    pub fn set_device_id(&mut self, device_id: String) {
152        self.device_id = Some(device_id);
153    }
154
155    /// 获取当前设备 ID(自定义优先,否则使用全局默认)
156    fn get_device_id(&self) -> &str {
157        self.device_id.as_deref().unwrap_or(&DEVICE_ID)
158    }
159
160    /// 发起 API 请求 - 核心方法
161    pub async fn request(
162        &self,
163        uri: &str,
164        data: Value,
165        options: RequestOption,
166    ) -> Result<ApiResponse> {
167        let mut headers = HeaderMap::new();
168
169        // IP 伪装
170        let ip = options.real_ip.clone().or_else(|| {
171            if options.random_cn_ip {
172                Some(generate_random_chinese_ip())
173            } else {
174                None
175            }
176        });
177
178        if let Some(ref ip) = ip {
179            if let (Ok(real_ip), Ok(fwd)) = (HeaderValue::from_str(ip), HeaderValue::from_str(ip)) {
180                headers.insert("X-Real-IP", real_ip);
181                headers.insert("X-Forwarded-For", fwd);
182            }
183        }
184
185        // Cookie 处理
186        let cookie_str = options
187            .cookie
188            .as_deref()
189            .or(self.cookie.as_deref())
190            .unwrap_or("");
191        let mut cookie_map = cookie_to_json(cookie_str);
192
193        // 注入必要的 cookie 字段
194        let ntes_nuid = random_hex(16);
195        let os = get_os_config(cookie_map.get("os").map(|s| s.as_str()).unwrap_or("pc"));
196        let now_ts = chrono::Utc::now().timestamp_millis().to_string();
197
198        cookie_map
199            .entry("__remember_me".to_string())
200            .or_insert_with(|| "true".to_string());
201        cookie_map
202            .entry("ntes_kaola_ad".to_string())
203            .or_insert_with(|| "1".to_string());
204        cookie_map
205            .entry("_ntes_nnid".to_string())
206            .or_insert_with(|| format!("{},{}", ntes_nuid, now_ts));
207        cookie_map
208            .entry("_ntes_nuid".to_string())
209            .or_insert(ntes_nuid);
210        cookie_map
211            .entry("WNMCID".to_string())
212            .or_insert_with(|| WNMCID.clone());
213        cookie_map
214            .entry("WEVNSM".to_string())
215            .or_insert_with(|| "1.0.0".to_string());
216        cookie_map
217            .entry("osver".to_string())
218            .or_insert_with(|| os.osver.to_string());
219        cookie_map
220            .entry("deviceId".to_string())
221            .or_insert_with(|| self.get_device_id().to_string());
222        cookie_map
223            .entry("os".to_string())
224            .or_insert_with(|| os.os.to_string());
225        cookie_map
226            .entry("channel".to_string())
227            .or_insert_with(|| os.channel.to_string());
228        cookie_map
229            .entry("appver".to_string())
230            .or_insert_with(|| os.appver.to_string());
231
232        // 非登录接口注入 NMTID
233        if !uri.contains("login") {
234            cookie_map
235                .entry("NMTID".to_string())
236                .or_insert_with(|| random_hex(8));
237        }
238
239        // 未登录时注入匿名 token
240        if !cookie_map.contains_key("MUSIC_U") {
241            if let Some(ref token) = self.anonymous_token {
242                cookie_map
243                    .entry("MUSIC_A".to_string())
244                    .or_insert_with(|| token.clone());
245            }
246        }
247
248        headers.insert(COOKIE, header_value(&cookie_obj_to_string(&cookie_map)));
249
250        // 确定加密方式
251        let crypto_type = if options.crypto == CryptoType::Eapi && !ENCRYPT {
252            CryptoType::Api
253        } else {
254            options.crypto.clone()
255        };
256
257        let mut data = data;
258        let url: String;
259        let encrypt_data: HashMap<String, String>;
260        let domain = options.domain.as_deref().unwrap_or("");
261
262        let csrf_token = cookie_map.get("__csrf").cloned().unwrap_or_default();
263
264        match crypto_type {
265            CryptoType::Weapi => {
266                let ref_domain = if domain.is_empty() { DOMAIN } else { domain };
267                headers.insert(REFERER, header_value(ref_domain));
268                let ua = options
269                    .ua
270                    .as_deref()
271                    .unwrap_or_else(|| choose_user_agent("weapi", "pc"));
272                headers.insert(USER_AGENT, header_value(ua));
273
274                data["csrf_token"] = Value::String(csrf_token);
275                encrypt_data = crypto::weapi(&data);
276                url = format!("{}/weapi/{}", ref_domain, &uri[5..]);
277            }
278            CryptoType::Linuxapi => {
279                let ua = options
280                    .ua
281                    .as_deref()
282                    .unwrap_or_else(|| choose_user_agent("linuxapi", "linux"));
283                headers.insert(USER_AGENT, header_value(ua));
284
285                let ref_domain = if domain.is_empty() { DOMAIN } else { domain };
286                let linux_data = serde_json::json!({
287                    "method": "POST",
288                    "url": format!("{}{}", ref_domain, uri),
289                    "params": data,
290                });
291                encrypt_data = crypto::linuxapi(&linux_data);
292                url = format!("{}/api/linux/forward", ref_domain);
293            }
294            CryptoType::Eapi | CryptoType::Api => {
295                // 构造 eapi header cookie
296                let now_secs = chrono::Utc::now().timestamp().to_string();
297                let request_id = format!(
298                    "{}_{:04}",
299                    chrono::Utc::now().timestamp_millis(),
300                    rand::random::<u16>() % 1000
301                );
302
303                let mut header_map: HashMap<String, String> = HashMap::new();
304                header_map.insert(
305                    "osver".to_string(),
306                    cookie_map.get("osver").cloned().unwrap_or_default(),
307                );
308                header_map.insert(
309                    "deviceId".to_string(),
310                    cookie_map.get("deviceId").cloned().unwrap_or_default(),
311                );
312                header_map.insert(
313                    "os".to_string(),
314                    cookie_map.get("os").cloned().unwrap_or_default(),
315                );
316                header_map.insert(
317                    "appver".to_string(),
318                    cookie_map.get("appver").cloned().unwrap_or_default(),
319                );
320                header_map.insert(
321                    "versioncode".to_string(),
322                    cookie_map
323                        .get("versioncode")
324                        .cloned()
325                        .unwrap_or_else(|| "140".to_string()),
326                );
327                header_map.insert(
328                    "mobilename".to_string(),
329                    cookie_map.get("mobilename").cloned().unwrap_or_default(),
330                );
331                header_map.insert(
332                    "buildver".to_string(),
333                    cookie_map
334                        .get("buildver")
335                        .cloned()
336                        .unwrap_or_else(|| now_secs[..10].to_string()),
337                );
338                header_map.insert(
339                    "resolution".to_string(),
340                    cookie_map
341                        .get("resolution")
342                        .cloned()
343                        .unwrap_or_else(|| "1920x1080".to_string()),
344                );
345                header_map.insert("__csrf".to_string(), csrf_token.clone());
346                header_map.insert(
347                    "channel".to_string(),
348                    cookie_map.get("channel").cloned().unwrap_or_default(),
349                );
350                header_map.insert("requestId".to_string(), request_id);
351
352                if options.check_token {
353                    header_map.insert("X-antiCheatToken".to_string(), CHECK_TOKEN.to_string());
354                }
355
356                if let Some(music_u) = cookie_map.get("MUSIC_U") {
357                    header_map.insert("MUSIC_U".to_string(), music_u.clone());
358                }
359                if let Some(music_a) = cookie_map.get("MUSIC_A") {
360                    header_map.insert("MUSIC_A".to_string(), music_a.clone());
361                }
362
363                let header_cookie_str = header_map
364                    .iter()
365                    .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v)))
366                    .collect::<Vec<_>>()
367                    .join("; ");
368
369                headers.insert(COOKIE, header_value(&header_cookie_str));
370
371                let ua = options
372                    .ua
373                    .as_deref()
374                    .unwrap_or_else(|| choose_user_agent("api", "iphone"));
375                headers.insert(USER_AGENT, header_value(ua));
376
377                let api_domain = if domain.is_empty() {
378                    API_DOMAIN
379                } else {
380                    domain
381                };
382
383                if crypto_type == CryptoType::Eapi {
384                    // 注入 header 和 e_r
385                    let header_value = serde_json::to_value(&header_map).unwrap();
386                    data["header"] = header_value;
387
388                    let e_r = options.e_r.unwrap_or(ENCRYPT_RESPONSE);
389                    data["e_r"] = Value::Bool(e_r);
390
391                    encrypt_data = crypto::eapi(uri, &data);
392                    url = format!("{}/eapi/{}", api_domain, &uri[5..]);
393                } else {
394                    // api 明文
395                    encrypt_data = if let Value::Object(map) = &data {
396                        map.iter()
397                            .map(|(k, v)| {
398                                (
399                                    k.clone(),
400                                    match v {
401                                        Value::String(s) => s.clone(),
402                                        _ => v.to_string(),
403                                    },
404                                )
405                            })
406                            .collect()
407                    } else {
408                        HashMap::new()
409                    };
410                    url = format!("{}{}", api_domain, uri);
411                }
412            }
413        }
414
415        // 构造 POST body
416        let body = serde_urlencoded::to_string(&encrypt_data)
417            .map_err(|e| NcmError::Unknown(e.to_string()))?;
418
419        // 设置 Content-Type
420        headers.insert(
421            reqwest::header::CONTENT_TYPE,
422            HeaderValue::from_static("application/x-www-form-urlencoded"),
423        );
424
425        // 构造请求并发送(按需使用代理)
426        let response = if let Some(ref proxy_url) = options.proxy {
427            let proxy = reqwest::Proxy::all(proxy_url)
428                .map_err(|e| NcmError::Unknown(format!("Invalid proxy URL: {}", e)))?;
429            let proxy_client = reqwest::Client::builder()
430                .proxy(proxy)
431                .build()
432                .map_err(NcmError::Http)?;
433            proxy_client
434                .post(&url)
435                .headers(headers)
436                .body(body)
437                .send()
438                .await?
439        } else {
440            self.client
441                .post(&url)
442                .headers(headers)
443                .body(body)
444                .send()
445                .await?
446        };
447
448        // 处理响应 cookie
449        let resp_cookies: Vec<String> = response
450            .headers()
451            .get_all("set-cookie")
452            .iter()
453            .filter_map(|v| v.to_str().ok())
454            .map(|s| {
455                // 移除 Domain 属性
456                DOMAIN_REGEX.replace_all(s, "").to_string()
457            })
458            .collect();
459
460        // 解析响应体
461        let e_r = options.e_r.unwrap_or(false);
462        let status_code = response.status().as_u16() as i64;
463
464        let body: Value = if crypto_type == CryptoType::Eapi && e_r {
465            let bytes = response.bytes().await?;
466            let hex_str = hex::encode_upper(&bytes);
467            crypto::eapi_res_decrypt(&hex_str).unwrap_or(Value::Null)
468        } else {
469            let text = response.text().await?;
470            serde_json::from_str(&text).unwrap_or(Value::String(text))
471        };
472
473        let mut status = body
474            .get("code")
475            .and_then(|c| {
476                c.as_i64()
477                    .or_else(|| c.as_str().and_then(|s| s.parse().ok()))
478            })
479            .unwrap_or(status_code);
480
481        // 特殊状态码视为 200
482        if SPECIAL_STATUS_CODES.contains(&status) {
483            status = 200;
484        }
485
486        // 状态码范围检查
487        if !(100..600).contains(&status) {
488            status = 400;
489        }
490
491        let answer = ApiResponse {
492            status,
493            body,
494            cookie: resp_cookies,
495        };
496
497        if status == 200 {
498            Ok(answer)
499        } else {
500            let msg = answer
501                .body
502                .get("msg")
503                .and_then(|m| m.as_str())
504                .unwrap_or("Unknown error")
505                .to_string();
506            Err(NcmError::from_api(status, msg))
507        }
508    }
509}