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:
1 parent
54b0bea86a
commit
b0671bbf73
5 files changed
+234
-44
No files matched your search
Generated
+9
-9
@@ -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
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user