fix(translate): follow Bing geo redirects and surface both provider errors (#3)

- Follow 301/302/303/307/308 redirects within *.bing.com when fetching the Bing translator token and posting to /ttranslatev3, preserving the POST method/form body and caching the resolved origin on BingToken.
- Combine primary and secondary provider error messages when hedged free translation fails on both channels instead of discarding the first error.
- Fix debug-mode objc2 type encoding check in macOS make_button by using typed NSColor::CGColor and CALayer::setBackgroundColor methods.
- Bump workspace version to 0.2.2.
This commit is contained in:
ZASENJC committed 2026-10-16 17:34:59 +08:00
1 parent 54b0bea86a
commit b0671bbf73
5 files changed
+234 -44

No files matched your search

Generated
+9 -9
View File
@@ -3251,7 +3251,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "ttree-ai"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"candle-core",
@@ -3269,7 +3269,7 @@ dependencies = [
[[package]]
name = "ttree-core"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"serde",
"serde_json",
@@ -3279,7 +3279,7 @@ dependencies = [
[[package]]
name = "ttree-macos"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"global-hotkey",
@@ -3300,7 +3300,7 @@ dependencies = [
[[package]]
name = "ttree-ocr"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"objc2",
"objc2-core-foundation",
@@ -3317,7 +3317,7 @@ dependencies = [
[[package]]
name = "ttree-platform"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"auto-launch",
@@ -3332,7 +3332,7 @@ dependencies = [
[[package]]
name = "ttree-storage"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"dirs 6.0.0",
"serde",
@@ -3343,7 +3343,7 @@ dependencies = [
[[package]]
name = "ttree-translate"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"futures-util",
"reqwest",
@@ -3356,7 +3356,7 @@ dependencies = [
[[package]]
name = "ttree-updater"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -3377,7 +3377,7 @@ dependencies = [
[[package]]
name = "ttree-windows"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"global-hotkey",
+3 -2
View File
@@ -13,7 +13,7 @@ members = [
]
[workspace.package]
version = "0.2.1"
version = "0.2.2"
edition = "2021"
authors = ["ZASENJC"]
license = "MIT"
@@ -114,8 +114,9 @@ objc2-app-kit = { version = "0.3", features = [
"NSVisualEffectView",
"NSWindow",
"objc2-core-foundation",
"objc2-core-graphics",
] }
objc2-quartz-core = { version = "0.3", features = ["CALayer"] }
objc2-quartz-core = { version = "0.3", features = ["CALayer", "objc2-core-graphics"] }
windows = { version = "0.58", features = [
"Foundation",
"Foundation_Collections",
+2 -2
View File
@@ -280,8 +280,8 @@ pub fn make_button(
if let Some(layer) = btn.layer() {
layer.setCornerRadius(6.0);
let bg = pale_ink(0.05);
let cg_bg: *const AnyObject = msg_send![&*bg, CGColor];
let _: () = msg_send![&*layer, setBackgroundColor: cg_bg];
let cg_bg = bg.CGColor();
layer.setBackgroundColor(Some(&cg_bg));
}
btn.setTag(tag);
btn.setTarget(Some(target));
+50 -4
View File
@@ -37,6 +37,26 @@ impl Provider {
Self::Bing => Self::Google,
}
}
fn label(self) -> &'static str {
match self {
Self::Google => "谷歌",
Self::Bing => "必应",
}
}
}
fn combine_provider_errors(
primary: Provider,
primary_err: String,
secondary: Provider,
secondary_err: String,
) -> String {
format!(
"{}: {primary_err} | {}: {secondary_err}",
primary.label(),
secondary.label()
)
}
#[derive(Clone, Copy)]
@@ -154,9 +174,13 @@ async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Resul
res = &mut primary_fut => {
match res {
Ok(out) => return Ok(out),
Err(_) => {
Err(primary_err) => {
// 主服务立即报错,直接等待备用服务
return call_provider(secondary, text, source, target).await;
return call_provider(secondary, text, source, target)
.await
.map_err(|secondary_err| {
combine_provider_errors(primary, primary_err, secondary, secondary_err)
});
}
}
}
@@ -171,13 +195,21 @@ async fn translate_chunk_hedged(text: &str, source: &str, target: &str) -> Resul
res1 = &mut primary_fut => {
match res1 {
Ok(out) => Ok(out),
Err(_) => secondary_fut.await,
Err(primary_err) => secondary_fut
.await
.map_err(|secondary_err| {
combine_provider_errors(primary, primary_err, secondary, secondary_err)
}),
}
}
res2 = &mut secondary_fut => {
match res2 {
Ok(out) => Ok(out),
Err(_) => primary_fut.await,
Err(secondary_err) => primary_fut
.await
.map_err(|primary_err| {
combine_provider_errors(primary, primary_err, secondary, secondary_err)
}),
}
}
}
@@ -255,4 +287,18 @@ mod tests {
assert!(chunks.len() > 1);
assert_eq!(chunks.concat(), input);
}
#[test]
fn combines_primary_and_secondary_errors_when_both_fail() {
let msg = combine_provider_errors(
Provider::Bing,
"必应翻译页返回状态 302 Found".into(),
Provider::Google,
"谷歌翻译返回状态 429 Too Many Requests".into(),
);
assert_eq!(
msg,
"必应: 必应翻译页返回状态 302 Found | 谷歌: 谷歌翻译返回状态 429 Too Many Requests"
);
}
}
+170 -27
View File
@@ -14,8 +14,8 @@ use crate::{
MAX_TRANSLATION_RESPONSE_BYTES,
};
const DEFAULT_ORIGIN: &str = "https://www.bing.com";
const TRANSLATOR_URL: &str = "https://www.bing.com/translator";
const TRANSLATE_URL: &str = "https://www.bing.com/ttranslatev3";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(8);
const TOKEN_TTL: Duration = Duration::from_secs(45 * 60);
const USER_AGENT: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 \
@@ -27,6 +27,7 @@ pub struct BingToken {
pub iid: String,
pub key: String,
pub token: String,
pub origin: String,
}
#[derive(Clone)]
@@ -76,13 +77,30 @@ fn map_lang(code: &str) -> &str {
}
}
fn is_allowed_bing_host(host: &str) -> bool {
let host = host.to_ascii_lowercase();
host == "bing.com" || host.ends_with(".bing.com")
}
fn origin_from_url(url: &reqwest::Url) -> Option<String> {
if url.scheme() != "https" && url.scheme() != "http" {
return None;
}
let host = url.host_str()?;
let mut origin = format!("{}://{}", url.scheme(), host);
if let Some(port) = url.port() {
origin.push_str(&format!(":{port}"));
}
Some(origin)
}
pub async fn translate(text: &str, source: &str, target: &str) -> Result<String, String> {
let client = shared_client(REQUEST_TIMEOUT, true);
let (token, from_cache) = match get_cached_token(Instant::now()) {
Some(t) => (t, true),
None => {
let fresh = fetch_token(&client).await?;
let fresh = fetch_token(&client, TRANSLATOR_URL).await?;
store_cached_token(fresh.clone(), Instant::now());
(fresh, false)
}
@@ -93,7 +111,7 @@ pub async fn translate(text: &str, source: &str, target: &str) -> Result<String,
Err(_first_err) if from_cache => {
// 缓存令牌可能提前失效,立即淘汰并刷新重试一次
invalidate_cached_token();
let fresh = fetch_token(&client).await?;
let fresh = fetch_token(&client, TRANSLATOR_URL).await?;
store_cached_token(fresh.clone(), Instant::now());
translate_with_token(&client, &fresh, text, source, target).await
}
@@ -108,26 +126,46 @@ async fn translate_with_token(
source: &str,
target: &str,
) -> Result<String, String> {
let url = format!(
"{TRANSLATE_URL}?isVertical=1&IG={ig}&IID={iid}",
let origin = if token.origin.is_empty() {
DEFAULT_ORIGIN
} else {
token.origin.as_str()
};
let referer = format!("{origin}/translator");
let mut url = format!(
"{origin}/ttranslatev3?isVertical=1&IG={ig}&IID={iid}",
ig = urlencoding::encode(&token.ig),
iid = urlencoding::encode(&token.iid),
);
let form = [
("fromLang", map_lang(source)),
("to", map_lang(target)),
("text", text),
("token", &token.token),
("key", &token.key),
];
let resp = client
.post(&url)
.header("User-Agent", USER_AGENT)
.header("Referer", TRANSLATOR_URL)
.form(&[
("fromLang", map_lang(source)),
("to", map_lang(target)),
("text", text),
("token", &token.token),
("key", &token.key),
])
.send()
.await
.map_err(|e| format!("必应翻译请求失败: {e}"))?;
let mut resp = None;
for _ in 0..=3 {
let current = client
.post(&url)
.header("User-Agent", USER_AGENT)
.header("Referer", &referer)
.form(&form)
.send()
.await
.map_err(|e| format!("必应翻译请求失败: {e}"))?;
if current.status().is_redirection() {
if let Some(next_url) = resolve_redirect_url(&url, current.headers()) {
url = next_url;
continue;
}
}
resp = Some(current);
break;
}
let resp = resp.ok_or_else(|| "必应翻译重定向次数过多".to_string())?;
if !resp.status().is_success() {
let status = resp.status();
@@ -144,22 +182,52 @@ async fn translate_with_token(
.ok_or_else(|| "必应翻译未返回译文".to_string())
}
async fn fetch_token(client: &reqwest::Client) -> Result<BingToken, String> {
let response = client
.get(TRANSLATOR_URL)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| format!("必应翻译页请求失败: {e}"))?;
fn resolve_redirect_url(current_url: &str, headers: &reqwest::header::HeaderMap) -> Option<String> {
let loc = headers.get(reqwest::header::LOCATION)?.to_str().ok()?;
let base = reqwest::Url::parse(current_url).ok()?;
let target = base.join(loc).ok()?;
let host = target.host_str()?;
let same_loopback = base.host_str() == Some(host) && (host == "127.0.0.1" || host == "localhost");
if (target.scheme() == "https" && is_allowed_bing_host(host)) || same_loopback {
Some(target.into())
} else {
None
}
}
async fn fetch_token(client: &reqwest::Client, translator_url: &str) -> Result<BingToken, String> {
let mut url = translator_url.to_string();
let mut response = None;
for _ in 0..=3 {
let current = client
.get(&url)
.header("User-Agent", USER_AGENT)
.send()
.await
.map_err(|e| format!("必应翻译页请求失败: {e}"))?;
if current.status().is_redirection() {
if let Some(next_url) = resolve_redirect_url(&url, current.headers()) {
url = next_url;
continue;
}
}
response = Some(current);
break;
}
let response = response.ok_or_else(|| "必应翻译页重定向次数过多".to_string())?;
if !response.status().is_success() {
let status = response.status();
let body = read_error_body(response).await;
return Err(format!("必应翻译页返回状态 {status}: {body}"));
}
let final_origin = origin_from_url(response.url()).unwrap_or_else(|| DEFAULT_ORIGIN.to_string());
let html = read_limited_response(response, MAX_BING_PAGE_BYTES, "必应翻译页").await?;
let html = std::str::from_utf8(&html).map_err(|e| format!("必应翻译页不是 UTF-8: {e}"))?;
parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())
let mut token =
parse_token(html).ok_or_else(|| "必应翻译令牌解析失败(端点可能已变更)".to_string())?;
token.origin = final_origin;
Ok(token)
}
pub fn parse_token(html: &str) -> Option<BingToken> {
@@ -188,6 +256,7 @@ pub fn parse_token(html: &str) -> Option<BingToken> {
iid,
key,
token,
origin: DEFAULT_ORIGIN.to_string(),
})
}
@@ -210,6 +279,8 @@ pub fn parse_response(body: &Value) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[test]
fn parses_bing_html_token_and_caches_with_ttl() {
@@ -228,4 +299,76 @@ mod tests {
None
);
}
#[tokio::test]
async fn follows_302_redirects_for_token_and_translation_post() {
let redirect_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let target_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let redirect_addr = redirect_listener.local_addr().unwrap();
let target_addr = target_listener.local_addr().unwrap();
let redirect_srv = tokio::spawn(async move {
let (mut socket, _) = redirect_listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await.unwrap();
let resp = format!(
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/translator-cn\r\nSet-Cookie: SNRHOP=1; Path=/\r\nContent-Length: 0\r\n\r\n",
target_addr.port()
);
socket.write_all(resp.as_bytes()).await.unwrap();
});
let target_srv = tokio::spawn(async move {
for step in 0..3 {
let (mut socket, _) = target_listener.accept().await.unwrap();
let mut buf = vec![0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let req = String::from_utf8_lossy(&buf[..n]);
match step {
0 => {
assert!(req.starts_with("GET /translator-cn "), "{req}");
let body = r#"IG:"FF00"; <div data-iid="translator.5028"></div> params_AbusePreventionHelper = [167060,"abcdef",3600000];"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
socket.write_all(resp.as_bytes()).await.unwrap();
}
1 => {
assert!(req.starts_with("POST /ttranslatev3?isVertical=1&IG=FF00&IID=translator.5028 "), "{req}");
let resp = format!(
"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:{}/ttranslatev3-cn?isVertical=1&IG=FF00&IID=translator.5028\r\nContent-Length: 0\r\n\r\n",
target_addr.port()
);
socket.write_all(resp.as_bytes()).await.unwrap();
}
_ => {
assert!(req.starts_with("POST /ttranslatev3-cn?isVertical=1&IG=FF00&IID=translator.5028 "), "{req}");
assert!(req.contains("text=hello"), "{req}");
let body = r#"[{"translations":[{"text":"你好","to":"zh-Hans"}]}]"#;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
socket.write_all(resp.as_bytes()).await.unwrap();
}
}
}
});
let client = shared_client(Duration::from_secs(5), true);
let initial_url = format!("http://127.0.0.1:{}/translator", redirect_addr.port());
let token = fetch_token(&client, &initial_url).await.unwrap();
assert_eq!(token.origin, format!("http://127.0.0.1:{}", target_addr.port()));
let translated = translate_with_token(&client, &token, "hello", "en", "zh-CN")
.await
.unwrap();
assert_eq!(translated, "你好");
redirect_srv.await.unwrap();
target_srv.await.unwrap();
}
}