ipclick 2.0.0__tar.gz → 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {ipclick-2.0.0 → ipclick-2.1.0}/PKG-INFO +3 -3
  2. {ipclick-2.0.0 → ipclick-2.1.0}/README.md +2 -2
  3. {ipclick-2.0.0 → ipclick-2.1.0}/pyproject.toml +1 -1
  4. {ipclick-2.0.0 → ipclick-2.1.0}/pyproject.toml.orig +1 -1
  5. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/base.py +14 -2
  6. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/browser_adapter.py +2 -2
  7. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/curl_cffi_adapter.py +4 -3
  8. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/drission_adapter.py +2 -2
  9. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/niquests_adapter.py +3 -2
  10. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/settings.py +20 -2
  11. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cli/main.py +12 -1
  12. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/probe.py +1 -1
  13. ipclick-2.1.0/src/ipclick/config_loader/env_writer.py +143 -0
  14. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/configs/default_config.toml +13 -7
  15. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/models.py +12 -5
  16. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/task.proto +4 -4
  17. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/task_pb2_grpc.py +3 -3
  18. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/limiter.py +2 -2
  19. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/task_service.py +14 -9
  20. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/assets.py +56 -11
  21. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/editable.py +155 -95
  22. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/components.py +1 -1
  23. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/sandbox.py +6 -1
  24. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/settings.py +141 -2
  25. ipclick-2.1.0/src/ipclick/web/proxy_tunnel.py +226 -0
  26. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/config.py +91 -2
  27. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/sandbox.py +5 -2
  28. {ipclick-2.0.0 → ipclick-2.1.0}/LICENSE +0 -0
  29. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/__init__.py +0 -0
  30. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/__main__.py +0 -0
  31. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/__init__.py +0 -0
  32. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/browser_engines.py +0 -0
  33. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/browser_settings.py +0 -0
  34. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/redirects.py +0 -0
  35. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/registry.py +0 -0
  36. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/retry.py +0 -0
  37. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/adapters/sessions.py +0 -0
  38. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/aio.py +0 -0
  39. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/async_limiter.py +0 -0
  40. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/async_server.py +0 -0
  41. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/auth.py +0 -0
  42. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cli/__init__.py +0 -0
  43. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cli/agent.py +0 -0
  44. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cli/output.py +0 -0
  45. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cli/skill_cmd.py +0 -0
  46. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/__init__.py +0 -0
  47. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/async_forwarder.py +0 -0
  48. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/balancer.py +0 -0
  49. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/client.py +0 -0
  50. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/discovery.py +0 -0
  51. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/forwarder.py +0 -0
  52. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/node.py +0 -0
  53. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/pool.py +0 -0
  54. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/status_page.py +0 -0
  55. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/cluster/tokens.py +0 -0
  56. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/components.py +0 -0
  57. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/compression.py +0 -0
  58. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/config_loader/__init__.py +0 -0
  59. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/config_loader/dotenv.py +0 -0
  60. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/config_loader/loader.py +0 -0
  61. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/config_loader/placeholders.py +0 -0
  62. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/config_loader/writer.py +0 -0
  63. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/configs/__init__.py +0 -0
  64. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/__init__.py +0 -0
  65. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/__init__.py +0 -0
  66. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/generate.py +0 -0
  67. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/task_pb2.py +0 -0
  68. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/proto/task_pb2.pyi +0 -0
  69. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/dto/response.py +0 -0
  70. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/exceptions.py +0 -0
  71. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/factory.py +0 -0
  72. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/health.py +0 -0
  73. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/multiprocess.py +0 -0
  74. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/ports.py +0 -0
  75. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/protocols.py +0 -0
  76. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/py.typed +0 -0
  77. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/resume.py +0 -0
  78. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/rpc/__init__.py +0 -0
  79. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/rpc/channel.py +0 -0
  80. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/rpc/options.py +0 -0
  81. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/sdk.py +0 -0
  82. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/secrets.py +0 -0
  83. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/server.py +0 -0
  84. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/server_settings.py +0 -0
  85. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/__init__.py +0 -0
  86. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/async_task_service.py +0 -0
  87. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/components.py +0 -0
  88. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/detached.py +0 -0
  89. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/services/errors.py +0 -0
  90. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/skill.py +0 -0
  91. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/skills/ipclick/SKILL.md +0 -0
  92. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/tls.py +0 -0
  93. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/trace/__init__.py +0 -0
  94. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/trace/counters.py +0 -0
  95. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/trace/recorder.py +0 -0
  96. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/trace/records.py +0 -0
  97. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/trace/store.py +0 -0
  98. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/__init__.py +0 -0
  99. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/coerce.py +0 -0
  100. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/config_util.py +0 -0
  101. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/log_util.py +0 -0
  102. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/module_probe.py +0 -0
  103. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/path_util.py +0 -0
  104. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/secure_util.py +0 -0
  105. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/utils/url_util.py +0 -0
  106. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/__init__.py +0 -0
  107. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/auth.py +0 -0
  108. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/curl_parser.py +0 -0
  109. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/deploy.py +0 -0
  110. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/installer.py +0 -0
  111. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/__init__.py +0 -0
  112. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/context.py +0 -0
  113. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/skill.py +0 -0
  114. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/pages/trace.py +0 -0
  115. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/server.py +0 -0
  116. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/snapshot.py +0 -0
  117. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/__init__.py +0 -0
  118. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/base.py +0 -0
  119. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/components.py +0 -0
  120. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/dashboard.py +0 -0
  121. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/deploy.py +0 -0
  122. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/login.py +0 -0
  123. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/skill.py +0 -0
  124. {ipclick-2.0.0 → ipclick-2.1.0}/src/ipclick/web/templates/trace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ipclick
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: IPClick Tool
5
5
  Keywords: http,grpc,downloader,scraping,browser,cluster
