204848 commited on
Commit ·
c12b13c
1
Parent(s): c7ba3dd
feat: 更新Web端支持,强制使用HTTP/2并调整TLS拨号器以兼容IPv4
Browse files
.gitignore
CHANGED
|
@@ -74,3 +74,4 @@ chat.deepseek.com.har.txt
|
|
| 74 |
chat.deepseek.com2.har.txt
|
| 75 |
chat.deepseek.com/
|
| 76 |
chat.deepseek.com3.har
|
|
|
|
|
|
| 74 |
chat.deepseek.com2.har.txt
|
| 75 |
chat.deepseek.com/
|
| 76 |
chat.deepseek.com3.har
|
| 77 |
+
chat.deepseek.com_2026_07_14_20_56_28.har
|
docs/web-endpoint-upgrade.md
CHANGED
|
@@ -321,7 +321,8 @@ func (p *HIFPoller) GetHeaders() map[string]string // 获取当前HIF头部
|
|
| 321 |
|
| 322 |
```
|
| 323 |
ClientHello: HelloSafari_Auto
|
| 324 |
-
ALPN:
|
|
|
|
| 325 |
```
|
| 326 |
|
| 327 |
### Web端
|
|
@@ -332,6 +333,11 @@ ALPN: 自然协商 (支持 h2 和 http/1.1)
|
|
| 332 |
ForceAttemptHTTP2: true
|
| 333 |
```
|
| 334 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
实现位于 `internal/deepseek/transport/transport.go`:
|
| 336 |
|
| 337 |
```go
|
|
@@ -339,7 +345,7 @@ func TLSDialerForPlatform(platform string) func(...) {
|
|
| 339 |
if platform == "web" {
|
| 340 |
return chromeTLSDialer(dialContext) // Chrome + HTTP/2
|
| 341 |
}
|
| 342 |
-
return safariTLSDialer(dialContext) // Safari + HTTP/
|
| 343 |
}
|
| 344 |
```
|
| 345 |
|
|
@@ -350,7 +356,7 @@ func TLSDialerForPlatform(platform string) func(...) {
|
|
| 350 |
### 6.1 设置面板切换
|
| 351 |
|
| 352 |
访问 WebUI 设置页面,在"接口平台"选项中选择:
|
| 353 |
-
- **Android端(默认)**:使用Safari TLS指纹,device_id为固定值
|
| 354 |
- **Web端(Chrome浏览器)**:使用Chrome TLS指纹+HTTP/2,动态生成数美device_id,轮询HIF头部
|
| 355 |
|
| 356 |
切换后**立即生效**,无需重启服务。
|
|
|
|
| 321 |
|
| 322 |
```
|
| 323 |
ClientHello: HelloSafari_Auto
|
| 324 |
+
ALPN: h2 + http/1.1 (setSafariALPN,与真实 Safari 一致)
|
| 325 |
+
ForceAttemptHTTP2: true
|
| 326 |
```
|
| 327 |
|
| 328 |
### Web端
|
|
|
|
| 333 |
ForceAttemptHTTP2: true
|
| 334 |
```
|
| 335 |
|
| 336 |
+
> chat.deepseek.com 现已要求 HTTP/2:HTTP/1.1 客户端会收到服务端返回的 h2
|
| 337 |
+
> SETTINGS 帧,被 Go 的 http.Transport 解析为 "malformed HTTP response"。
|
| 338 |
+
> 因此两个平台均通过 ALPN 协商 h2 并 `ForceAttemptHTTP2: true`。Android 端
|
| 339 |
+
> 早期曾强制 http/1.1,现已对齐真实 Safari 行为改用 h2。
|
| 340 |
+
|
| 341 |
实现位于 `internal/deepseek/transport/transport.go`:
|
| 342 |
|
| 343 |
```go
|
|
|
|
| 345 |
if platform == "web" {
|
| 346 |
return chromeTLSDialer(dialContext) // Chrome + HTTP/2
|
| 347 |
}
|
| 348 |
+
return safariTLSDialer(dialContext) // Safari + HTTP/2
|
| 349 |
}
|
| 350 |
```
|
| 351 |
|
|
|
|
| 356 |
### 6.1 设置面板切换
|
| 357 |
|
| 358 |
访问 WebUI 设置页面,在"接口平台"选项中选择:
|
| 359 |
+
- **Android端(默认)**:使用Safari TLS指纹 + HTTP/2,device_id为固定值
|
| 360 |
- **Web端(Chrome浏览器)**:使用Chrome TLS指纹+HTTP/2,动态生成数美device_id,轮询HIF头部
|
| 361 |
|
| 362 |
切换后**立即生效**,无需重启服务。
|
internal/deepseek/hif/hif.go
CHANGED
|
@@ -37,11 +37,13 @@ type HIFPoller struct {
|
|
| 37 |
}
|
| 38 |
|
| 39 |
// NewHIFPoller creates a new HIFPoller using web-platform TLS fingerprinting.
|
|
|
|
|
|
|
| 40 |
func NewHIFPoller() *HIFPoller {
|
| 41 |
return &HIFPoller{
|
| 42 |
stopCh: make(chan struct{}),
|
| 43 |
-
client: trans.
|
| 44 |
-
fallback: trans.NewFallbackClientWithPlatform(15*time.Second,
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
// NewHIFPoller creates a new HIFPoller using web-platform TLS fingerprinting.
|
| 40 |
+
// 拨号器强制 IPv4:HuggingFace Spaces 等环境没有 IPv6 出口,
|
| 41 |
+
// hif-dliq/hif-leim 常解析到 IPv6,会导致 "network is unreachable" 反复重试。
|
| 42 |
func NewHIFPoller() *HIFPoller {
|
| 43 |
return &HIFPoller{
|
| 44 |
stopCh: make(chan struct{}),
|
| 45 |
+
client: trans.NewWithDialContextAndPlatform(15*time.Second, trans.IPv4DialContext, "web"),
|
| 46 |
+
fallback: trans.NewFallbackClientWithPlatform(15*time.Second, trans.IPv4DialContext, "web"),
|
| 47 |
}
|
| 48 |
}
|
| 49 |
|
internal/deepseek/protocol/constants.go
CHANGED
|
@@ -135,7 +135,7 @@ func buildBaseHeaders(client clientConstants, overrides map[string]string) map[s
|
|
| 135 |
return out
|
| 136 |
}
|
| 137 |
|
| 138 |
-
const webUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/
|
| 139 |
|
| 140 |
func userAgentForPlatform(client clientConstants) string {
|
| 141 |
if client.Platform == "web" {
|
|
@@ -215,7 +215,7 @@ func UserAgent() string {
|
|
| 215 |
// config but need to be added dynamically for the web platform.
|
| 216 |
func WebExtraHeaders() map[string]string {
|
| 217 |
return map[string]string{
|
| 218 |
-
"sec-ch-ua": `"Chromium";v="
|
| 219 |
"sec-ch-ua-mobile": "?0",
|
| 220 |
"sec-ch-ua-platform": `"Windows"`,
|
| 221 |
"origin": "https://chat.deepseek.com",
|
|
|
|
| 135 |
return out
|
| 136 |
}
|
| 137 |
|
| 138 |
+
const webUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36 Edg/150.0.0.0"
|
| 139 |
|
| 140 |
func userAgentForPlatform(client clientConstants) string {
|
| 141 |
if client.Platform == "web" {
|
|
|
|
| 215 |
// config but need to be added dynamically for the web platform.
|
| 216 |
func WebExtraHeaders() map[string]string {
|
| 217 |
return map[string]string{
|
| 218 |
+
"sec-ch-ua": `"Not;A=Brand";v="8", "Chromium";v="150", "Microsoft Edge";v="150"`,
|
| 219 |
"sec-ch-ua-mobile": "?0",
|
| 220 |
"sec-ch-ua-platform": `"Windows"`,
|
| 221 |
"origin": "https://chat.deepseek.com",
|
internal/deepseek/protocol/constants_shared.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
"client": {
|
| 3 |
"name": "DeepSeek",
|
| 4 |
"platform": "android",
|
| 5 |
-
"version": "2.
|
| 6 |
"android_api_level": "32",
|
| 7 |
"locale": "zh_CN"
|
| 8 |
},
|
|
|
|
| 2 |
"client": {
|
| 3 |
"name": "DeepSeek",
|
| 4 |
"platform": "android",
|
| 5 |
+
"version": "2.2.2",
|
| 6 |
"android_api_level": "32",
|
| 7 |
"locale": "zh_CN"
|
| 8 |
},
|
internal/deepseek/protocol/constants_web.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
"client": {
|
| 3 |
"name": "DeepSeek",
|
| 4 |
"platform": "web",
|
| 5 |
-
"version": "2.
|
| 6 |
"locale": "zh_CN"
|
| 7 |
},
|
| 8 |
"base_headers": {
|
|
@@ -11,11 +11,12 @@
|
|
| 11 |
"Content-Type": "application/json",
|
| 12 |
"accept-encoding": "gzip, deflate, br, zstd",
|
| 13 |
"accept-charset": "UTF-8",
|
| 14 |
-
"x-app-version": "2.
|
|
|
|
| 15 |
"x-client-locale": "zh_CN",
|
| 16 |
"x-client-platform": "web",
|
| 17 |
"x-client-timezone-offset": "28800",
|
| 18 |
-
"x-client-version": "2.
|
| 19 |
},
|
| 20 |
"skip_contains_patterns": [
|
| 21 |
"quasi_status",
|
|
|
|
| 2 |
"client": {
|
| 3 |
"name": "DeepSeek",
|
| 4 |
"platform": "web",
|
| 5 |
+
"version": "2.2.0",
|
| 6 |
"locale": "zh_CN"
|
| 7 |
},
|
| 8 |
"base_headers": {
|
|
|
|
| 11 |
"Content-Type": "application/json",
|
| 12 |
"accept-encoding": "gzip, deflate, br, zstd",
|
| 13 |
"accept-charset": "UTF-8",
|
| 14 |
+
"x-app-version": "2.2.0",
|
| 15 |
+
"x-client-bundle-id": "com.deepseek.chat",
|
| 16 |
"x-client-locale": "zh_CN",
|
| 17 |
"x-client-platform": "web",
|
| 18 |
"x-client-timezone-offset": "28800",
|
| 19 |
+
"x-client-version": "2.2.0"
|
| 20 |
},
|
| 21 |
"skip_contains_patterns": [
|
| 22 |
"quasi_status",
|
internal/deepseek/transport/transport.go
CHANGED
|
@@ -39,9 +39,10 @@ func NewWithDialContextAndPlatform(timeout time.Duration, dialContext DialContex
|
|
| 39 |
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
| 40 |
}
|
| 41 |
tlsDialer := TLSDialerForPlatform(platform, dialContext)
|
| 42 |
-
|
|
|
|
| 43 |
base := &http.Transport{
|
| 44 |
-
ForceAttemptHTTP2:
|
| 45 |
MaxIdleConns: 200,
|
| 46 |
MaxIdleConnsPerHost: 100,
|
| 47 |
IdleConnTimeout: 90 * time.Second,
|
|
@@ -68,9 +69,10 @@ func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContex
|
|
| 68 |
if dialContext == nil {
|
| 69 |
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
| 70 |
}
|
| 71 |
-
|
|
|
|
| 72 |
base := &http.Transport{
|
| 73 |
-
ForceAttemptHTTP2:
|
| 74 |
MaxIdleConns: 200,
|
| 75 |
MaxIdleConnsPerHost: 100,
|
| 76 |
IdleConnTimeout: 90 * time.Second,
|
|
@@ -84,8 +86,9 @@ func NewFallbackClientWithPlatform(timeout time.Duration, dialContext DialContex
|
|
| 84 |
}
|
| 85 |
|
| 86 |
// TLSDialerForPlatform returns the appropriate TLS dialer based on the platform.
|
| 87 |
-
// "web" uses Chrome TLS fingerprinting
|
| 88 |
-
//
|
|
|
|
| 89 |
func TLSDialerForPlatform(platform string, dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
| 90 |
if platform == "web" {
|
| 91 |
return chromeTLSDialer(dialContext)
|
|
@@ -127,7 +130,9 @@ func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, netw
|
|
| 127 |
host, _, _ := net.SplitHostPort(addr)
|
| 128 |
uCfg := &utls.Config{ServerName: host}
|
| 129 |
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
_ = plainConn.Close()
|
| 132 |
return nil, err
|
| 133 |
}
|
|
@@ -136,15 +141,12 @@ func safariTLSDialer(dialContext DialContextFunc) func(ctx context.Context, netw
|
|
| 136 |
_ = plainConn.Close()
|
| 137 |
return nil, err
|
| 138 |
}
|
| 139 |
-
if negotiated := uConn.ConnectionState().NegotiatedProtocol; negotiated != "" && negotiated != "http/1.1" {
|
| 140 |
-
_ = uConn.Close()
|
| 141 |
-
return nil, fmt.Errorf("unexpected ALPN protocol negotiated: %s", negotiated)
|
| 142 |
-
}
|
| 143 |
return uConn, nil
|
| 144 |
}
|
| 145 |
}
|
| 146 |
|
| 147 |
-
|
|
|
|
| 148 |
if err := uConn.BuildHandshakeState(); err != nil {
|
| 149 |
return err
|
| 150 |
}
|
|
@@ -153,8 +155,46 @@ func forceHTTP11ALPN(uConn *utls.UConn) error {
|
|
| 153 |
if !ok {
|
| 154 |
continue
|
| 155 |
}
|
| 156 |
-
alpnExt.AlpnProtocols = []string{"http/1.1"}
|
| 157 |
return nil
|
| 158 |
}
|
| 159 |
return nil
|
| 160 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
| 40 |
}
|
| 41 |
tlsDialer := TLSDialerForPlatform(platform, dialContext)
|
| 42 |
+
// chat.deepseek.com 现已要求 HTTP/2(HTTP/1.1 会被服务端返回的 h2 SETTINGS 帧
|
| 43 |
+
// 击穿为 "malformed HTTP response"),因此所有平台都强制尝试 h2。
|
| 44 |
base := &http.Transport{
|
| 45 |
+
ForceAttemptHTTP2: true,
|
| 46 |
MaxIdleConns: 200,
|
| 47 |
MaxIdleConnsPerHost: 100,
|
| 48 |
IdleConnTimeout: 90 * time.Second,
|
|
|
|
| 69 |
if dialContext == nil {
|
| 70 |
dialContext = (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
| 71 |
}
|
| 72 |
+
// 同主 transport:所有平台强制尝试 h2,保证 utls 指纹请求失败回退到 std
|
| 73 |
+
// transport 时仍能与要求 HTTP/2 的 chat.deepseek.com 通信。
|
| 74 |
base := &http.Transport{
|
| 75 |
+
ForceAttemptHTTP2: true,
|
| 76 |
MaxIdleConns: 200,
|
| 77 |
MaxIdleConnsPerHost: 100,
|
| 78 |
IdleConnTimeout: 90 * time.Second,
|
|
|
|
| 86 |
}
|
| 87 |
|
| 88 |
// TLSDialerForPlatform returns the appropriate TLS dialer based on the platform.
|
| 89 |
+
// "web" uses Chrome TLS fingerprinting; all other values (including "android")
|
| 90 |
+
// use Safari TLS fingerprinting. Both negotiate HTTP/2 via ALPN, since
|
| 91 |
+
// chat.deepseek.com now requires HTTP/2.
|
| 92 |
func TLSDialerForPlatform(platform string, dialContext DialContextFunc) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
| 93 |
if platform == "web" {
|
| 94 |
return chromeTLSDialer(dialContext)
|
|
|
|
| 130 |
host, _, _ := net.SplitHostPort(addr)
|
| 131 |
uCfg := &utls.Config{ServerName: host}
|
| 132 |
uConn := utls.UClient(plainConn, uCfg, utls.HelloSafari_Auto)
|
| 133 |
+
// 真实 Safari 协商 HTTP/2;显式声明 h2+http/1.1,避免被 HelloSafari_Auto
|
| 134 |
+
// 的默认 ALPN 漏掉 h2,导致服务端按 HTTP/1.1 应答而被 h2-only 端点拒绝。
|
| 135 |
+
if err := setSafariALPN(uConn); err != nil {
|
| 136 |
_ = plainConn.Close()
|
| 137 |
return nil, err
|
| 138 |
}
|
|
|
|
| 141 |
_ = plainConn.Close()
|
| 142 |
return nil, err
|
| 143 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
return uConn, nil
|
| 145 |
}
|
| 146 |
}
|
| 147 |
|
| 148 |
+
// setSafariALPN 显式把 ALPN 设为 h2 + http/1.1,与真实 Safari 行为一致。
|
| 149 |
+
func setSafariALPN(uConn *utls.UConn) error {
|
| 150 |
if err := uConn.BuildHandshakeState(); err != nil {
|
| 151 |
return err
|
| 152 |
}
|
|
|
|
| 155 |
if !ok {
|
| 156 |
continue
|
| 157 |
}
|
| 158 |
+
alpnExt.AlpnProtocols = []string{"h2", "http/1.1"}
|
| 159 |
return nil
|
| 160 |
}
|
| 161 |
return nil
|
| 162 |
}
|
| 163 |
+
|
| 164 |
+
// IPv4DialContext 仅拨号解析到的 IPv4 地址。用于 HuggingFace Spaces 等
|
| 165 |
+
// 没有 IPv6 出口的部署:标准 Happy Eyeballs 在某些纯 IPv6 解析结果下会反复
|
| 166 |
+
// 拨 IPv6 拿到 "network is unreachable",这里显式跳过 IPv6。
|
| 167 |
+
func IPv4DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
| 168 |
+
host, port, err := net.SplitHostPort(addr)
|
| 169 |
+
if err != nil {
|
| 170 |
+
return nil, err
|
| 171 |
+
}
|
| 172 |
+
// 已经是 IP 直连时,IPv4 直接拨,IPv6 直接放弃。
|
| 173 |
+
if ip := net.ParseIP(host); ip != nil {
|
| 174 |
+
if ip.To4() == nil {
|
| 175 |
+
return nil, fmt.Errorf("ipv4-only dialer: %s is not IPv4", host)
|
| 176 |
+
}
|
| 177 |
+
d := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
| 178 |
+
return d.DialContext(ctx, "tcp4", net.JoinHostPort(host, port))
|
| 179 |
+
}
|
| 180 |
+
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
| 181 |
+
if err != nil {
|
| 182 |
+
return nil, err
|
| 183 |
+
}
|
| 184 |
+
var lastErr error
|
| 185 |
+
d := &net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}
|
| 186 |
+
for _, ip := range ips {
|
| 187 |
+
if ip.IP.To4() == nil {
|
| 188 |
+
continue
|
| 189 |
+
}
|
| 190 |
+
conn, derr := d.DialContext(ctx, "tcp4", net.JoinHostPort(ip.IP.String(), port))
|
| 191 |
+
if derr == nil {
|
| 192 |
+
return conn, nil
|
| 193 |
+
}
|
| 194 |
+
lastErr = derr
|
| 195 |
+
}
|
| 196 |
+
if lastErr != nil {
|
| 197 |
+
return nil, lastErr
|
| 198 |
+
}
|
| 199 |
+
return nil, fmt.Errorf("ipv4-only dialer: no IPv4 address resolved for %s", host)
|
| 200 |
+
}
|