Browse Source

feat(V1.0.10.B): 优化扫货代理与日志,并同步构建产物

buylisting/令牌确认改为直连;完善 API 成功日志与 tljb 购买确认脚本;发布 steamSH V1.0.10。

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 tháng trước cách đây
mục cha
commit
421c1f07a3

+ 3 - 0
.gitignore

@@ -45,6 +45,9 @@ pip-delete-this-directory.txt
 *.env
 !.env.example
 secrets.yml
+tljb/config.json
+tljb/accounts/
+tljb/*.zip
 
 # Rust / Tauri
 target/

BIN
client-tauri/SteamSH_Portable.zip


+ 2 - 2
client-tauri/src-tauri/src/commands.rs

@@ -1395,7 +1395,7 @@ async fn post_steam_api_once(
     _proxy_url: Option<&str>,
 ) -> Result<(reqwest::StatusCode, String), String> {
     // 不再传递代理信息给 localhost:5000,让 steamAPI 服务直连 Steam
-    // 代理只用于客户端直接访问 Steam 的接口(如 render、buylisting)
+    // 代理只用于客户端直接访问 Steam 的 list/render;buylisting 购买直连
     let resp = client
         .post(api_url)
         .json(base_body)
@@ -1414,7 +1414,7 @@ async fn post_steam_api_with_proxy_retry(
     label: &str,
 ) -> Result<(reqwest::StatusCode, String), String> {
     // 调用 localhost:5000 接口,不使用代理(直连模式)
-    // 代理仅用于客户端直接访问 Steam 的接口(如 render、buylisting)
+    // 代理仅用于客户端直接访问 Steam 的 list/render;buylisting 购买直连
     log::info!(
         "[api:{}] 直连 localhost:5000(不使用代理)",
         label

+ 235 - 95
client-tauri/src-tauri/src/commands/buff_sweep_runner.rs

@@ -1,10 +1,11 @@
 //! 客户端本地 BUFF 扫货主循环(与 `backend-client/app/services/buff_sweep_service.py` 对齐)。
 //! 磨损:`render` 的 `assets` 中优先 `propertyid=2` 的 `float_value`;若无则用 `propertyid=6` 的 hex 经 `buff_sweep_decode`(与 `decode_url.py` 的 `paintwear` 一致)。
 //! 扫货明细日志仅写入本机 `steam-msg/{sweep_request_id}.txt`,不再调用 `sweep-log-client-sync` 落库以减轻数据库压力。
-//! 每条选品:**仅第一页** Steam render(`start=0`,不翻页;每页条数由 `buff_sweep_steam_listings_page_size` 决定)、只比价,收集所有「价格合适」的 listing;若有 N 条则对 N 条**并发**:各自解磨损 → 比区间 → 合适则账号轮询**直接** `buylisting`(购前不调 `wallet-balance`、不根据余额拦截;购后不解析/缓存 `wallet_balance`;仅依 `wallet_info.success` 等判断成败,失败再换号)。同一选品行可多次成交,不限制只买一件。每个 Steam 账号一把互斥锁。单页内最多处理 `buff_sweep_steam_listings_max_scan_per_item` 条 listing。扫货启动时 Steam 会话准备与界面「查询余额 / 重新登录」一致:先 `wallet-balance` 探活,失败再 `POST /login` 写回 `accounts.json`。
+//! 每条选品:**仅第一页** Steam render(`start=0`,不翻页;每页条数由 `buff_sweep_steam_listings_page_size` 决定)、只比价,收集所有「价格合适」的 listing;若有 N 条则对 N 条**并发**:各自解磨损 → 比区间 → 合适则账号轮询 `buylisting`(走全局 IP 代理池;购前不调 `wallet-balance`、不根据余额拦截;购后不解析/缓存 `wallet_balance`;仅依 `wallet_info.success` 等判断成败,失败再换号)。同一选品行可多次成交,不限制只买一件。每个 Steam 账号一把互斥锁。单页内最多处理 `buff_sweep_steam_listings_max_scan_per_item` 条 listing。扫货启动时 Steam 会话准备与界面「查询余额 / 重新登录」一致:先 `wallet-balance` 探活,失败再 `POST /login` 写回 `accounts.json`。
 //! **API 代理池(全局共享)**:扫货进程启动时,通过 API 获取一批代理存入全局共享代理池(不再每个 Worker 独立维护)。
 //! 当池中代理数量低于 20 个时,自动调用 API 补充新代理。API 调用失败时采用指数退避重试策略(2s → 5s → 8s → ... → 最大 30s)。
 //! 子线程共用这个全局代理池,并发获取代理时使用 tokio Mutex 保证线程安全。
+//! **代理使用范围**:Steam `listings/render`(GET)走全局 IP 代理池;`buylisting`(POST buy)与手机令牌确认均**不走代理**(直连)。
 //! **饰品间暂停**:`buff_sweep_pause_after_render_seconds`(心跳同步)表示**每个选品饰品**在「扫描 listing + 并发解磨损/购买」整段结束后,再暂停若干秒,然后才处理下一个饰品;用于降速、减轻 Steam 429。
 
 use std::collections::{HashMap, HashSet};
@@ -101,7 +102,7 @@ const STEAM_API_BASE: &str = "http://localhost:5000";
 const MAX_CONCURRENT_LISTING_JOBS: usize = 32;
 
 /// 日志轮转阈值(达到此条数时清空日志文件)
-const LOG_ROTATION_THRESHOLD: usize = 1000;
+const LOG_ROTATION_THRESHOLD: usize = 5000;
 
 /// 扫货全局共享代理池:所有 Worker 共享同一个代理池
 /// 启动时通过 API 获取一批代理,池中少于 20 个时自动补充
@@ -359,9 +360,120 @@ fn sweep_api_ts() -> String {
     Local::now().format("%Y-%m-%d %H:%M:%S%.3f").to_string()
 }
 
+/// 截断响应体,避免成功日志把 Worker 文件撑爆(尤其是 listings render)。
+fn clip_api_log_body(body: &str, max_chars: usize) -> String {
+    let trimmed = body.trim();
+    if trimmed.is_empty() {
+        return "(空响应)".to_string();
+    }
+    clip_buy_log(trimmed, max_chars)
+}
+
+/// 写入 API 成功(含 buy 需确认 406)调用日志到 Worker 扫货日志。
+/// 成功不写 error 文件,避免与失败日志混在一起;body 做截断。
+async fn log_api_call_ok(
+    sweep_file: Option<&SweepShared>,
+    request_id_fallback: i64,
+    steam_account: &str,
+    api_name: &str,
+    method: &str,
+    url: &str,
+    headers: &HeaderMap,
+    form_params: Option<&[(String, String)]>,
+    response_status: reqwest::StatusCode,
+    response_body: &str,
+    elapsed_ms: u64,
+    note: Option<&str>,
+) {
+    let rid = sweep_file
+        .map(|s| s.sweep_request_id)
+        .unwrap_or(request_id_fallback);
+
+    let headers_json = serde_json::to_value(
+        headers
+            .iter()
+            .map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
+            .collect::<HashMap<_, _>>(),
+    )
+    .unwrap_or(Value::Null);
+
+    let form_json: Value = match form_params {
+        Some(form) => serde_json::to_value(
+            form.iter()
+                .map(|(k, v)| (k.clone(), v.clone()))
+                .collect::<Vec<_>>(),
+        )
+        .unwrap_or(Value::Null),
+        None => Value::Null,
+    };
+
+    // buy 回包较短可多留;listings render 很大,只留摘要
+    let body_limit = if api_name == "buy" { 2500 } else { 800 };
+    let response_body_clip = clip_api_log_body(response_body, body_limit);
+    let note_s = note.unwrap_or("");
+    let msg = if note_s.is_empty() {
+        format!(
+            "[API-OK] {} {} -> {} ({}ms)",
+            method,
+            api_name,
+            response_status.as_u16(),
+            elapsed_ms
+        )
+    } else {
+        format!(
+            "[API-OK] {} {} -> {} ({}ms) - {}",
+            method,
+            api_name,
+            response_status.as_u16(),
+            elapsed_ms,
+            note_s
+        )
+    };
+
+    let log_entry = json!({
+        "created_at": sweep_api_ts(),
+        "goods_id": "-",
+        "goods_name": "",
+        "result": "api_ok",
+        "api_name": api_name,
+        "steam_account": steam_account,
+        "method": method,
+        "url": url,
+        "request_headers": headers_json,
+        "request_body": form_json,
+        "response_status": response_status.as_u16(),
+        "response_body": response_body_clip,
+        "elapsed_ms": elapsed_ms,
+        "message": msg,
+        "line_color": "#52c41a",
+    });
+
+    match sweep_file {
+        Some(s) => {
+            let _g = s.log_lock.lock().await;
+            append_worker_log(&s.worker_log_file, &log_entry);
+        }
+        None => {
+            if rid > 0 {
+                append_local_log(rid, &log_entry);
+            }
+        }
+    }
+
+    info!(
+        "[API-OK] account={} api={} method={} url={} status={} elapsed={}ms note={}",
+        steam_account,
+        api_name,
+        method,
+        url,
+        response_status.as_u16(),
+        elapsed_ms,
+        note_s
+    );
+}
+
 /// 写入API错误调用日志到扫货日志文件
 /// 包含:时间戳、账号、API名称、URL、请求头、请求体、响应状态、响应体、耗时
-/// 注意:只有失败的API调用才会记录日志,成功的不记录
 async fn log_api_call_error(
     sweep_file: Option<&SweepShared>,
     request_id_fallback: i64,
@@ -1173,6 +1285,48 @@ fn invalidate_global_proxy(sweep_file: Option<&SweepShared>, proxy: &str) {
     }
 }
 
+/// 与 tljb 可用购买脚本一致的 Chrome 指纹(实测可降低 market 429)
+const STEAM_CHROME_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
+const STEAM_SEC_CH_UA: &str =
+    "\"Not=A?Brand\";v=\"99\", \"Google Chrome\";v=\"151\", \"Chromium\";v=\"151\"";
+
+fn steam_random_browser_id() -> String {
+    let n: u64 = rand::thread_rng().gen_range(100_000_000_000_000_000..999_999_999_999_999_999);
+    n.to_string()
+}
+
+/// 市场 list/render:无登录会话时的浏览器风格 Cookie
+fn steam_market_cookie_anonymous() -> String {
+    format!(
+        "ActListPageSize=10; bMarketOptOut=1; timezoneOffset=28800,0; timezoneName=Asia%2FShanghai; browserid={}; steamCountry=HK%7C795cd6413f372e6c27f32346040e5249; strInventoryLastContext=730_2",
+        steam_random_browser_id()
+    )
+}
+
+/// 市场 buylisting:带 session 的浏览器风格 Cookie
+fn steam_market_cookie_with_session(session_id: &str, steam_login_secure: &str) -> String {
+    format!(
+        "ActListPageSize=10; bMarketOptOut=1; timezoneOffset=28800,0; timezoneName=Asia%2FShanghai; browserid={}; steamCountry=HK%7C795cd6413f372e6c27f32346040e5249; sessionid={}; steamLoginSecure={}; strInventoryLastContext=730_2",
+        steam_random_browser_id(),
+        session_id,
+        steam_login_secure
+    )
+}
+
+fn apply_steam_chrome_client_hints(headers: &mut HeaderMap) {
+    headers.insert("User-Agent", HeaderValue::from_static(STEAM_CHROME_UA));
+    headers.insert("Accept-Language", HeaderValue::from_static("zh-CN,zh;q=0.9"));
+    // 不声明 br/zstd,避免本机未装解码库时 body 解不开
+    headers.insert("Accept-Encoding", HeaderValue::from_static("gzip, deflate"));
+    headers.insert("Connection", HeaderValue::from_static("keep-alive"));
+    headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("empty"));
+    headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("cors"));
+    headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
+    headers.insert("sec-ch-ua", HeaderValue::from_static(STEAM_SEC_CH_UA));
+    headers.insert("sec-ch-ua-mobile", HeaderValue::from_static("?0"));
+    headers.insert("sec-ch-ua-platform", HeaderValue::from_static("\"Windows\""));
+}
+
 async fn http_get_steam(
     url: &str,
     label: &str,
@@ -1224,84 +1378,27 @@ async fn http_get_steam(
             .build()
             .map_err(|e| e.to_string())?;
         let mut headers = HeaderMap::new();
-        // 模拟 Chrome 浏览器的 User-Agent(与 sec-ch-ua 系列头保持一致)
-        headers.insert(
-            "User-Agent",
-            HeaderValue::from_static(
-                "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
-            ),
-        );
-        // Accept 头:模拟 XHR 请求
+        // 对齐浏览器 / tljb 可用脚本:Chrome151 + Sec-Fetch + Client Hints
+        apply_steam_chrome_client_hints(&mut headers);
         headers.insert(
             "Accept",
             HeaderValue::from_static("text/javascript, text/html, application/xml, text/xml, */*"),
         );