6
6
  Author: 元气码农少女酱
@@ -225,7 +225,7 @@ with Downloader() as downloader:
225
225
  response = downloader.request(
226
226
  method=HttpMethod.GET,
227
227
  url="https://httpbin.org/get",
228
- headers={"User-Agent": "IPClick/1.0"},
228
+ headers={"User-Agent": "MyApp/1.0"},
229
229
  params={"key": "value"},
230
230
  timeout=30,
231
231
  )
@@ -270,7 +270,7 @@ IPCLICK_WEB_USER=ops IPCLICK_WEB_PASSWORD=... ipclick run -w
270
270
  | **请求流** `/trace` | 实时看请求打进来,按状态 / 适配器 / URL 过滤,看站点排行与按天趋势 |
271
271
  | **试一试** `/test` | 填网址或直接粘一条 curl,就地发一次请求看链路与源码;可点名打到某个节点 |
272
272
  | **组件** `/components` | 五个可选 extras 的两级安装状态,可就地安装 / 卸载 / 下载浏览器本体 |
273
- | **配置** `/config` | 75 项配置分 12 组,写回 `ipclick.toml`(保留注释、留 `.bak`);集群节点增删 |
273
+ | **配置** `/config` | 75 项配置分 10 组(可折叠),写回 `ipclick.toml`(保留注释、留 `.bak`);隧道代理整串粘贴自动拆分(地址进 toml、凭据进 `.env`);集群节点增删 |
274
274
  | **AI 接入** `/skill` | 技能包全文、安装命令与 `SKILL.md` 下载 |
275
275
 
276
276
  「试一试」走的是本进程 `TaskService` 的**同一条**代码路径——SSRF 准入、限流、以及开了转发时
@@ -176,7 +176,7 @@ with Downloader() as downloader:
176
176
  response = downloader.request(
177
177
  method=HttpMethod.GET,
178
178
  url="https://httpbin.org/get",
179
- headers={"User-Agent": "IPClick/1.0"},
179
+ headers={"User-Agent": "MyApp/1.0"},
180
180
  params={"key": "value"},
181
181
  timeout=30,
182
182
  )
@@ -221,7 +221,7 @@ IPCLICK_WEB_USER=ops IPCLICK_WEB_PASSWORD=... ipclick run -w
221
221
  | **请求流** `/trace` | 实时看请求打进来,按状态 / 适配器 / URL 过滤,看站点排行与按天趋势 |
222
222
  | **试一试** `/test` | 填网址或直接粘一条 curl,就地发一次请求看链路与源码;可点名打到某个节点 |
223
223
  | **组件** `/components` | 五个可选 extras 的两级安装状态,可就地安装 / 卸载 / 下载浏览器本体 |
224
- | **配置** `/config` | 75 项配置分 12 组,写回 `ipclick.toml`(保留注释、留 `.bak`);集群节点增删 |
224
+ | **配置** `/config` | 75 项配置分 10 组(可折叠),写回 `ipclick.toml`(保留注释、留 `.bak`);隧道代理整串粘贴自动拆分(地址进 toml、凭据进 `.env`);集群节点增删 |
225
225
  | **AI 接入** `/skill` | 技能包全文、安装命令与 `SKILL.md` 下载 |
226
226
 
227
227
  「试一试」走的是本进程 `TaskService` 的**同一条**代码路径——SSRF 准入、限流、以及开了转发时
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ipclick"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "IPClick Tool"
5
5
  license = "MIT"