-        // 语言设置
-        headers.insert(
-            "Accept-Language",
-            HeaderValue::from_static("zh-CN,zh;q=0.9"),
-        );
-        // 压缩格式
-        headers.insert(
-            "Accept-Encoding",
-            HeaderValue::from_static("gzip, deflate, br, zstd"),
-        );
-        // 保持连接
-        headers.insert(
-            "Connection",
-            HeaderValue::from_static("keep-alive"),
-        );
-        // Referer:Steam 用此验证请求来源(必须与目标 URL 对应)
         if !referer_url.is_empty() {
             headers.insert(
                 "Referer",
                 HeaderValue::from_str(referer_url).map_err(|e| e.to_string())?,
             );
         }
-        // XHR 请求标记
         headers.insert(
             "X-Requested-With",
             HeaderValue::from_static("XMLHttpRequest"),
         );
-        // Prototype.js 版本头
         headers.insert(
             "X-Prototype-Version",
             HeaderValue::from_static("1.7"),
         );
-        // Fetch Metadata:same-origin CORS 请求
-        headers.insert(
-            "Sec-Fetch-Site",
-            HeaderValue::from_static("same-origin"),
-        );
-        headers.insert(
-            "Sec-Fetch-Mode",
-            HeaderValue::from_static("cors"),
-        );
-        headers.insert(
-            "Sec-Fetch-Dest",
-            HeaderValue::from_static("empty"),
-        );
-        // Client Hints:浏览器版本指纹
-        headers.insert(
-            "sec-ch-ua",
-            HeaderValue::from_static("\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\""),
-        );
-        headers.insert(
-            "sec-ch-ua-mobile",
-            HeaderValue::from_static("?0"),
-        );
-        headers.insert(
-            "sec-ch-ua-platform",
-            HeaderValue::from_static("\"Windows\""),
-        );
-        // 伪造的基础 Cookie(用于通过 Steam 的基础校验)
-        // 注意:Steam market `render` 接口额外携带 bMarketOptOut=1
-        let base_cookie = "timezoneOffset=28800,0; steamCountry=CN%7Cxxxxxxxxxxxx";
-        let cookie = if url.contains("/market/listings/") && url.contains("/render") {
-            format!("{base_cookie}; bMarketOptOut=1")
-        } else {
-            base_cookie.to_string()
-        };
+        let cookie = steam_market_cookie_anonymous();
         headers.insert("Cookie", HeaderValue::from_str(&cookie).map_err(|e| e.to_string())?);
 
         let request_start = std::time::Instant::now();
@@ -1319,8 +1416,23 @@ async fn http_get_steam(
                     .unwrap_or_default();
                 let elapsed_ms = request_start.elapsed().as_millis() as u64;
 
-                // 只有失败的请求才记录日志
-                if !status.is_success() {
+                if status.is_success() {
+                    log_api_call_ok(
+                        sweep_file,
+                        rid,
+                        account_name,
+                        label,
+                        "GET",
+                        url,
+                        &headers_for_log,
+                        None,
+                        status,
+                        &text,
+                        elapsed_ms,
+                        None,
+                    )
+                    .await;
+                } else {
                     log_api_call_error(
                         sweep_file,
                         rid,
@@ -1483,11 +1595,8 @@ async fn http_post_form_steam(
     }
 
     for attempt in 0..=retry_times {
-        // POST 请求不使用代理,直接连接 Steam
-        let msg = format!(
-            "[sweep:{}] Steam POST 直连(不使用代理)",
-            label
-        );
+        // buylisting 直连,不走全局代理池(list/render 仍走代理)
+        let msg = format!("[sweep:{}] Steam POST buylisting 直连(不走代理)", label);
         info!("{}", msg);
         sweep_log_diag(sweep_file, rid, "info", &msg).await;
         let client = reqwest::Client::builder()
@@ -1495,6 +1604,8 @@ async fn http_post_form_steam(
             .build()
             .map_err(|e| e.to_string())?;
         let mut headers = HeaderMap::new();
+        // 对齐浏览器 / tljb 可用购买脚本:完整 UA + Sec-Fetch + Client Hints
+        apply_steam_chrome_client_hints(&mut headers);
         headers.insert(
             "Content-Type",
             HeaderValue::from_static("application/x-www-form-urlencoded; charset=UTF-8"),
@@ -1505,7 +1616,6 @@ async fn http_post_form_steam(
         );
         headers.insert("Referer", HeaderValue::from_str(referer).map_err(|e| e.to_string())?);
         headers.insert("Cookie", HeaderValue::from_str(cookie).map_err(|e| e.to_string())?);
-        headers.insert("User-Agent", HeaderValue::from_static("Mozilla/5.0"));
         headers.insert("Accept", HeaderValue::from_static("*/*"));
 
         let request_start = std::time::Instant::now();
@@ -1530,7 +1640,7 @@ async fn http_post_form_steam(
                 let elapsed_ms = request_start.elapsed().as_millis() as u64;
 
                 // 检查是否是需要确认的响应(406 + need_confirmation=true)
-                // 这类响应不应重试,应交给上层处理确认逻辑
+                // 这类响应不应重试,应交给上层处理确认逻辑(确认走本地 steamAPI,不走代理)
                 let is_confirmation_needed = status == reqwest::StatusCode::NOT_ACCEPTABLE
                     && text.contains("need_confirmation")
                     && text.contains("true");
@@ -1541,12 +1651,27 @@ async fn http_post_form_steam(
                     );
                     info!("{}", confirm_msg);
                     sweep_log_diag(sweep_file, rid, "info", &confirm_msg).await;
+                    // 需确认属于购买正常路径,记成功向日志便于对账
+                    log_api_call_ok(
+                        sweep_file,
+                        rid,
+                        account_name,
+                        label,
+                        "POST",
+                        url,
+                        &headers_for_log,
+                        Some(form),
+                        status,
+                        &text,
+                        elapsed_ms,
+                        Some("need_confirmation"),
+                    )
+                    .await;
                     return Ok((status, text));
                 }
 
-                // 只有失败的请求才记录日志
-                if !status.is_success() {
-                    log_api_call_error(
+                if status.is_success() {
+                    log_api_call_ok(
                         sweep_file,
                         rid,
                         account_name,
@@ -1558,16 +1683,40 @@ async fn http_post_form_steam(
                         status,
                         &text,
                         elapsed_ms,
+                        None,
                     )
                     .await;
+                    return Ok((status, text));
                 }
 
-                let need_retry = attempt < retry_times
-                    && !status.is_success();
-                if !need_retry {
+                log_api_call_error(
+                    sweep_file,
+                    rid,
+                    account_name,
+                    label,
+                    "POST",
+                    url,
+                    &headers_for_log,
+                    Some(form),
+                    status,
+                    &text,
+                    elapsed_ms,
+                )
+                .await;
+
+                // 直连:业务/HTTP 失败一般直接返回;仅网络类可重试由 Err 分支处理
+                // 对 5xx 做有限次重试
+                let retryable_http = status.is_server_error();
+                if !retryable_http || attempt >= retry_times {
                     return Ok((status, text));
                 }
-                let wmsg = format!("[sweep:{}] Steam POST 请求失败,重试", label);
+                let wmsg = format!(
+                    "[sweep:{}] Steam POST 直连失败(http={}),准备重试 ({}/{})",
+                    label,
+                    status,
+                    attempt + 1,
+                    retry_times
+                );
                 warn!("{}", wmsg);
                 sweep_log_diag(sweep_file, rid, "warn", &wmsg).await;
             }
@@ -1621,12 +1770,11 @@ async fn http_post_form_steam(
                     elapsed_ms,
                 ).await;
 
-                let need_retry = attempt < retry_times;
-                if !need_retry {
+                if attempt >= retry_times {
                     return Err(last_err);
                 }
                 let wmsg = format!(
-                    "[sweep:{}] Steam POST 网络异常: {},等待重试",
+                    "[sweep:{}] Steam POST 直连网络异常: {},准备重试",
                     label, last_err
                 );
                 warn!("{}", wmsg);
@@ -1638,14 +1786,12 @@ async fn http_post_form_steam(
                 }
             }
         }
-        // 重试前检查取消标志
         if cancelled() {
             info!("[sweep:{}] HTTP POST 重试前检测到取消", label);
             return Err("扫货已取消".to_string());
         }
         if retry_interval_ms > 0 {
             interruptible_sleep_default(Duration::from_millis(retry_interval_ms)).await;
-            // 可中断 sleep 返回后再次检查(确保退出时立即响应)
             if cancelled() {
                 info!("[sweep:{}] HTTP POST 重试间隔中检测到取消", label);
                 return Err("扫货已取消".to_string());
@@ -1706,7 +1852,7 @@ async fn operate_confirmation(
         "ConfirmationId": cid,
         "Operation": "accept",
     });
-    // 不使用代理,直接连接本地 steamAPI
+    // 确认接口:直连本地 steamAPI,不走扫货 IP 代理池
     let client = reqwest::Client::builder()
         .timeout(Duration::from_secs(25))
         .build()
@@ -1754,6 +1900,7 @@ async fn operate_confirmation_with_retry(
     // 与 steamAPI(ulong ConfirmationId) 对齐,避免超过 i64::MAX 的确认号解析失败后跳过确认
     let cid = parse_steam_confirmation_id(confirmation_id)?;
 
+    // 直连本地 steamAPI,不走扫货 IP 代理池
     let client = reqwest::Client::builder()
         .timeout(Duration::from_secs(25))
         .build()
@@ -1912,10 +2059,7 @@ async fn post_buylisting(
         form.push(("billing_state".into(), "".into()));
     }
 
-    let cookie = format!(
-        "timezoneOffset=28800,0; sessionid={}; steamLoginSecure={};",
-        acc.session_id, acc.steam_login_secure
-    );
+    let cookie = steam_market_cookie_with_session(&acc.session_id, &acc.steam_login_secure);
     let (status, text) = http_post_form_steam(
         &url,
         steam_url,
@@ -1992,10 +2136,7 @@ async fn post_buylisting_with_retry(
             form.push(("billing_state".into(), "".into()));
         }
 
-        let cookie = format!(
-            "timezoneOffset=28800,0; sessionid={}; steamLoginSecure={};",
-            acc.session_id, acc.steam_login_secure
-        );
+        let cookie = steam_market_cookie_with_session(&acc.session_id, &acc.steam_login_secure);
 
         match http_post_form_steam(
             &url,
@@ -2299,13 +2440,12 @@ fn append_worker_log(worker_log_file: &std::path::Path, line: &Value) {
                 info!("[sweep][LOG-DEBUG] *** {} 触发清空! line_count={} >= threshold={} ***",
                       worker_log_file.file_name().unwrap_or_default().to_string_lossy(),
                       line_count, LOG_ROTATION_THRESHOLD);
-                // 清空文件,重新写入 meta 行(不备份)
+                // 清空文件,重新写入 meta 行(不备份);随后继续写入当前日志行,避免本条丢失
                 let meta = json!({"meta": {"cleared_at": sweep_log_ts()}});
                 if let Err(e) = std::fs::write(worker_log_file, format!("{}\n", meta)) {
                     warn!("[sweep] Worker {} 清空日志文件失败: {}", 
                           worker_log_file.file_name().unwrap_or_default().to_string_lossy(), e);
                 }
-                return; // 清空后不再写入,避免日志丢失
             }
         }
     }

BIN
client-tauri/steamSH_V1.0.10.exe


BIN
client-tauri/steamSH_V1.0.8.exe → client-tauri/steamSH_V1.0.9.exe


BIN
steam-client/steamAPI/bin/Debug/net8.0/SteamAuth.dll


BIN
steam-client/steamAPI/bin/Debug/net8.0/SteamAuth.pdb


BIN
steam-client/steamAPI/bin/Debug/net8.0/steamAPI.dll


BIN
steam-client/steamAPI/bin/Debug/net8.0/steamAPI.exe


BIN
steam-client/steamAPI/bin/Debug/net8.0/steamAPI.pdb


BIN
steam-client/steamAPI/obj/Debug/net8.0/apphost.exe


BIN
steam-client/steamAPI/obj/Debug/net8.0/ref/steamAPI.dll


BIN
steam-client/steamAPI/obj/Debug/net8.0/refint/steamAPI.dll


+ 1 - 1
steam-client/steamAPI/obj/Debug/net8.0/steamAPI.AssemblyInfo.cs

@@ -14,7 +14,7 @@ using System.Reflection;
 [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
 [assembly: System.Reflection.AssemblyDescriptionAttribute("Steam login verification local service")]
 [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
-[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+47ec8eb9ccaaff510181465548d1d79c3f01bc3c")]
+[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8a820c85418f1b3bdaa61a3152113ee695b04e0b")]
 [assembly: System.Reflection.AssemblyProductAttribute("steamAPI")]
 [assembly: System.Reflection.AssemblyTitleAttribute("steamAPI")]
 [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

+ 1 - 1
steam-client/steamAPI/obj/Debug/net8.0/steamAPI.AssemblyInfoInputs.cache

@@ -1 +1 @@
-1f64c8f5cb399b756a35788cd55bb7ae5e0cc535bf76f4b971c63c46b302072e
+97f909b33a714d34441154d6036f34c2693de032ecd9e16138d2580ff9ac572b

BIN
steam-client/steamAPI/obj/Debug/net8.0/steamAPI.csproj.AssemblyReference.cache


BIN
steam-client/steamAPI/obj/Debug/net8.0/steamAPI.dll


BIN
steam-client/steamAPI/obj/Debug/net8.0/steamAPI.pdb


+ 10 - 0
tljb/.gitignore

@@ -0,0 +1,10 @@
+# 敏感信息(本机打包可保留;勿推远程)
+config.json
+accounts/*.maFile
+accounts/*.mafile
+__pycache__/
+steamAPI/bin/
+steamAPI/obj/
+SteamAuth/bin/
+SteamAuth/obj/
+*.zip

+ 16 - 0
tljb/README.md

@@ -0,0 +1,16 @@
+# tljb — Steam 市场购买脚本(依赖服务器已有 localhost:5000 steamAPI)
+
+## Windows Server 快速执行
+
+服务器上已有 5000 端口服务时,在本目录打开 CMD:
+
+```bat
+pip install -r requirements.txt
+
+python steam_buy_confirm_test.py --listing-id 你的listingId --market-url "市场URL" --proxy ip:port
+
+python steam_buy_confirm_test.py --listing-id 你的listingId --subtotal 21 --steam-fee 1 --pub-fee 2 --proxy ip:port
+```
+
+账号/密码/maFile 读自 `config.json` 与 `accounts/`。  
+`--proxy` 仅作用于市场取价(render);登录、令牌确认、购买均直连。

+ 49 - 20
tljb/_prep_buy.py

@@ -1,27 +1,57 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
+"""登录 + 钱包 + 市场 render 取价,结果写入 _tmp_session.json(读 config.json)"""
 import json
 import sys
 from pathlib import Path
 
 import requests
 
-API = "http://localhost:5000"
-MAFILE = r"c:\Users\Administrator\Desktop\tsi19338.maFile"
-MARKET = "https://steamcommunity.com/market/listings/730/G180E208A023004"
-OUT = Path(r"d:\code\steam\tljb\_tmp_session.json")
+ROOT = Path(__file__).resolve().parent
+CFG = ROOT / "config.json"
+OUT = ROOT / "_tmp_session.json"
+
+
+def load_cfg():
+    with CFG.open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def resolve_mafile(mafile: str) -> str:
+    p = Path(mafile)
+    if not p.is_absolute():
+        p = ROOT / p
+    return str(p.resolve())
+
+
+def normalize_proxy(proxy: str):
+    raw = (proxy or "").strip()
+    if not raw:
+        return None
+    if raw.lower().startswith(("http://", "https://", "socks5://", "socks4://", "socks://")):
+        return raw
+    return f"http://{raw}"
 
 
 def main() -> int:
-    r = requests.post(
-        f"{API}/login",
-        json={
-            "Username": "tsi19338",
-            "Password": "ayx71486",
-            "MaFilePath": MAFILE,
-        },
-        timeout=90,
-    )
+    cfg = load_cfg()
+    api = (cfg.get("api_base") or "http://localhost:5000").rstrip("/")
+    mafile = resolve_mafile(cfg["mafile"])
+    market = (cfg.get("market_url") or "").strip()
+    if not market:
+        print("config.json 需要 market_url", file=sys.stderr)
+        return 2
+    proxy = normalize_proxy(cfg.get("proxy") or "")
+    proxies = {"http": proxy, "https": proxy} if proxy else None
+
+    body = {
+        "Username": cfg["username"],
+        "Password": cfg["password"],
+        "MaFilePath": mafile,
+    }
+    if proxy:
+        body["ProxyUrl"] = proxy
+    r = requests.post(f"{api}/login", json=body, timeout=90)
     print("login", r.status_code)
     r.raise_for_status()
     d = r.json()
@@ -35,18 +65,17 @@ def main() -> int:
         "https://store.steampowered.com/api/getfundwalletinfo/?cc=us&l=english",
         headers={"Cookie": f"steamLoginSecure={sls}"},
         timeout=20,
+        proxies=proxies,
     )
     print("wallet_http", wr.status_code, wr.text[:400])
 
-    url = (
-        MARKET
-        + "/render/?query=&start=0&count=10&country=US&language=schinese&currency=1"
-    )
+    url = market.rstrip("/") + "/render/?query=&start=0&count=10&country=US&language=schinese&currency=1"
     cookie = f"timezoneOffset=28800,0; sessionid={sid}; steamLoginSecure={sls}; bMarketOptOut=1"
     rr = requests.get(
         url,
-        headers={"User-Agent": "Mozilla/5.0", "Cookie": cookie, "Referer": MARKET},
+        headers={"User-Agent": "Mozilla/5.0", "Cookie": cookie, "Referer": market},
         timeout=30,
+        proxies=proxies,
     )
     print("render", rr.status_code)
     page = rr.json()
@@ -67,9 +96,9 @@ def main() -> int:
                 "session": sess,
                 "steamLoginSecure": sls,
                 "sessionId": sid,
-                "mafile": MAFILE,
+                "mafile": mafile,
                 "cheapest": rows[0] if rows else None,
-                "market": MARKET,
+                "market": market,
             },
             ensure_ascii=False,
             indent=2,

+ 3 - 3
tljb/config.example.json

@@ -3,12 +3,12 @@
   "api_base": "http://localhost:5000",
   "username": "",
   "password": "",
-  "mafile": "D:\\\\path\\\\to\\\\account.maFile",
+  "mafile": "accounts/account.maFile",
   "listing_id": "",
   "market_url": "https://steamcommunity.com/market/listings/730/ItemName",
   "subtotal": 0,
   "steam_fee": 0,
   "pub_fee": 0,
-  "is_us_account": true,
-  "proxy": ""
+  "is_us_account": false,
+  "proxy": "154.48.255.25:13465"
 }

+ 54 - 29
tljb/confirm_only.py

@@ -1,40 +1,66 @@
 #!/usr/bin/env python3
 # -*- coding: utf-8 -*-
-"""浠呯‘璁わ細login + trade-confirmations/operate"""
+"""仅确认:login + trade-confirmations/operate(读 config.json)"""
 import json
 import sys
 import time
+from pathlib import Path
 
 import requests
 
-API = "http://localhost:5000"
-USERNAME = "tsi19338"
-PASSWORD = "ayx71486"
-MAFILE = r"c:\Users\Administrator\Desktop\tsi19338.maFile"
-CONFIRMATION_ID = "1184627388269539651"
-PROXY = "http://127.0.0.1:7897"
+ROOT = Path(__file__).resolve().parent
+CFG = ROOT / "config.json"
 
 
-def login():
+def load_cfg():
+    with CFG.open("r", encoding="utf-8") as f:
+        return json.load(f)
+
+
+def resolve_mafile(mafile: str) -> str:
+    p = Path(mafile)
+    if not p.is_absolute():
+        p = ROOT / p
+    return str(p.resolve())
+
+
+def normalize_proxy(proxy: str):
+    raw = (proxy or "").strip()
+    if not raw:
+        return None
+    if raw.lower().startswith(("http://", "https://", "socks5://", "socks4://", "socks://")):
+        return raw
+    return f"http://{raw}"
+
+
+def login(cfg, proxy):
     body = {
-        "Username": USERNAME,
-        "Password": PASSWORD,
-        "MaFilePath": MAFILE,
+        "Username": cfg["username"],
+        "Password": cfg["password"],
+        "MaFilePath": resolve_mafile(cfg["mafile"]),
     }
-    if PROXY:
-        body["ProxyUrl"] = PROXY
+    if proxy:
+        body["ProxyUrl"] = proxy
+    api = cfg.get("api_base") or "http://localhost:5000"
     for attempt in range(1, 6):
         print(f"[login] attempt {attempt}/5")
-        r = requests.post(f"{API}/login", json=body, timeout=90)
+        r = requests.post(f"{api.rstrip('/')}/login", json=body, timeout=90)
         print(f"[login] HTTP {r.status_code} {r.text[:300]}")
         if r.status_code == 200:
-            return r.json()
+            return r.json(), api
         time.sleep(3)
     raise RuntimeError("login failed")
 
 
 def main():
-    data = login()
+    if len(sys.argv) < 2:
+        print("用法: python confirm_only.py <confirmation_id> [proxy]", file=sys.stderr)
+        return 2
+    confirmation_id = sys.argv[1]
+    cfg = load_cfg()
+    proxy = normalize_proxy(sys.argv[2] if len(sys.argv) > 2 else cfg.get("proxy") or "")
+
+    data, api = login(cfg, proxy)
     sess = data["session"]
     session = {
         "SteamId": sess["steamId"],
@@ -42,12 +68,13 @@ def main():
         "RefreshToken": sess["refreshToken"],
         "SessionId": sess["sessionId"],
     }
-    # list first
-    list_body = {"MaFilePath": MAFILE, "Session": session}
-    if PROXY:
-        list_body["ProxyUrl"] = PROXY
+    mafile = resolve_mafile(cfg["mafile"])
+
+    list_body = {"MaFilePath": mafile, "Session": session}
+    if proxy:
+        list_body["ProxyUrl"] = proxy
     print("[list] fetching confirmations...")
-    lr = requests.post(f"{API}/trade-confirmations/list", json=list_body, timeout=60)
+    lr = requests.post(f"{api.rstrip('/')}/trade-confirmations/list", json=list_body, timeout=60)
     print(f"[list] HTTP {lr.status_code} {lr.text[:2000]}")
     if lr.status_code == 200:
         lj = lr.json()
@@ -61,17 +88,17 @@ def main():
             }
 
     op_body = {
-        "MaFilePath": MAFILE,
+        "MaFilePath": mafile,
         "Session": session,
-        "ConfirmationId": int(CONFIRMATION_ID),
+        "ConfirmationId": int(confirmation_id),
         "Operation": "accept",
     }
-    if PROXY:
-        op_body["ProxyUrl"] = PROXY
+    if proxy:
+        op_body["ProxyUrl"] = proxy
 
     for attempt in range(1, 11):
-        print(f"[confirm] attempt {attempt}/10 ConfirmationId={CONFIRMATION_ID}")
-        r = requests.post(f"{API}/trade-confirmations/operate", json=op_body, timeout=60)
+        print(f"[confirm] attempt {attempt}/10 ConfirmationId={confirmation_id}")
+        r = requests.post(f"{api.rstrip('/')}/trade-confirmations/operate", json=op_body, timeout=60)
         print(f"[confirm] HTTP {r.status_code}")
         print(r.text[:2000])
         if r.status_code == 200:
@@ -79,7 +106,6 @@ def main():
             if j.get("success") in (True, 1, "1"):
                 print("[DONE] confirm success")
                 return 0
-            # refresh session for next try
             if j.get("session"):
                 s = j["session"]
                 op_body["Session"] = {
@@ -99,4 +125,3 @@ if __name__ == "__main__":
     except Exception:
         pass
     raise SystemExit(main())
-

+ 1 - 0
tljb/requirements.txt

@@ -0,0 +1 @@
+requests>=2.31.0

+ 3 - 0
tljb/run_buy.bat

@@ -0,0 +1,3 @@
+@echo off
+cd /d "%~dp0"
+python steam_buy_confirm_test.py %*

+ 276 - 56
tljb/steam_buy_confirm_test.py

@@ -23,6 +23,9 @@
   - requests
 
 用法示例:
+  # 使用本目录 config.json(推荐,服务器部署):
+  python steam_buy_confirm_test.py --listing-id 123 --market-url "https://..." --proxy 1.2.3.4:8080
+
   python steam_buy_confirm_test.py ^
     --username YOUR_STEAM_USER ^
     --password YOUR_STEAM_PASS ^
@@ -33,8 +36,14 @@
   # 已知价格(美分)时可跳过 render:
   python steam_buy_confirm_test.py ... --listing-id ... --subtotal 100 --steam-fee 10 --pub-fee 5
 
+  # 默认非美区(仅空 billing_state,与浏览器一致);美区账号才加:
+  #   --us-account
+
   # 只测确认(已有 confirmation_id,不真正下单):
   python steam_buy_confirm_test.py ... --confirm-only --confirmation-id 1234567890
+
+  # 代理:仅市场取价(render)可选走代理;登录/确认/购买均直连
+  python steam_buy_confirm_test.py ... --proxy 154.48.255.25:13465
 """
 
 from __future__ import annotations
@@ -44,6 +53,7 @@ import json
 import random
 import sys
 import time
+from pathlib import Path
 from typing import Any, Dict, Optional, Tuple
 try:
     import requests
@@ -54,7 +64,14 @@ except ImportError:
 
 STEAM_API_BASE_DEFAULT = "http://localhost:5000"
 BUY_URL = "https://steamcommunity.com/market/buylisting/{listing_id}"
-UA = "Mozilla/5.0"
+# 对齐常见 Chrome 指纹(与浏览器抓包一致)
+UA = (
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+    "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
+)
+SEC_CH_UA = '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"'
+ROOT_DIR = Path(__file__).resolve().parent
+DEFAULT_CONFIG = ROOT_DIR / "config.json"
 
 
 def log(step: str, msg: str, data: Any = None) -> None:
@@ -94,6 +111,9 @@ class SessionState:
         self.session_id: str = ""
         self.steam_login_secure: str = ""
         self.mafile_path: str = ""
+        # 浏览器侧常见附属 Cookie(非登录必需,降低“脚本味”)
+        self.browser_id: str = str(random.randint(10**17, 10**18 - 1))
+        self.steam_country: str = "HK%7C795cd6413f372e6c27f32346040e5249"
 
     def apply_login_response(self, resp: dict) -> None:
         sess = resp.get("session") or {}
@@ -109,6 +129,10 @@ class SessionState:
                 self.steam_login_secure = value
             if name == "sessionid" and value:
                 self.session_id = value
+            if name == "browserid" and value:
+                self.browser_id = value
+            if name == "steamCountry" and value:
+                self.steam_country = value
 
         # 与 SessionData.GetSteamLoginSecure 一致:steamid%7C%7CaccessToken
         if not self.steam_login_secure and self.steam_id and self.access_token:
@@ -127,11 +151,21 @@ class SessionState:
         if self.steam_id and self.access_token:
             self.steam_login_secure = f"{self.steam_id}%7C%7C{self.access_token}"
 
-    def cookie_header(self) -> str:
-        return (
-            f"timezoneOffset=28800,0; sessionid={self.session_id}; "
-            f"steamLoginSecure={self.steam_login_secure};"
-        )
+    def cookie_header(self, for_market: bool = True) -> str:
+        """尽量贴近浏览器市场请求 Cookie 组合。"""
+        parts = [
+            "ActListPageSize=10",
+            "timezoneOffset=28800,0",
+            "timezoneName=Asia%2FShanghai",
+            f"browserid={self.browser_id}",
+            f"steamCountry={self.steam_country}",
+            f"sessionid={self.session_id}",
+            f"steamLoginSecure={self.steam_login_secure}",
+        ]
+        if for_market:
+            parts.insert(1, "bMarketOptOut=1")
+            parts.append("strInventoryLastContext=730_2")
+        return "; ".join(parts) + ";"
 
     def to_steam_api_session(self) -> dict:
         return {
@@ -142,6 +176,27 @@ class SessionState:
         }
 
 
+def browser_market_headers(referer: str, cookie: str) -> Dict[str, str]:
+    """对齐浏览器对 steamcommunity.com/market 的 XHR 指纹头。"""
+    return {
+        "Accept": "*/*",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+        "Accept-Encoding": "gzip, deflate",
+        "Connection": "keep-alive",
+        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
+        "Origin": "https://steamcommunity.com",
+        "Referer": referer,
+        "Cookie": cookie,
+        "User-Agent": UA,
+        "Sec-Fetch-Dest": "empty",
+        "Sec-Fetch-Mode": "cors",
+        "Sec-Fetch-Site": "same-origin",
+        "sec-ch-ua": SEC_CH_UA,
+        "sec-ch-ua-mobile": "?0",
+        "sec-ch-ua-platform": '"Windows"',
+    }
+
+
 def steam_api_login(
     api_base: str,
     username: str,
@@ -182,8 +237,31 @@ def steam_api_login(
     return st
 
 
+def normalize_proxy(proxy: Optional[str]) -> Optional[str]:
+    """
+    规范化代理地址,供 steamAPI ProxyUrl 与 requests 使用。
+    支持:
+      - 154.48.255.25:13465
+      - http://154.48.255.25:13465
+      - https://host:port / socks5://host:port
+    """
+    if proxy is None:
+        return None
+    raw = str(proxy).strip()
+    if not raw:
+        return None
+    lower = raw.lower()
+    if lower.startswith(("http://", "https://", "socks5://", "socks4://", "socks://")):
+        return raw
+    # host:port(可含 IPv6 方括号,如 [2001:db8::1]:8080)
+    if "://" not in raw:
+        return f"http://{raw}"
+    return raw
+
+
 def http_proxies(proxy_url: Optional[str]) -> Optional[Dict[str, str]]:
-    """本机 HTTP 代理,如 http://127.0.0.1:7897,用于直连 Steam 超时场景。"""
+    """HTTP(S) 代理,如 http://154.48.255.25:13465。"""
+    proxy_url = normalize_proxy(proxy_url)
     if not proxy_url:
         return None
     return {"http": proxy_url, "https": proxy_url}
@@ -205,9 +283,17 @@ def fetch_listing_price(
     url = f"{base}/?query=&start=0&count=100&country=US&language=schinese&currency=1"
     headers = {
         "User-Agent": UA,
-        "Cookie": session.cookie_header() + " bMarketOptOut=1",
+        "Cookie": session.cookie_header(for_market=True),
         "Referer": market_url.split("/render")[0],
-        "Accept": "*/*",
+        "Accept": "text/javascript, text/html, application/xml, text/xml, */*",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+        "X-Requested-With": "XMLHttpRequest",
+        "Sec-Fetch-Dest": "empty",
+        "Sec-Fetch-Mode": "cors",
+        "Sec-Fetch-Site": "same-origin",
+        "sec-ch-ua": SEC_CH_UA,
+        "sec-ch-ua-mobile": "?0",
+        "sec-ch-ua-platform": '"Windows"',
     }
     log("2-render", f"GET {url}", {"proxy": proxy_url or "direct"})
     r = requests.get(url, headers=headers, timeout=30, proxies=http_proxies(proxy_url))
@@ -273,16 +359,10 @@ def post_buylisting(
     if is_us_account:
         form.update(random_us_addr())
     else:
+        # 与浏览器一致:只传空 billing_state,不附带美区地址字段
         form["billing_state"] = ""
 
-    headers = {
-        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
-        "Origin": "https://steamcommunity.com",
-        "Referer": referer,
-        "Cookie": session.cookie_header(),
-        "User-Agent": UA,
-        "Accept": "*/*",
-    }
+    headers = browser_market_headers(referer, session.cookie_header(for_market=True))
     log(
         label,
         f"POST {url}",
@@ -291,7 +371,14 @@ def post_buylisting(
             "subtotal": subtotal,
             "fee": fee,
             "total": total,
+            "is_us_account": is_us_account,
+            "billing_fields": (
+                "random_us_addr"
+                if is_us_account
+                else "billing_state="
+            ),
             "proxy": proxy_url or "direct",
+            "ua": UA,
         },
     )
     r = requests.post(
@@ -299,28 +386,45 @@ def post_buylisting(
     )
     text = r.text
     log(label, f"HTTP {r.status_code}", clip(text, 2000))
+    parsed: dict = {}
     try:
-        parsed = r.json() if text.strip() else {}
+        if text.strip():
+            data = r.json()
+            parsed = data if isinstance(data, dict) else {"_raw": data}
     except Exception:
         parsed = {"_raw": text}
     return r.status_code, text, parsed
 
 
-def wallet_success(parsed: dict) -> bool:
+def wallet_success(parsed: Optional[dict]) -> bool:
+    if not isinstance(parsed, dict):
+        return False
     w = parsed.get("wallet_info")
     if isinstance(w, dict):
         return w.get("success") in (1, "1", True)
     return False
 
 
-def extract_confirmation_id(parsed: dict) -> str:
+def extract_confirmation_id(parsed: Optional[dict]) -> str:
+    if not isinstance(parsed, dict):
+        return ""
     conf = parsed.get("confirmation") or {}
+    if not isinstance(conf, dict):
+        return ""
     cid = conf.get("confirmation_id")
     if cid is None:
         return ""
     return str(cid).strip()
 
 
+def is_rate_limited(status: int, raw: str, parsed: Optional[dict]) -> bool:
+    if status == 429:
+        return True
+    if isinstance(parsed, dict) and str(parsed.get("message", "")).lower().find("too many") >= 0:
+        return True
+    return "too many requests" in (raw or "").lower()
+
+
 def operate_confirmation(
     api_base: str,
     session: SessionState,
@@ -416,6 +520,7 @@ def purchase_one_listing(
     confirm_retries: int = 10,
     buy2_retries: int = 10,
     proxy_url: Optional[str] = None,
+    buy_retries: int = 8,
 ) -> Tuple[bool, str]:
     """
     严格复现 purchase_one_listing:
@@ -424,22 +529,34 @@ def purchase_one_listing(
     fee = steam_fee + pub_fee
     total = subtotal + fee
 
-    status, raw, first = post_buylisting(
-        session,
-        listing_id,
-        referer,
-        subtotal,
-        fee,
-        total,
-        "0",
-        is_us_account,
-        "3-buy",
-        proxy_url=proxy_url,
-    )
-    if wallet_success(first):
-        return True, "购买成功(无需确认)"
+    status, raw, first = 0, "", {}
+    for attempt in range(1, buy_retries + 1):
+        status, raw, first = post_buylisting(
+            session,
+            listing_id,
+            referer,
+            subtotal,
+            fee,
+            total,
+            "0",
+            is_us_account,
+            f"3-buy#{attempt}" if buy_retries > 1 else "3-buy",
+            proxy_url=proxy_url,
+        )
+        if wallet_success(first):
+            return True, "购买成功(无需确认)"
+        if is_rate_limited(status, raw, first):
+            wait_s = min(5 * attempt, 30)
+            log("3-buy", f"HTTP {status} 限流,{wait_s}s 后重试 ({attempt}/{buy_retries})")
+            if attempt < buy_retries:
+                time.sleep(wait_s)
+                continue
+            return False, f"购买被限流(HTTP {status}),请换代理或稍后重试: {clip(raw)}"
+        break
 
-    need = first.get("need_confirmation") is True or first.get("need_confirmation") == "true"
+    need = False
+    if isinstance(first, dict):
+        need = first.get("need_confirmation") is True or first.get("need_confirmation") == "true"
     # 客户端也接受 HTTP 406 + need_confirmation
     if need or (status == 406 and "need_confirmation" in raw and "true" in raw):
         confirmation_id = extract_confirmation_id(first)
@@ -448,9 +565,9 @@ def purchase_one_listing(
 
         log("3-buy", f"需要确认 confirmation_id={confirmation_id}")
 
-        # 可选:列出当前待确认,便于对照 id / creator_id
+        # 可选:列出当前待确认,便于对照 id / creator_id(确认不走购买代理)
         try:
-            confs = list_confirmations(api_base, session, proxy_url)
+            confs = list_confirmations(api_base, session, proxy_url=None)
             log("confirm-list", "当前待确认列表", confs)
         except Exception as e:
             log("confirm-list", f"列确认失败(忽略): {e}")
@@ -458,7 +575,7 @@ def purchase_one_listing(
         ok = False
         try:
             ok = operate_confirmation(
-                api_base, session, confirmation_id, max_retries=confirm_retries, proxy_url=proxy_url
+                api_base, session, confirmation_id, max_retries=confirm_retries, proxy_url=None
             )
         except Exception as e:
             log("4-confirm", f"连续失败,仍尝试 buy2: {e}")
@@ -481,7 +598,14 @@ def purchase_one_listing(
                 proxy_url=proxy_url,
             )
             last_status, last_raw, last_parsed = status2, raw2, second
-            if status2 == 406 and second.get("need_confirmation") is True:
+            if is_rate_limited(status2, raw2, second):
+                wait_s = min(5 * attempt, 30)
+                log("5-buy2", f"HTTP {status2} 限流,{wait_s}s 后重试 ({attempt}/{buy2_retries})")
+                if attempt < buy2_retries:
+                    time.sleep(wait_s)
+                    continue
+                return False, f"确认后购买被限流(HTTP {status2}): {clip(raw2)}"
+            if status2 == 406 and isinstance(second, dict) and second.get("need_confirmation") is True:
                 # 客户端 buy2 遇到再次 need_confirmation 会直接返回,不继续重试该分支
                 return False, f"buy2 仍返回 need_confirmation: {clip(raw2)}"
             if wallet_success(second):
@@ -494,14 +618,83 @@ def purchase_one_listing(
     return False, f"购买失败: HTTP {status} {clip(raw)}"
 
 
+def resolve_mafile_path(mafile: str) -> str:
+    """相对路径按本脚本目录解析;已是绝对路径则原样返回。"""
+    raw = (mafile or "").strip()
+    if not raw:
+        return raw
+    p = Path(raw)
+    if not p.is_absolute():
+        p = ROOT_DIR / p
+    return str(p.resolve())
+
+
+def load_config(path: Optional[str]) -> Dict[str, Any]:
+    cfg_path = Path(path) if path else DEFAULT_CONFIG
+    if not cfg_path.is_absolute():
+        cfg_path = ROOT_DIR / cfg_path
+    if not cfg_path.is_file():
+        return {}
+    with cfg_path.open("r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, dict):
+        raise RuntimeError(f"配置文件格式错误: {cfg_path}")
+    return data
+
+
+def apply_config_defaults(args: argparse.Namespace, cfg: Dict[str, Any]) -> None:
+    """命令行未显式传入时,用 config.json 补齐。"""
+    def _set_if_empty(name: str, cfg_key: str, transform=None) -> None:
+        cur = getattr(args, name, None)
+        empty = cur is None or cur == "" or cur == 0 or cur is False
+        # bool / store_true 不走这里
+        if name in ("us_account", "no_us_account", "confirm_only", "list_conf_only"):
+            return
+        if name in ("subtotal", "steam_fee", "pub_fee", "confirm_retries", "buy2_retries"):
+            if cur not in (None, 0):
+                return
+        elif not empty and cur is not None:
+            # 字符串类:非空则保留 CLI
+            if isinstance(cur, str) and cur.strip() != "":
+                return
+            if not isinstance(cur, str) and cur:
+                return
+        if cfg_key not in cfg:
+            return
+        val = cfg.get(cfg_key)
+        if val is None or val == "":
+            return
+        if transform:
+            val = transform(val)
+        setattr(args, name, val)
+
+    _set_if_empty("api_base", "api_base")
+    _set_if_empty("username", "username")
+    _set_if_empty("password", "password")
+    _set_if_empty("mafile", "mafile")
+    _set_if_empty("listing_id", "listing_id", lambda v: str(v))
+    _set_if_empty("market_url", "market_url")
+    _set_if_empty("subtotal", "subtotal", int)
+    _set_if_empty("steam_fee", "steam_fee", int)
+    _set_if_empty("pub_fee", "pub_fee", int)
+    _set_if_empty("proxy", "proxy")
+    if not args.us_account and not args.no_us_account and bool(cfg.get("is_us_account")):
+        args.us_account = True
+
+
 def build_arg_parser() -> argparse.ArgumentParser:
     p = argparse.ArgumentParser(
         description="测试 client-tauri Steam 市场购买 + 令牌确认流程"
     )
-    p.add_argument("--api-base", default=STEAM_API_BASE_DEFAULT, help="本地 steamAPI 地址")
-    p.add_argument("--username", required=True, help="Steam 账号")
-    p.add_argument("--password", required=True, help="Steam 密码")
-    p.add_argument("--mafile", required=True, help=".maFile 绝对路径")
+    p.add_argument(
+        "--config",
+        default=str(DEFAULT_CONFIG),
+        help=f"配置文件路径(默认 {DEFAULT_CONFIG.name})",
+    )
+    p.add_argument("--api-base", default="", help="本地 steamAPI 地址")
+    p.add_argument("--username", default="", help="Steam 账号(可写在 config.json)")
+    p.add_argument("--password", default="", help="Steam 密码(可写在 config.json)")
+    p.add_argument("--mafile", default="", help=".maFile 路径,相对 tljb 目录或绝对路径")
     p.add_argument("--listing-id", default="", help="Steam market listing id(购买目标)")
     p.add_argument(
         "--market-url",
@@ -511,12 +704,20 @@ def build_arg_parser() -> argparse.ArgumentParser:
     p.add_argument("--subtotal", type=int, default=0, help="converted_price(美分),不传则从 render 取")
     p.add_argument("--steam-fee", type=int, default=0, help="converted_steam_fee(美分)")
     p.add_argument("--pub-fee", type=int, default=0, help="converted_publisher_fee(美分)")
-    p.add_argument("--us-account", action="store_true", default=True, help="美区账号(默认 True,带随机账单地址)")
-    p.add_argument("--no-us-account", action="store_true", help="非美区账号")
+    p.add_argument(
+        "--us-account",
+        action="store_true",
+        help="美区账号:附带随机账单地址(默认关闭;浏览器非美区仅传空 billing_state)",
+    )
+    p.add_argument(
+        "--no-us-account",
+        action="store_true",
+        help="非美区账号(默认行为,可省略)",
+    )
     p.add_argument(
         "--proxy",
         default="",
-        help="可选本机 HTTP 代理,如 http://127.0.0.1:7897(登录/确认/buylisting 均使用)",
+        help="仅用于市场取价(render);登录/确认/购买均直连。格式 host:port,如 154.48.255.25:13465",
     )
     p.add_argument("--confirm-retries", type=int, default=10)
     p.add_argument("--buy2-retries", type=int, default=10)
@@ -536,8 +737,28 @@ def build_arg_parser() -> argparse.ArgumentParser:
 
 def main() -> int:
     args = build_arg_parser().parse_args()
-    is_us = not args.no_us_account
-    proxy = args.proxy or None
+    cfg = load_config(args.config)
+    apply_config_defaults(args, cfg)
+
+    if not args.api_base:
+        args.api_base = STEAM_API_BASE_DEFAULT
+    if not args.username or not args.password or not args.mafile:
+        print(
+            "缺少账号信息:请在 config.json 填写 username/password/mafile,或通过命令行传入",
+            file=sys.stderr,
+        )
+        return 2
+
+    args.mafile = resolve_mafile_path(args.mafile)
+    if not Path(args.mafile).is_file():
+        print(f"maFile 不存在: {args.mafile}", file=sys.stderr)
+        return 2
+
+    # 默认非美区(与浏览器一致:仅 billing_state 空);显式 --us-account 才带随机地址
+    is_us = bool(args.us_account) and not args.no_us_account
+    proxy = normalize_proxy(args.proxy)
+    if proxy:
+        log("proxy", f"仅取价(render)走代理: {proxy}(登录/确认/购买直连)")
 
     # 健康检查 steamAPI
     try:
@@ -550,20 +771,19 @@ def main() -> int:
         _ = r.status_code  # 只要能连上即可
     except requests.RequestException as e:
         print(
-            f"无法连接 steamAPI ({args.api_base}),请先启动:\n"
-            f"  cd steam-client\\steamAPI\n"
-            f'  dotnet run --urls "http://localhost:5000"\n'
+            f"无法连接 steamAPI ({args.api_base}),请确认本机 5000 端口服务已启动。\n"
             f"错误: {e}",
             file=sys.stderr,
         )
         return 2
 
+    # 登录不走代理
     session = steam_api_login(
-        args.api_base, args.username, args.password, args.mafile, proxy_url=proxy
+        args.api_base, args.username, args.password, args.mafile, proxy_url=None
     )
 
     if args.list_conf_only:
-        confs = list_confirmations(args.api_base, session, proxy)
+        confs = list_confirmations(args.api_base, session, proxy_url=None)
         log("DONE", f"待确认 {len(confs)} 条", confs)
         return 0
 
@@ -571,10 +791,10 @@ def main() -> int:
         if not args.confirmation_id:
             print("--confirm-only 需要 --confirmation-id", file=sys.stderr)
             return 2
-        confs = list_confirmations(args.api_base, session, proxy)
+        confs = list_confirmations(args.api_base, session, proxy_url=None)
         log("confirm-list", "确认前列表", confs)
         ok = operate_confirmation(
-            args.api_base, session, args.confirmation_id, args.confirm_retries, proxy
+            args.api_base, session, args.confirmation_id, args.confirm_retries, proxy_url=None
         )
         log("DONE", "确认结果", {"success": ok})
         return 0 if ok else 1
@@ -605,7 +825,7 @@ def main() -> int:
         is_us_account=is_us,
         confirm_retries=args.confirm_retries,
         buy2_retries=args.buy2_retries,
-        proxy_url=proxy,
+        proxy_url=None,  # 购买直连,不走代理
     )
     log("DONE", msg, {"success": ok})
     return 0 if ok else 1

+ 842 - 0
tljb/steam_buy_confirm_test1.py

@@ -0,0 +1,842 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+复现 client-tauri 扫货购买 + 手机令牌确认流程(对照 buff_sweep_runner.rs::purchase_one_listing)。
+
+流程(与客户端一致):
+  1) POST http://localhost:5000/login          → 拿 session / steamLoginSecure
+  2) (可选)GET market/.../render             → 用 listing_id 取价格
+  3) POST steamcommunity.com/market/buylisting/{listing_id}
+       confirmation=0
+       → 若 wallet_info.success==1:无需确认,成功结束
+       → 若 need_confirmation==true:取出 confirmation.confirmation_id
+  4) POST http://localhost:5000/trade-confirmations/operate
+       Operation=accept, ConfirmationId=confirmation_id
+       (steamAPI 内部会 FetchConfirmations,按 id 或 creator_id 匹配后 AcceptConfirmation)
+  5) 再次 POST buylisting/{listing_id}
+       confirmation=<confirmation_id>
+       → 检查 wallet_info.success==1
+
+依赖:
+  - 本地 steamAPI 已启动: dotnet run --urls "http://localhost:5000"
+  - 该账号的 .maFile(含 identity_secret / device_id)
+  - requests
+
+用法示例:
+  # 使用本目录 config.json(推荐,服务器部署):
+  python steam_buy_confirm_test.py --listing-id 123 --market-url "https://..." --proxy 1.2.3.4:8080
+
+  python steam_buy_confirm_test.py ^
+    --username YOUR_STEAM_USER ^
+    --password YOUR_STEAM_PASS ^
+    --mafile "D:\\path\\to\\account.maFile" ^
+    --listing-id 1234567890123456789 ^
+    --market-url "https://steamcommunity.com/market/listings/730/ItemName"
+
+  # 已知价格(美分)时可跳过 render:
+  python steam_buy_confirm_test.py ... --listing-id ... --subtotal 100 --steam-fee 10 --pub-fee 5
+
+  # 默认非美区(仅空 billing_state,与浏览器一致);美区账号才加:
+  #   --us-account
+
+  # 只测确认(已有 confirmation_id,不真正下单):
+  python steam_buy_confirm_test.py ... --confirm-only --confirmation-id 1234567890
+
+  # 代理:仅市场取价/购买走代理;登录与令牌确认不走代理
+  python steam_buy_confirm_test.py ... --proxy 154.48.255.25:13465
+  python steam_buy_confirm_test.py ... --proxy http://154.48.255.25:13465
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import random
+import sys
+import time
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple
+try:
+    import requests
+except ImportError:
+    print("请先安装: pip install requests", file=sys.stderr)
+    sys.exit(1)
+
+
+STEAM_API_BASE_DEFAULT = "http://localhost:5000"
+BUY_URL = "https://steamcommunity.com/market/buylisting/{listing_id}"
+# 对齐常见 Chrome 指纹(与浏览器抓包一致)
+UA = (
+    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
+    "(KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36"
+)
+SEC_CH_UA = '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"'
+ROOT_DIR = Path(__file__).resolve().parent
+DEFAULT_CONFIG = ROOT_DIR / "config.json"
+
+
+def log(step: str, msg: str, data: Any = None) -> None:
+    print(f"\n===== [{step}] {msg} =====")
+    if data is not None:
+        if isinstance(data, (dict, list)):
+            print(json.dumps(data, ensure_ascii=False, indent=2)[:4000])
+        else:
+            text = str(data)
+            print(text if len(text) <= 4000 else text[:4000] + "...(truncated)")
+
+
+def clip(text: str, n: int = 500) -> str:
+    text = text or ""
+    return text if len(text) <= n else text[:n] + "..."
+
+
+def random_us_addr() -> Dict[str, str]:
+    """对应 buff_sweep_runner.rs::random_addr_fields(美区账号账单地址)。"""
+    return {
+        "first_name": f"user{random.randint(1000, 9999)}",
+        "last_name": f"ln{random.randint(1000, 9999)}",
+        "billing_address": f"{random.randint(10, 9999)} Main St",
+        "billing_address_two": f"Apt {random.randint(1, 999)}",
+        "billing_country": "US",
+        "billing_city": f"City{random.randint(10, 999)}",
+        "billing_state": "AK",
+        "billing_postal_code": str(random.randint(10000, 99999)),
+    }
+
+
+class SessionState:
+    def __init__(self) -> None:
+        self.steam_id: int = 0
+        self.access_token: str = ""
+        self.refresh_token: str = ""
+        self.session_id: str = ""
+        self.steam_login_secure: str = ""
+        self.mafile_path: str = ""
+        # 浏览器侧常见附属 Cookie(非登录必需,降低“脚本味”)
+        self.browser_id: str = str(random.randint(10**17, 10**18 - 1))
+        self.steam_country: str = "HK%7C795cd6413f372e6c27f32346040e5249"
+
+    def apply_login_response(self, resp: dict) -> None:
+        sess = resp.get("session") or {}
+        self.steam_id = int(sess.get("steamId") or 0)
+        self.access_token = sess.get("accessToken") or ""
+        self.refresh_token = sess.get("refreshToken") or ""
+        self.session_id = sess.get("sessionId") or self.session_id
+
+        for c in resp.get("cookies") or []:
+            name = c.get("name")
+            value = c.get("value") or ""
+            if name == "steamLoginSecure" and value:
+                self.steam_login_secure = value
+            if name == "sessionid" and value:
+                self.session_id = value
+            if name == "browserid" and value:
+                self.browser_id = value
+            if name == "steamCountry" and value:
+                self.steam_country = value
+
+        # 与 SessionData.GetSteamLoginSecure 一致:steamid%7C%7CaccessToken
+        if not self.steam_login_secure and self.steam_id and self.access_token:
+            self.steam_login_secure = f"{self.steam_id}%7C%7C{self.access_token}"
+
+    def apply_operate_session(self, resp: dict) -> None:
+        sess = resp.get("session") or {}
+        if sess.get("sessionId"):
+            self.session_id = sess["sessionId"]
+        if sess.get("accessToken"):
+            self.access_token = sess["accessToken"]
+        if sess.get("refreshToken"):
+            self.refresh_token = sess["refreshToken"]
+        if sess.get("steamId"):
+            self.steam_id = int(sess["steamId"])
+        if self.steam_id and self.access_token:
+            self.steam_login_secure = f"{self.steam_id}%7C%7C{self.access_token}"
+
+    def cookie_header(self, for_market: bool = True) -> str:
+        """尽量贴近浏览器市场请求 Cookie 组合。"""
+        parts = [
+            "ActListPageSize=10",
+            "timezoneOffset=28800,0",
+            "timezoneName=Asia%2FShanghai",
+            f"browserid={self.browser_id}",
+            f"steamCountry={self.steam_country}",
+            f"sessionid={self.session_id}",
+            f"steamLoginSecure={self.steam_login_secure}",
+        ]
+        if for_market:
+            parts.insert(1, "bMarketOptOut=1")
+            parts.append("strInventoryLastContext=730_2")
+        return "; ".join(parts) + ";"
+
+    def to_steam_api_session(self) -> dict:
+        return {
+            "SteamId": self.steam_id,
+            "AccessToken": self.access_token,
+            "RefreshToken": self.refresh_token,
+            "SessionId": self.session_id,
+        }
+
+
+def browser_market_headers(referer: str, cookie: str) -> Dict[str, str]:
+    """对齐浏览器对 steamcommunity.com/market 的 XHR 指纹头。"""
+    return {
+        "Accept": "*/*",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+        "Accept-Encoding": "gzip, deflate",
+        "Connection": "keep-alive",
+        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
+        "Origin": "https://steamcommunity.com",
+        "Referer": referer,
+        "Cookie": cookie,
+        "User-Agent": UA,
+        "Sec-Fetch-Dest": "empty",
+        "Sec-Fetch-Mode": "cors",
+        "Sec-Fetch-Site": "same-origin",
+        "sec-ch-ua": SEC_CH_UA,
+        "sec-ch-ua-mobile": "?0",
+        "sec-ch-ua-platform": '"Windows"',
+    }
+
+
+def steam_api_login(
+    api_base: str,
+    username: str,
+    password: str,
+    mafile: str,
+    proxy_url: Optional[str] = None,
+) -> SessionState:
+    url = f"{api_base.rstrip('/')}/login"
+    body: Dict[str, Any] = {
+        "Username": username,
+        "Password": password,
+        "MaFilePath": mafile,
+    }
+    if proxy_url:
+        body["ProxyUrl"] = proxy_url
+    log("1-login", f"POST {url}", {"Username": username, "MaFilePath": mafile})
+    r = requests.post(url, json=body, timeout=60)
+    text = r.text
+    log("1-login", f"HTTP {r.status_code}", clip(text, 1500))
+    if r.status_code != 200:
+        raise RuntimeError(f"login 失败: HTTP {r.status_code} {clip(text)}")
+    data = r.json()
+    st = SessionState()
+    st.mafile_path = mafile
+    st.apply_login_response(data)
+    if not st.session_id or not st.steam_login_secure or not st.access_token:
+        raise RuntimeError(f"login 响应缺字段: {json.dumps(data, ensure_ascii=False)[:800]}")
+    log(
+        "1-login",
+        "session OK",
+        {
+            "steamId": st.steam_id,
+            "sessionId": st.session_id,
+            "steamLoginSecure_len": len(st.steam_login_secure),
+            "accessToken_len": len(st.access_token),
+        },
+    )
+    return st
+
+
+def normalize_proxy(proxy: Optional[str]) -> Optional[str]:
+    """
+    规范化代理地址,供 steamAPI ProxyUrl 与 requests 使用。
+    支持:
+      - 154.48.255.25:13465
+      - http://154.48.255.25:13465
+      - https://host:port / socks5://host:port
+    """
+    if proxy is None:
+        return None
+    raw = str(proxy).strip()
+    if not raw:
+        return None
+    lower = raw.lower()
+    if lower.startswith(("http://", "https://", "socks5://", "socks4://", "socks://")):
+        return raw
+    # host:port(可含 IPv6 方括号,如 [2001:db8::1]:8080)
+    if "://" not in raw:
+        return f"http://{raw}"
+    return raw
+
+
+def http_proxies(proxy_url: Optional[str]) -> Optional[Dict[str, str]]:
+    """HTTP(S) 代理,如 http://154.48.255.25:13465。"""
+    proxy_url = normalize_proxy(proxy_url)
+    if not proxy_url:
+        return None
+    return {"http": proxy_url, "https": proxy_url}
+
+
+def fetch_listing_price(
+    market_url: str,
+    listing_id: str,
+    session: SessionState,
+    proxy_url: Optional[str] = None,
+) -> Tuple[int, int, int]:
+    """
+    从 market listing render 取 converted_price / steam_fee / publisher_fee(美分)。
+    对应客户端扫货取价逻辑。
+    """
+    base = market_url.rstrip("/")
+    if not base.endswith("/render"):
+        base = base + "/render"
+    url = f"{base}/?query=&start=0&count=100&country=US&language=schinese&currency=1"
+    headers = {
+        "User-Agent": UA,
+        "Cookie": session.cookie_header(for_market=True),
+        "Referer": market_url.split("/render")[0],
+        "Accept": "text/javascript, text/html, application/xml, text/xml, */*",
+        "Accept-Language": "zh-CN,zh;q=0.9",
+        "X-Requested-With": "XMLHttpRequest",
+        "Sec-Fetch-Dest": "empty",
+        "Sec-Fetch-Mode": "cors",
+        "Sec-Fetch-Site": "same-origin",
+        "sec-ch-ua": SEC_CH_UA,
+        "sec-ch-ua-mobile": "?0",
+        "sec-ch-ua-platform": '"Windows"',
+    }
+    log("2-render", f"GET {url}", {"proxy": proxy_url or "direct"})
+    r = requests.get(url, headers=headers, timeout=30, proxies=http_proxies(proxy_url))
+    log("2-render", f"HTTP {r.status_code}", clip(r.text, 800))
+    r.raise_for_status()
+    page = r.json()
+    listing = (page.get("listinginfo") or {}).get(str(listing_id))
+    if not listing:
+        # 有时 key 是数字型,再扫一遍
+        for k, v in (page.get("listinginfo") or {}).items():
+            if str(k) == str(listing_id):
+                listing = v
+                break
+    if not listing:
+        raise RuntimeError(
+            f"render 中找不到 listing_id={listing_id}。"
+            f"当前 keys={list((page.get('listinginfo') or {}).keys())[:20]}"
+        )
+    subtotal = int(listing.get("converted_price") or 0)
+    steam_fee = int(listing.get("converted_steam_fee") or 0)
+    pub_fee = int(listing.get("converted_publisher_fee") or 0)
+    if subtotal <= 0:
+        raise RuntimeError(f"listing 价格无效: {listing}")
+    log(
+        "2-render",
+        "价格",
+        {
+            "listing_id": listing_id,
+            "subtotal": subtotal,
+            "steam_fee": steam_fee,
+            "pub_fee": pub_fee,
+            "total": subtotal + steam_fee + pub_fee,
+        },
+    )
+    return subtotal, steam_fee, pub_fee
+
+
+def post_buylisting(
+    session: SessionState,
+    listing_id: str,
+    referer: str,
+    subtotal: int,
+    fee: int,
+    total: int,
+    confirmation: str,
+    is_us_account: bool,
+    label: str,
+    proxy_url: Optional[str] = None,
+) -> Tuple[int, str, dict]:
+    """对应 buff_sweep_runner.rs::post_buylisting。"""
+    url = BUY_URL.format(listing_id=listing_id)
+    form: Dict[str, str] = {
+        "sessionid": session.session_id,
+        "currency": "1",
+        "subtotal": str(subtotal),
+        "fee": str(fee),
+        "total": str(total),
+        "quantity": "1",
+        "save_my_address": "0",
+        "tradefee_tax": "0",
+        "confirmation": str(confirmation),
+    }
+    if is_us_account:
+        form.update(random_us_addr())
+    else:
+        # 与浏览器一致:只传空 billing_state,不附带美区地址字段
+        form["billing_state"] = ""
+
+    headers = browser_market_headers(referer, session.cookie_header(for_market=True))
+    log(
+        label,
+        f"POST {url}",
+        {
+            "confirmation": confirmation,
+            "subtotal": subtotal,
+            "fee": fee,
+            "total": total,
+            "is_us_account": is_us_account,
+            "billing_fields": (
+                "random_us_addr"
+                if is_us_account
+                else "billing_state="
+            ),
+            "proxy": proxy_url or "direct",
+            "ua": UA,
+        },
+    )
+    r = requests.post(
+        url, data=form, headers=headers, timeout=30, proxies=http_proxies(proxy_url)
+    )
+    text = r.text
+    log(label, f"HTTP {r.status_code}", clip(text, 2000))
+    parsed: dict = {}
+    try:
+        if text.strip():
+            data = r.json()
+            parsed = data if isinstance(data, dict) else {"_raw": data}
+    except Exception:
+        parsed = {"_raw": text}
+    return r.status_code, text, parsed
+
+
+def wallet_success(parsed: Optional[dict]) -> bool:
+    if not isinstance(parsed, dict):
+        return False
+    w = parsed.get("wallet_info")
+    if isinstance(w, dict):
+        return w.get("success") in (1, "1", True)
+    return False
+
+
+def extract_confirmation_id(parsed: Optional[dict]) -> str:
+    if not isinstance(parsed, dict):
+        return ""
+    conf = parsed.get("confirmation") or {}
+    if not isinstance(conf, dict):
+        return ""
+    cid = conf.get("confirmation_id")
+    if cid is None:
+        return ""
+    return str(cid).strip()
+
+
+def is_rate_limited(status: int, raw: str, parsed: Optional[dict]) -> bool:
+    if status == 429:
+        return True
+    if isinstance(parsed, dict) and str(parsed.get("message", "")).lower().find("too many") >= 0:
+        return True
+    return "too many requests" in (raw or "").lower()
+
+
+def operate_confirmation(
+    api_base: str,
+    session: SessionState,
+    confirmation_id: str,
+    max_retries: int = 10,
+    proxy_url: Optional[str] = None,
+) -> bool:
+    """对应 operate_confirmation_with_retry → steamAPI /trade-confirmations/operate。"""
+    url = f"{api_base.rstrip('/')}/trade-confirmations/operate"
+    # ConfirmationId 在 C# 侧是 ulong;buylisting 有时返回字符串数字
+    try:
+        cid_num = int(confirmation_id)
+    except ValueError as e:
+        raise RuntimeError(f"confirmation_id 无效: {confirmation_id}") from e
+
+    for attempt in range(1, max_retries + 1):
+        body: Dict[str, Any] = {
+            "MaFilePath": session.mafile_path,
+            "Session": session.to_steam_api_session(),
+            "ConfirmationId": cid_num,
+            "Operation": "accept",
+        }
+        if proxy_url:
+            body["ProxyUrl"] = proxy_url
+        log(
+            "4-confirm",
+            f"attempt {attempt}/{max_retries} POST {url}",
+            {"ConfirmationId": cid_num, "Operation": "accept"},
+        )
+        try:
+            r = requests.post(url, json=body, timeout=30)
+            text = r.text
+            log("4-confirm", f"HTTP {r.status_code}", clip(text, 1500))
+            if r.status_code != 200:
+                if attempt < max_retries:
+                    time.sleep(3)
+                    continue
+                return False
+            data = r.json() if text.strip().startswith("{") else {}
+            session.apply_operate_session(data)
+            ok = data.get("success") in (True, 1, "1")
+            if ok:
+                log(
+                    "4-confirm",
+                    "确认成功",
+                    {
+                        "matchedBy": data.get("matchedBy"),
+                        "confirmationId": data.get("confirmationId"),
+                        "requestedConfirmationId": data.get("requestedConfirmationId"),
+                    },
+                )
+                return True
+            if attempt < max_retries:
+                time.sleep(3)
+                continue
+            return False
+        except requests.RequestException as e:
+            log("4-confirm", f"请求异常: {e}")
+            if attempt < max_retries:
+                time.sleep(3)
+                continue
+            raise
+    return False
+
+
+def list_confirmations(api_base: str, session: SessionState, proxy_url: Optional[str] = None) -> list:
+    url = f"{api_base.rstrip('/')}/trade-confirmations/list"
+    body: Dict[str, Any] = {
+        "MaFilePath": session.mafile_path,
+        "Session": session.to_steam_api_session(),
+    }
+    if proxy_url:
+        body["ProxyUrl"] = proxy_url
+    log("confirm-list", f"POST {url}")
+    r = requests.post(url, json=body, timeout=30)
+    log("confirm-list", f"HTTP {r.status_code}", clip(r.text, 2000))
+    if r.status_code != 200:
+        return []
+    data = r.json()
+    session.apply_operate_session(data)
+    return data.get("confirmations") or []
+
+
+def purchase_one_listing(
+    api_base: str,
+    session: SessionState,
+    listing_id: str,
+    referer: str,
+    subtotal: int,
+    steam_fee: int,
+    pub_fee: int,
+    is_us_account: bool,
+    confirm_retries: int = 10,
+    buy2_retries: int = 10,
+    proxy_url: Optional[str] = None,
+    buy_retries: int = 8,
+) -> Tuple[bool, str]:
+    """
+    严格复现 purchase_one_listing:
+      buy(confirmation=0) → 可选 confirm → buy2(confirmation=id)
+    """
+    fee = steam_fee + pub_fee
+    total = subtotal + fee
+
+    status, raw, first = 0, "", {}
+    for attempt in range(1, buy_retries + 1):
+        status, raw, first = post_buylisting(
+            session,
+            listing_id,
+            referer,
+            subtotal,
+            fee,
+            total,
+            "0",
+            is_us_account,
+            f"3-buy#{attempt}" if buy_retries > 1 else "3-buy",
+            proxy_url=proxy_url,
+        )
+        if wallet_success(first):
+            return True, "购买成功(无需确认)"
+        if is_rate_limited(status, raw, first):
+            wait_s = min(5 * attempt, 30)
+            log("3-buy", f"HTTP {status} 限流,{wait_s}s 后重试 ({attempt}/{buy_retries})")
+            if attempt < buy_retries:
+                time.sleep(wait_s)
+                continue
+            return False, f"购买被限流(HTTP {status}),请换代理或稍后重试: {clip(raw)}"
+        break
+
+    need = False
+    if isinstance(first, dict):
+        need = first.get("need_confirmation") is True or first.get("need_confirmation") == "true"
+    # 客户端也接受 HTTP 406 + need_confirmation
+    if need or (status == 406 and "need_confirmation" in raw and "true" in raw):
+        confirmation_id = extract_confirmation_id(first)
+        if not confirmation_id:
+            return False, "需要确认但未返回 confirmation_id"
+
+        log("3-buy", f"需要确认 confirmation_id={confirmation_id}")
+
+        # 可选:列出当前待确认,便于对照 id / creator_id(确认不走购买代理)
+        try:
+            confs = list_confirmations(api_base, session, proxy_url=None)
+            log("confirm-list", "当前待确认列表", confs)
+        except Exception as e:
+            log("confirm-list", f"列确认失败(忽略): {e}")
+
+        ok = False
+        try:
+            ok = operate_confirmation(
+                api_base, session, confirmation_id, max_retries=confirm_retries, proxy_url=None
+            )
+        except Exception as e:
+            log("4-confirm", f"连续失败,仍尝试 buy2: {e}")
+            ok = False
+        if not ok:
+            log("4-confirm", "确认返回失败/未成功,仍继续 buy2(与客户端一致)")
+
+        last_status, last_raw, last_parsed = status, raw, first
+        for attempt in range(1, buy2_retries + 1):
+            status2, raw2, second = post_buylisting(
+                session,
+                listing_id,
+                referer,
+                subtotal,
+                fee,
+                total,
+                confirmation_id,
+                is_us_account,
+                f"5-buy2#{attempt}",
+                proxy_url=proxy_url,
+            )
+            last_status, last_raw, last_parsed = status2, raw2, second
+            if is_rate_limited(status2, raw2, second):
+                wait_s = min(5 * attempt, 30)
+                log("5-buy2", f"HTTP {status2} 限流,{wait_s}s 后重试 ({attempt}/{buy2_retries})")
+                if attempt < buy2_retries:
+                    time.sleep(wait_s)
+                    continue
+                return False, f"确认后购买被限流(HTTP {status2}): {clip(raw2)}"
+            if status2 == 406 and isinstance(second, dict) and second.get("need_confirmation") is True:
+                # 客户端 buy2 遇到再次 need_confirmation 会直接返回,不继续重试该分支
+                return False, f"buy2 仍返回 need_confirmation: {clip(raw2)}"
+            if wallet_success(second):
+                return True, "购买成功(确认后完成)"
+            if attempt < buy2_retries:
+                time.sleep(3)
+
+        return False, f"确认后购买失败: HTTP {last_status} {clip(last_raw)}"
+
+    return False, f"购买失败: HTTP {status} {clip(raw)}"
+
+
+def resolve_mafile_path(mafile: str) -> str:
+    """相对路径按本脚本目录解析;已是绝对路径则原样返回。"""
+    raw = (mafile or "").strip()
+    if not raw:
+        return raw
+    p = Path(raw)
+    if not p.is_absolute():
+        p = ROOT_DIR / p
+    return str(p.resolve())
+
+
+def load_config(path: Optional[str]) -> Dict[str, Any]:
+    cfg_path = Path(path) if path else DEFAULT_CONFIG
+    if not cfg_path.is_absolute():
+        cfg_path = ROOT_DIR / cfg_path
+    if not cfg_path.is_file():
+        return {}
+    with cfg_path.open("r", encoding="utf-8") as f:
+        data = json.load(f)
+    if not isinstance(data, dict):
+        raise RuntimeError(f"配置文件格式错误: {cfg_path}")
+    return data
+
+
+def apply_config_defaults(args: argparse.Namespace, cfg: Dict[str, Any]) -> None:
+    """命令行未显式传入时,用 config.json 补齐。"""
+    def _set_if_empty(name: str, cfg_key: str, transform=None) -> None:
+        cur = getattr(args, name, None)
+        empty = cur is None or cur == "" or cur == 0 or cur is False
+        # bool / store_true 不走这里
+        if name in ("us_account", "no_us_account", "confirm_only", "list_conf_only"):
+            return
+        if name in ("subtotal", "steam_fee", "pub_fee", "confirm_retries", "buy2_retries"):
+            if cur not in (None, 0):
+                return
+        elif not empty and cur is not None:
+            # 字符串类:非空则保留 CLI
+            if isinstance(cur, str) and cur.strip() != "":
+                return
+            if not isinstance(cur, str) and cur:
+                return
+        if cfg_key not in cfg:
+            return
+        val = cfg.get(cfg_key)
+        if val is None or val == "":
+            return
+        if transform:
+            val = transform(val)
+        setattr(args, name, val)
+
+    _set_if_empty("api_base", "api_base")
+    _set_if_empty("username", "username")
+    _set_if_empty("password", "password")
+    _set_if_empty("mafile", "mafile")
+    _set_if_empty("listing_id", "listing_id", lambda v: str(v))
+    _set_if_empty("market_url", "market_url")
+    _set_if_empty("subtotal", "subtotal", int)
+    _set_if_empty("steam_fee", "steam_fee", int)
+    _set_if_empty("pub_fee", "pub_fee", int)
+    _set_if_empty("proxy", "proxy")
+    if not args.us_account and not args.no_us_account and bool(cfg.get("is_us_account")):
+        args.us_account = True
+
+
+def build_arg_parser() -> argparse.ArgumentParser:
+    p = argparse.ArgumentParser(
+        description="测试 client-tauri Steam 市场购买 + 令牌确认流程"
+    )
+    p.add_argument(
+        "--config",
+        default=str(DEFAULT_CONFIG),
+        help=f"配置文件路径(默认 {DEFAULT_CONFIG.name})",
+    )
+    p.add_argument("--api-base", default="", help="本地 steamAPI 地址")
+    p.add_argument("--username", default="", help="Steam 账号(可写在 config.json)")
+    p.add_argument("--password", default="", help="Steam 密码(可写在 config.json)")
+    p.add_argument("--mafile", default="", help=".maFile 路径,相对 tljb 目录或绝对路径")
+    p.add_argument("--listing-id", default="", help="Steam market listing id(购买目标)")
+    p.add_argument(
+        "--market-url",
+        default="",
+        help="市场物品页 URL,用于 Referer 与自动取价,如 https://steamcommunity.com/market/listings/730/xxx",
+    )
+    p.add_argument("--subtotal", type=int, default=0, help="converted_price(美分),不传则从 render 取")
+    p.add_argument("--steam-fee", type=int, default=0, help="converted_steam_fee(美分)")
+    p.add_argument("--pub-fee", type=int, default=0, help="converted_publisher_fee(美分)")
+    p.add_argument(
+        "--us-account",
+        action="store_true",
+        help="美区账号:附带随机账单地址(默认关闭;浏览器非美区仅传空 billing_state)",
+    )
+    p.add_argument(
+        "--no-us-account",
+        action="store_true",
+        help="非美区账号(默认行为,可省略)",
+    )
+    p.add_argument(
+        "--proxy",
+        default="",
+        help="仅用于市场取价/购买(buylisting);登录与令牌确认不走代理。格式 host:port,如 154.48.255.25:13465",
+    )
+    p.add_argument("--confirm-retries", type=int, default=10)
+    p.add_argument("--buy2-retries", type=int, default=10)
+    p.add_argument(
+        "--confirm-only",
+        action="store_true",
+        help="只测令牌确认,不买东西(需 --confirmation-id)",
+    )
+    p.add_argument("--confirmation-id", default="", help="confirm-only 时使用")
+    p.add_argument(
+        "--list-conf-only",
+        action="store_true",
+        help="登录后只列出待确认项",
+    )
+    return p
+
+
+def main() -> int:
+    args = build_arg_parser().parse_args()
+    cfg = load_config(args.config)
+    apply_config_defaults(args, cfg)
+
+    if not args.api_base:
+        args.api_base = STEAM_API_BASE_DEFAULT
+    if not args.username or not args.password or not args.mafile:
+        print(
+            "缺少账号信息:请在 config.json 填写 username/password/mafile,或通过命令行传入",
+            file=sys.stderr,
+        )
+        return 2
+
+    args.mafile = resolve_mafile_path(args.mafile)
+    if not Path(args.mafile).is_file():
+        print(f"maFile 不存在: {args.mafile}", file=sys.stderr)
+        return 2
+
+    # 默认非美区(与浏览器一致:仅 billing_state 空);显式 --us-account 才带随机地址
+    is_us = bool(args.us_account) and not args.no_us_account
+    proxy = normalize_proxy(args.proxy)
+    if proxy:
+        log("proxy", f"仅购买/取价走代理: {proxy}(登录与确认直连)")
+
+    # 健康检查 steamAPI
+    try:
+        requests.get(args.api_base, timeout=3)
+    except requests.RequestException:
+        # MapGet 可能没有 /,再试任意端口连通
+        pass
+    try:
+        r = requests.post(f"{args.api_base.rstrip('/')}/wallet-balance", json={}, timeout=5)
+        _ = r.status_code  # 只要能连上即可
+    except requests.RequestException as e:
+        print(
+            f"无法连接 steamAPI ({args.api_base}),请确认本机 5000 端口服务已启动。\n"
+            f"错误: {e}",
+            file=sys.stderr,
+        )
+        return 2
+
+    # 登录不走代理
+    session = steam_api_login(
+        args.api_base, args.username, args.password, args.mafile, proxy_url=None
+    )
+
+    if args.list_conf_only:
+        confs = list_confirmations(args.api_base, session, proxy_url=None)
+        log("DONE", f"待确认 {len(confs)} 条", confs)
+        return 0
+
+    if args.confirm_only:
+        if not args.confirmation_id:
+            print("--confirm-only 需要 --confirmation-id", file=sys.stderr)
+            return 2
+        confs = list_confirmations(args.api_base, session, proxy_url=None)
+        log("confirm-list", "确认前列表", confs)
+        ok = operate_confirmation(
+            args.api_base, session, args.confirmation_id, args.confirm_retries, proxy_url=None
+        )
+        log("DONE", "确认结果", {"success": ok})
+        return 0 if ok else 1
+
+    if not args.listing_id:
+        print("购买模式需要 --listing-id", file=sys.stderr)
+        return 2
+
+    subtotal, steam_fee, pub_fee = args.subtotal, args.steam_fee, args.pub_fee
+    referer = args.market_url or "https://steamcommunity.com/market/"
+    if subtotal <= 0:
+        if not args.market_url:
+            print("未传 --subtotal 时必须提供 --market-url 以便 render 取价", file=sys.stderr)
+            return 2
+        subtotal, steam_fee, pub_fee = fetch_listing_price(
+            args.market_url, args.listing_id, session, proxy_url=proxy
+        )
+        referer = args.market_url.split("/render")[0]
+
+    ok, msg = purchase_one_listing(
+        api_base=args.api_base,
+        session=session,
+        listing_id=str(args.listing_id),
+        referer=referer,
+        subtotal=subtotal,
+        steam_fee=steam_fee,
+        pub_fee=pub_fee,
+        is_us_account=is_us,
+        confirm_retries=args.confirm_retries,
+        buy2_retries=args.buy2_retries,
+        proxy_url=proxy,
+    )
+    log("DONE", msg, {"success": ok})
+    return 0 if ok else 1
+
+
+if __name__ == "__main__":
+    # Windows 控制台 UTF-8
+    try:
+        sys.stdout.reconfigure(encoding="utf-8")
+        sys.stderr.reconfigure(encoding="utf-8")
+    except Exception:
+        pass
+    sys.exit(main())