6
6
  license-files = ["LICENSE"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ipclick"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "IPClick Tool"
5
5
  authors = [
6
6
  { name = "元气码农少女酱", email = "1276162429@qq.com" }
@@ -10,7 +10,7 @@ import re
10
10
  import threading
11
11
  from typing import Any, cast
12
12
 
13
- from ipclick.adapters.settings import AdapterSettings
13
+ from ipclick.adapters.settings import DEFAULT_DOWNLOAD_TIMEOUT, AdapterSettings
14
14
  from ipclick.dto.response import Response
15
15
  from ipclick.exceptions import URLNotAllowedError, ValidationError
16
16
  from ipclick.utils.log_util import log
@@ -168,7 +168,7 @@ class DownloaderAdapter(ABC):
168
168
  json: dict[str, Any] | None = None,
169
169
  files: dict[str, Any] | None = None,
170
170
  proxy: str | None = None,
171
- timeout: float = 60,
171
+ timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
172
172
  max_retries: int = 3,
173
173
  retry_delay: float = 2.0,
174
174
  verify: bool = True,
@@ -201,6 +201,17 @@ class DownloaderAdapter(ABC):
201
201
  return
202
202
  yield cast(StreamEvent, item)
203
203
 
204
+ def apply_stream_timeout(self, kwargs: dict[str, Any]) -> None:
205
+ """流式请求没显式给超时时,换用比普通请求更大的那份预算。
206
+
207
+ 调用方显式传了就用调用方的——这里只兜"没传"的情况。放在基类是因为三条流式
208
+ 路径(curl_cffi、niquests 的真流式,以及下面这个先下完再切块的兜底实现)都得
209
+ 用同一份预算,否则"换个适配器就超时"会变成一个查不出原因的差异。
210
+ """
211
+ requested = kwargs.get("timeout")
212
+ if not requested or requested <= 0:
213
+ kwargs["timeout"] = self.settings.stream_timeout
214
+
204
215
  def download_stream(
205
216
  self,
206
217
  url: str,
@@ -209,6 +220,7 @@ class DownloaderAdapter(ABC):
209
220
  **kwargs: Any,
210
221
  ) -> "Iterator[StreamEvent]":
211
222
  """把非流式响应按固定大小切块,作为默认流式实现。"""
223
+ self.apply_stream_timeout(kwargs)
212
224
  response = self.download(url, **kwargs)
213
225
  yield StreamHeader(
214
226
  url=response.url,
@@ -33,7 +33,7 @@ from ipclick.adapters.browser_settings import (
33
33
  resolve_max_pages,
34
34
  )
35
35
  from ipclick.adapters.retry import retry
36
- from ipclick.adapters.settings import AdapterSettings
36
+ from ipclick.adapters.settings import DEFAULT_DOWNLOAD_TIMEOUT, AdapterSettings
37
37
  from ipclick.dto.response import Response
38
38
  from ipclick.exceptions import AdapterError, ValidationError
39
39
  from ipclick.utils.log_util import log
@@ -479,7 +479,7 @@ class BrowserAdapter(DownloaderAdapter):
479
479
  json: dict[str, Any] | None = None,
480
480
  files: dict[str, Any] | None = None,
481
481
  proxy: str | None = None,
482
- timeout: float = 60,
482
+ timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
483
483
  max_retries: int = 3,
484
484
  retry_delay: float = 2.0,
485
485
  verify: bool = True,
@@ -9,7 +9,7 @@ from ipclick.adapters.base import DEFAULT_CHUNK_SIZE, DownloaderAdapter, StreamE
9
9
  from ipclick.adapters.redirects import HopFollowingMixin
10
10
  from ipclick.adapters.retry import aretry, retry
11
11
  from ipclick.adapters.sessions import AsyncSessionCache, SessionCache, reset_cookies
12
- from ipclick.adapters.settings import AdapterSettings
12
+ from ipclick.adapters.settings import DEFAULT_DOWNLOAD_TIMEOUT, AdapterSettings
13
13
  from ipclick.dto.response import Response
14
14
  from ipclick.exceptions import AdapterError, ValidationError
15
15
  from ipclick.utils.log_util import log
@@ -106,7 +106,7 @@ class CurlCffiAdapter(HopFollowingMixin, DownloaderAdapter):
106
106
  正好等于总超时,总预算不会凭空多出一个 connect_timeout。
107
107
 
108
108
  传标量则 ``[DOWNLOADER].connect_timeout`` 完全无从生效——此前就是这样:连一个
109
- 黑洞地址要一直等到 download_timeout(默认 300 秒),每次重试再付一遍。
109
+ 黑洞地址要一直等到 download_timeout,每次重试再付一遍。
110
110
  """
111
111
  total = requested or self.timeout
112
112
  # 读 settings 而不是 self.connect_timeout,和 niquests 那边保持一致。
@@ -155,7 +155,7 @@ class CurlCffiAdapter(HopFollowingMixin, DownloaderAdapter):
155
155
  json: dict[str, Any] | None = None,
156
156
  files: dict[str, Any] | None = None,
157
157
  proxy: str | None = None,
158
- timeout: float = 60,
158
+ timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
159
159
  max_retries: int = 3,
160
160
  retry_delay: float = 2.0,
161
161
  verify: bool = True,
@@ -245,6 +245,7 @@ class CurlCffiAdapter(HopFollowingMixin, DownloaderAdapter):
245
245
  **kwargs: Any,
246
246
  ) -> Iterator[StreamEvent]:
247
247
  """产出响应首部及正文分片,并在生成器结束时关闭响应。"""
248
+ self.apply_stream_timeout(kwargs)
248
249
  method = str(kwargs.get("method", "GET")).upper()
249
250
  if method not in _SUPPORTED_METHODS:
250
251
  yield StreamHeader(url=url, status_code=-1, error=f"Unsupported HTTP method: {method}")
@@ -24,7 +24,7 @@ from ipclick.adapters.browser_settings import (
24
24
  wait_for_timeout_ms,
25
25
  )
26
26
  from ipclick.adapters.retry import retry
27
- from ipclick.adapters.settings import AdapterSettings
27
+ from ipclick.adapters.settings import DEFAULT_DOWNLOAD_TIMEOUT, AdapterSettings
28
28
  from ipclick.dto.response import Response
29
29
  from ipclick.exceptions import AdapterError, ValidationError
30
30
  from ipclick.utils.log_util import log
@@ -132,7 +132,7 @@ class DrissionPageAdapter(DownloaderAdapter):
132
132
  json: dict[str, Any] | None = None,
133
133
  files: dict[str, Any] | None = None,
134
134
  proxy: str | None = None,
135
- timeout: float = 60,
135
+ timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
136
136
  max_retries: int = 3,
137
137
  retry_delay: float = 2.0,
138
138
  verify: bool = True,
@@ -11,7 +11,7 @@ from ipclick.adapters.base import DEFAULT_CHUNK_SIZE, DownloaderAdapter, StreamE
11
11
  from ipclick.adapters.redirects import HopFollowingMixin
12
12
  from ipclick.adapters.retry import aretry, retry
13
13
  from ipclick.adapters.sessions import AsyncSessionCache, SessionCache, reset_cookies
14
- from ipclick.adapters.settings import AdapterSettings
14
+ from ipclick.adapters.settings import DEFAULT_DOWNLOAD_TIMEOUT, AdapterSettings
15
15
  from ipclick.dto.response import Response
16
16
  from ipclick.exceptions import AdapterError, ValidationError
17
17
  from ipclick.utils.log_util import log
@@ -141,7 +141,7 @@ class NiquestsAdapter(HopFollowingMixin, DownloaderAdapter):
141
141
  json: dict[str, Any] | None = None,
142
142
  files: dict[str, Any] | None = None,
143
143
  proxy: str | None = None,
144
- timeout: float = 60,
144
+ timeout: float = DEFAULT_DOWNLOAD_TIMEOUT,
145
145
  max_retries: int = 3,
146
146
  retry_delay: float = 2.0,
147
147
  verify: bool = True,
@@ -231,6 +231,7 @@ class NiquestsAdapter(HopFollowingMixin, DownloaderAdapter):
231
231
  **kwargs: Any,
232
232
  ) -> Iterator[StreamEvent]:
233
233
  """产出响应首部和正文分片,并确保响应最终关闭。"""
234
+ self.apply_stream_timeout(kwargs)
234
235
  self.reject_impersonate(kwargs.get("impersonate"))
235
236
  method = str(kwargs.get("method", "GET")).upper()
236
237
  if method not in _SUPPORTED_METHODS:
@@ -9,6 +9,22 @@ from ipclick.utils.config_util import section
9
9
 
10
10
  DEFAULT_RETRY_STATUS_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
11
11
 
12
+ # 超时的**唯一真源**:随包配置模板、适配器 dataclass、各适配器 download() 的签名默认值、
13
+ # Web 的可编辑字段默认值,全部指向这两个常量。此前它们各写各的(settings 里 300、
14
+ # download() 签名里 60、页面预填 30),"默认超时是多少"这个问题有三个不同的答案。
15
+ #
16
+ # 连接段是从总预算里**先**划走的(见 curl_cffi_adapter._timeout_pair),所以两者不是
17
+ # 并列关系:连接给得越多,留给收数据的越少。20 / 60 这组值是按"走隧道代理"定的——
18
+ # 建连要等代理再去拨目标站,比直连慢得多。
19
+ DEFAULT_CONNECT_TIMEOUT = 20.0
20
+
21
+ DEFAULT_DOWNLOAD_TIMEOUT = 60.0
22
+
23
+ # 流式下载单独一份、且大得多的预算:整段响应体要在这一个预算里收完,而流式用的正是
24
+ # "文件大、收得久"的场景。和普通请求共用 60 秒的话,默认配置下大文件必然中途断,
25
+ # 错误信息还只会说"超时"——看不出预算是按普通请求给的。
26
+ DEFAULT_STREAM_TIMEOUT = 300.0
27
+
12
28
  HARD_MAX_BACKOFF = 300.0
13
29
 
14
30
  HARD_MAX_RETRIES = 20
@@ -18,8 +34,9 @@ HARD_MAX_RETRIES = 20
18
34
  class AdapterSettings:
19
35
  """适配器创建时固定的连接池与重试默认值。"""
20
36
 
21
- connect_timeout: float = 10.0
22
- download_timeout: float = 300.0
37
+ connect_timeout: float = DEFAULT_CONNECT_TIMEOUT
38
+ download_timeout: float = DEFAULT_DOWNLOAD_TIMEOUT
39
+ stream_timeout: float = DEFAULT_STREAM_TIMEOUT
23
40
 
24
41
  max_attempts: int = 3
25
42
  backoff_exponent: float = 2.0
@@ -50,6 +67,7 @@ class AdapterSettings:
50
67
  return cls(
51
68
  connect_timeout=as_positive_float(config.get("connect_timeout"), defaults.connect_timeout),
52
69
  download_timeout=as_positive_float(config.get("download_timeout"), defaults.download_timeout),
70
+ stream_timeout=as_positive_float(config.get("stream_timeout"), defaults.stream_timeout),
53
71
  max_attempts=as_int(retry.get("max_attempts"), defaults.max_attempts, minimum=0, maximum=HARD_MAX_RETRIES),
54
72
  backoff_exponent=as_positive_float(retry.get("backoff_exponent"), defaults.backoff_exponent),
55
73
  initial_backoff=as_positive_float(retry.get("initial_backoff"), defaults.initial_backoff),
@@ -164,7 +164,18 @@ def init(force: bool, target_dir: Path, port: int | None) -> None:
164
164
  env_path.chmod(0o600)
165
165
  with os.fdopen(os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w", encoding="utf-8") as f:
166
166
  _ = f.write(env_text)
167
- click.echo(f"已生成 {env_path}(权限 600,已预填随机 Web 密码)")
167
+ # 上面那个 0o600 只在 POSIX 上真的生效:Windows 的 os.open 把 mode 当成"是否置只读位",
168
+ # ACL 一动没动。照着 POSIX 报"权限 600",等于跟 Windows 用户说这份密钥文件已经受保护
169
+ # 了——它没有,而 dotenv 那边的宽权限告警也只在 POSIX 上跑,不会有人来纠正这句话。
170
+ if os.name == "posix":
171
+ click.echo(f"已生成 {env_path}(权限 600,已预填随机 Web 密码)")
172
+ else:
173
+ click.echo(f"已生成 {env_path}(已预填随机 Web 密码)")
174
+ click.echo(
175
+ f"注意:Windows 上没有收紧 {env_path.name} 的权限(600 是 POSIX 的说法)——"
176
+ "里面是密钥,请自行确认本机其他账户读不到。",
177
+ err=True,
178
+ )
168
179
 
169
180
  gitignore = target_dir / ".gitignore"
170
181
  if gitignore.exists():
@@ -177,7 +177,7 @@ def _from_rpc_error(node: Node, error: grpc.RpcError, started: float) -> ProbeRe
177
177
  reachable=True,
178
178
  authenticated=True,
179
179
  elapsed_ms=_ms(started),
180
- detail="连得上、鉴权也通过,但对方版本低于 0.4(没有 Ping 接口),拿不到它的详细信息",
180
+ detail="连得上、鉴权也通过,但对方没有 Ping 接口(版本太旧),拿不到它的详细信息",
181
181
  )
182
182
 
183
183
  name = getattr(code, "name", str(code))
@@ -0,0 +1,143 @@
1
+ """就地更新 ``.env`` 里的机密项,保留注释和其余全部内容。
2
+
3
+ 和 ``writer.py`` 写 TOML 是同一套思路:只换目标那一行,不重排、不丢注释。差别在于
4
+ ``.env`` 里全是机密,所以**不留 ``.bak``**——一份 644 的备份会把刚收紧的 600 白费掉。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ import os
11
+ from pathlib import Path
12
+ import stat
13
+ import tempfile
14
+
15
+ from ipclick.exceptions import ConfigError
16
+ from ipclick.utils.log_util import log
17
+
18
+
19
+ # 需要引号的字符:空白会被 dotenv 当值的一部分留下但容易看错,'#' 会被当行尾注释,
20
+ # 引号和反斜杠则会被它的转义解析改写。落到这些情况就整段用双引号包起来。
21
+ _NEEDS_QUOTES = set(" \t\"'\\#")
22
+
23
+
24
+ def format_env_value(value: str) -> str:
25
+ """把值编码成 dotenv 解析器能原样读回来的形式。"""
26
+ if not value:
27
+ return ""
28
+ if not any(char in _NEEDS_QUOTES for char in value) and value == value.strip():
29
+ return value
30
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
31
+ escaped = escaped.replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t")
32
+ return f'"{escaped}"'
33
+
34
+
35
+ def _key_of(line: str) -> str:
36
+ """取出一行 dotenv 赋值语句的键名;不是赋值行时返回空串。"""
37
+ text = line.strip()
38
+ if not text or text.startswith("#"):
39
+ return ""
40
+ if text.startswith("export "):
41
+ text = text[len("export ") :].lstrip()
42
+ key, sep, _ = text.partition("=")
43
+ return key.strip() if sep else ""
44
+
45
+
46
+ def set_env_values(text: str, values: dict[str, str]) -> tuple[str, list[str]]:
47
+ """定点更新 dotenv 文本,返回新文本和实际改动的键。
48
+
49
+ 值相同就不算改动——Web 端每次保存都会把整组机密传进来,不筛一遍的话
50
+ 「已更新 0 项」会变成「已更新 6 项」,提示就没意义了。
51
+ """
52
+ lines = text.splitlines()
53
+ changed: list[str] = []
54
+ remaining = dict(values)
55
+
56
+ for index, line in enumerate(lines):
57
+ key = _key_of(line)
58
+ if key not in remaining:
59
+ continue
60
+ new_line = f"{key}={format_env_value(remaining.pop(key))}"
61
+ if lines[index] != new_line:
62
+ lines[index] = new_line
63
+ changed.append(key)
64
+
65
+ for key, value in remaining.items():
66
+ if not value:
67
+ # 文件里本来没有这一项,要设的又是空值——那就是"保持不设置",别追加一行噪音。
68
+ continue
69
+ if lines and lines[-1].strip():
70
+ lines.append("")
71
+ lines.append(f"{key}={format_env_value(value)}")
72
+ changed.append(key)
73
+
74
+ return "\n".join(lines) + "\n", changed
75
+
76
+
77
+ def save_env(path: Path, text: str) -> None:
78
+ """原子写入 ``.env``,新建时用 600 权限。"""
79
+ path = Path(path)
80
+ temp: Path | None = None
81
+ fd: int | None = None
82
+ try:
83
+ # 已存在就沿用它现有的权限(用户可能自己放宽过,别悄悄改);新建一律 600。
84
+ mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600
85
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
86
+ temp = Path(temp_name)
87
+ if hasattr(os, "fchmod"):
88
+ os.fchmod(fd, mode)
89
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
90
+ fd = None
91
+ _ = f.write(text)
92
+ f.flush()
93
+ os.fsync(f.fileno())
94
+ os.replace(temp, path)
95
+ temp = None
96
+ except OSError as e:
97
+ raise ConfigError(f"写入 {path} 失败:{e}") from e
98
+ finally:
99
+ if fd is not None:
100
+ with contextlib.suppress(OSError):
101
+ os.close(fd)
102
+ if temp is not None:
103
+ with contextlib.suppress(OSError):
104
+ temp.unlink()
105
+
106
+ log.info(f"机密已写回 {path}")
107
+
108
+
109
+ def env_target_path() -> Path:
110
+ """选出该写哪个 ``.env``——必须是加载器回头会去读的那一个。
111
+
112
+ 加载器只在**当前工作目录**找 ``.env``(不向上递归),所以写到别处等于写进黑洞。
113
+ """
114
+ from ipclick.config_loader.dotenv import DEFAULT_ENV_FILENAME, find_env_file
115
+
116
+ return find_env_file() or Path.cwd() / DEFAULT_ENV_FILENAME
117
+
118
+
119
+ def update_env_file(values: dict[str, str], path: Path | None = None) -> tuple[Path, list[str]]:
120
+ """把机密写进 ``.env``;文件不存在时先落一份带注释的模板。"""
121
+ target = Path(path) if path is not None else env_target_path()
122
+ if target.exists():
123
+ # utf-8-sig:记事本 / Set-Content 存出来的 .env 带 BOM,跟 dotenv 读取端保持一致。
124
+ text = target.read_text(encoding="utf-8-sig")
125
+ else:
126
+ from ipclick.config_loader.loader import example_env
127
+
128
+ text = example_env()
129
+ log.info(f"{target} 不存在,将以机密模板为基础创建")
130
+
131
+ new_text, changed = set_env_values(text, values)
132
+ if changed or not target.exists():
133
+ save_env(target, new_text)
134
+ return target, changed
135
+
136
+
137
+ __all__ = [
138
+ "env_target_path",
139
+ "format_env_value",
140
+ "save_env",
141
+ "set_env_values",
142
+ "update_env_file",
143
+ ]
@@ -152,7 +152,7 @@ port = 9528
152
152
  # 最大线程数(每个进行中的请求占用一个线程)
153
153
  max_workers = 100
154
154
 
155
- # 工作进程数。1 = 单进程(默认,与旧行为一致),0 = 按 CPU 核数自动(上限 8)。
155
+ # 工作进程数。1 = 单进程(默认),0 = 按 CPU 核数自动(上限 8)。
156
156
  #
157
157
  # 为什么需要它:服务端是一请求一线程的 CPython 进程,**GIL 才是吞吐天花板**。
158
158
  # 实测 16 核机器上单进程只能用出 1.45 个核,`max_workers` 从 32 调到 256 对
@@ -198,14 +198,14 @@ max_concurrent_streams = 0
198
198
  # 1. 自定义适配器只实现了同步 download(),异步模式下它们被丢进线程池跑——
199
199
  # 功能一致,但一请求一线程变成线程池复用线程,谁用了 threading.local
200
200
  # 做缓存行为就变了,而且是静默的。
201
- # 2. 多进程本身已经换过一次并发模型。两套并发模型同时翻默认值,出了问题
201
+ # 2. 多进程本身就是一层并发模型。两套并发模型同时翻默认值,出了问题
202
202
  # 分不清是哪一个引起的。
203
203
  #
204
204
  # 想吃满多核仍然要靠上面的 processes:协程解决并发模型,多进程解决多核,
205
205
  # 两者叠加才是终态。
206
206
  async_mode = false
207
207
 
208
- # 响应压缩:gzip(默认,与旧行为一致)/ deflate / none。
208
+ # 响应压缩:gzip(默认)/ deflate / none。
209
209
  # 本项目最常见的负载是几百字节到几 KB 的 JSON,压它省下的带宽不值那点 CPU;
210
210
  # 同机或内网部署里带宽根本不是瓶颈。但抓整页 HTML、下载文件时它确实有用,
211
211
  # 所以不是"该关掉"而是"该能选"。
@@ -260,10 +260,16 @@ server_name_override = ""
260
260
  # 这些是"请求未显式传参时"的默认值。单次请求传的 timeout / max_retries /
261
261
  # retry_backoff 优先级更高。
262
262
  [DOWNLOADER]
263
- # 连接超时(秒)
264
- connect_timeout = 10
265
- # 下载超时(秒)
266
- download_timeout = 300
263
+ # 一次请求的总预算(秒),建连也算在里面。
264
+ download_timeout = 60
265
+ # 总预算里先划给建连的上限(秒),剩下的归收数据。
266
+ # 所以这两项不是并列的:连接给得越多,留给收数据的越少。
267
+ # 20 是按"走隧道代理"定的——建连要等代理再去拨目标站,比直连慢得多。
268
+ # 直连为主、又要下大文件的话,把 download_timeout 调大,别动这一项。
269
+ connect_timeout = 20
270
+ # 流式下载(stream=true)的总预算(秒)。整段响应体要在这一个预算里收完,
271
+ # 而流式用的正是"文件大、收得久"的场景,所以它单独一份、比上面大得多。
272
+ stream_timeout = 300
267
273
  # 流式下载时每次吐出多少字节。
268
274
  chunk_size = 65536
269
275
  # 是否读取环境变量里的代理设置(HTTP_PROXY / ALL_PROXY)。
@@ -94,11 +94,18 @@ class ProxyConfig:
94
94
  if not self.host and not self.tunnel_server:
95
95
  return None
96
96
 
97
- auth = (
98
- f"{quote(str(self.auth_key), safe='')}:{quote(str(self.auth_password or ''), safe='')}"
99
- if self.auth_key
100
- else ""
101
- )
97
+ auth_key = str(self.auth_key or "").strip()
98
+ auth_password = str(self.auth_password or "")
99
+ # 只填密码不填账号,原先是**静默**丢掉整个凭据段:拼出一个不带鉴权的代理 URL,
100
+ # 请求照发、被隧道商拒掉(多半 407),而报错和"我明明填了密码"毫无关系。
101
+ # 缺一半的凭据只可能是配错了,当场说清楚比让人对着 407 排查半天强。
102
+ if auth_password and not auth_key:
103
+ raise ValidationError(
104
+ "配了代理密码却没配代理账号:IPCLICK_PROXY_AUTH_PASSWORD 有值,"
105
+ "IPCLICK_PROXY_AUTH_KEY 是空的。代理鉴权要两个一起给——"
106
+ "补上账号,或者把密码也清掉(那就是不带鉴权的代理)"
107
+ )
108
+ auth = f"{quote(auth_key, safe='')}:{quote(auth_password, safe='')}" if auth_key else ""
102
109
  channel_name = f":C{quote(str(self.channel_name), safe='')}" if self.channel_name else ""
103
110
  session_ttl = f":T{quote(str(self.session_ttl), safe='')}" if self.session_ttl else ""
104
111
  country_code = f":A{quote(str(self.country_code), safe='')}" if self.country_code else ""
@@ -16,7 +16,7 @@ enum AdapterType {
16
16
  // 维护两套等价的 HTTP 适配器不划算。
17
17
  HTTPX = 1 [deprecated = true];
18
18
  // 已移除:requests 适配器被 niquests 取代(后者 API 相同且支持 HTTP/2、HTTP/3)。
19
- // 编号保留不复用——0.2.3 已发布,旧客户端仍可能发来这个值。
19
+ // 编号保留不复用——这个值已经发布出去过,旧客户端仍可能发来。
20
20
  REQUESTS = 2 [deprecated = true];
21
21
  DRISSIONPAGE = 3;
22
22
  UC = 4; // undetected-chromedriver(未实现)
@@ -254,11 +254,11 @@ service TaskService {
254
254
  rpc SendBatch(stream ReqTask) returns (stream TaskResp) {}
255
255
 
256
256
  // 节点探测:验连通性与集群内部鉴权,不做任何业务动作。
257
- // 0.4 新增——对着 0.3 的节点调会收到 UNIMPLEMENTED,调用方据此判断"连上了、
257
+ // 对着没有这个接口的旧节点调会收到 UNIMPLEMENTED,调用方据此判断"连上了、
258
258
  // 鉴权也过了,只是那台还没升级"。
259
259
  rpc Ping(PingReq) returns (PingResp) {}
260
260
 
261
- // 远程管理可选组件。0.5 新增,**默认关闭**(见 ComponentReq 的说明)。
262
- // 对着 0.4 及更早的节点调会收到 UNIMPLEMENTED,主控据此提示"那台还没升级"。
261
+ // 远程管理可选组件,**默认关闭**(见 ComponentReq 的说明)。对着没有这个
262
+ // 接口的旧节点调会收到 UNIMPLEMENTED,主控据此提示"那台还没升级"。
263
263
  rpc Component(ComponentReq) returns (ComponentResp) {}
264
264
  }
@@ -97,7 +97,7 @@ class TaskServiceServicer:
97
97
 
98
98
  def Ping(self, request, context):
99
99
  """节点探测:验连通性与集群内部鉴权,不做任何业务动作。
100
- 0.4 新增——对着 0.3 的节点调会收到 UNIMPLEMENTED,调用方据此判断"连上了、
100
+ 对着没有这个接口的旧节点调会收到 UNIMPLEMENTED,调用方据此判断"连上了、
101
101
  鉴权也过了,只是那台还没升级"。
102
102
  """
103
103
  context.set_code(grpc.StatusCode.UNIMPLEMENTED)
@@ -105,8 +105,8 @@ class TaskServiceServicer:
105
105
  raise NotImplementedError('Method not implemented!')
106
106
 
107
107
  def Component(self, request, context):
108
- """远程管理可选组件。0.5 新增,**默认关闭**(见 ComponentReq 的说明)。
109
- 对着 0.4 及更早的节点调会收到 UNIMPLEMENTED,主控据此提示"那台还没升级"。
108
+ """远程管理可选组件,**默认关闭**(见 ComponentReq 的说明)。对着没有这个
109
+ 接口的旧节点调会收到 UNIMPLEMENTED,主控据此提示"那台还没升级"。
110
110
  """
111
111
  context.set_code(grpc.StatusCode.UNIMPLEMENTED)
112
112
  context.set_details('Method not implemented!')
@@ -287,8 +287,8 @@ def build_limiter(downloader_config: dict[str, Any] | None) -> HostLimiter:
287
287
  backend = str(rate.get("backend") or "memory").strip().lower()
288
288
  if backend not in ("", "memory", "local"):
289
289
  raise ConfigError(
290
- f"未知的限流后端 {backend!r}。0.3 起只支持 memory——"
291
- f"集群限流由入口节点统一计算,不再需要 Redis。请删掉 [DOWNLOADER.rate_limit].backend"
290
+ f"未知的限流后端 {backend!r}。只支持 memory——"
291
+ f"集群限流由入口节点统一计算,不需要 Redis。请删掉 [DOWNLOADER.rate_limit].backend"
292
292
  )
293
293
  return HostLimiter(LimiterSettings.from_config(config))
294
294
 
@@ -282,8 +282,12 @@ class TaskService(task_pb2_grpc.TaskServiceServicer):
282
282
  return [(k, v) for k, v in value]
283
283
  return value
284
284
 
285
- def _build_download_kwargs(self, request: task_pb2.ReqTask) -> dict[str, Any]:
286
- """将 protobuf 可选字段转换为适配器调用参数。"""
285
+ def _build_download_kwargs(self, request: task_pb2.ReqTask, *, stream: bool = False) -> dict[str, Any]:
286
+ """将 protobuf 可选字段转换为适配器调用参数。
287
+
288
+ ``stream`` 只影响"调用方没给超时"时兜的那个默认值:流式要在一个预算里收完
289
+ 整段响应体,和普通请求共用同一份的话,默认配置下大文件必然中途断。
290
+ """
287
291
  method = METHOD_MAP.get(request.method, "GET")
288
292
 
289
293
  if request.HasField("max_retries") and not 0 <= request.max_retries <= HARD_MAX_RETRIES:
@@ -297,6 +301,8 @@ class TaskService(task_pb2_grpc.TaskServiceServicer):
297
301
 
298
302
  headers = dict(request.headers) if request.headers else None
299
303
  cookies = dict(request.cookies) if request.cookies else None
304
+ settings = self.adapter_settings
305
+ default_timeout = settings.stream_timeout if stream else settings.download_timeout
300
306
 
301
307
  # 查询参数**不过** json_hook:它会把形似 ISO 的字符串还原成 datetime,
302
308
  # 而这些值最终要被 str() 拼进 URL。于是 params={"start": "2024-01-01"} 发出去
@@ -321,11 +327,7 @@ class TaskService(task_pb2_grpc.TaskServiceServicer):
321
327
  "data": data,
322
328
  "json": json_data,
323
329
  "proxy": request.proxy or None,
324
- "timeout": (
325
- request.timeout_seconds
326
- if request.HasField("timeout_seconds")
327
- else self.adapter_settings.download_timeout
328
- ),
330
+ "timeout": (request.timeout_seconds if request.HasField("timeout_seconds") else default_timeout),
329
331
  "max_retries": (
330
332
  request.max_retries if request.HasField("max_retries") else self.adapter_settings.max_attempts
331
333
  ),
@@ -346,8 +348,11 @@ class TaskService(task_pb2_grpc.TaskServiceServicer):
346
348
  "kwargs": request.kwargs or None,
347
349
  }
348
350
 
351
+ # 兜底两处:字段没带(上面)和带了个 0/负数(这里)。当初只有下面这一处按流式
352
+ # 分支,结果"字段没带"那条路照旧拿普通请求的预算——一个只在默认路径上出现、
353
+ # 而单测正好覆盖不到的差异。
349
354
  if not download_kwargs["timeout"] or download_kwargs["timeout"] <= 0:
350
- download_kwargs["timeout"] = self.adapter_settings.download_timeout
355
+ download_kwargs["timeout"] = default_timeout
351
356
 
352
357
  return download_kwargs
353
358
 
@@ -490,7 +495,7 @@ class TaskService(task_pb2_grpc.TaskServiceServicer):
490
495
  真流式适配器若将来加上 ``@retry()``,就必须在那一层自己判断"已经吐过字节了
491
496
  就不能重投",而不是靠这里抹参数——那样做只会让调用方的参数看起来生效实际不生效。
492
497
  """
493
- download_kwargs = self._build_download_kwargs(request)
498
+ download_kwargs = self._build_download_kwargs(request, stream=True)
494
499
  return self._caller_aware_stream(
495
500
  context,
496
501
  self._limited_stream(