dataapp-crawler-octoparse-browser 0.1.0__py3-none-any.whl
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.
- dataapp_crawler_octoparse/__init__.py +13 -0
- dataapp_crawler_octoparse/browserenv.py +439 -0
- dataapp_crawler_octoparse/captcha.py +580 -0
- dataapp_crawler_octoparse/relay.py +293 -0
- dataapp_crawler_octoparse_browser-0.1.0.dist-info/METADATA +188 -0
- dataapp_crawler_octoparse_browser-0.1.0.dist-info/RECORD +7 -0
- dataapp_crawler_octoparse_browser-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Octoparse browser provider extension for DataApp Crawler."""
|
|
2
|
+
|
|
3
|
+
from .browserenv import BrowserEnvRelay
|
|
4
|
+
from .captcha import CaptchaType, ManagedCaptchaPlugin, PreparedCaptchaExtension
|
|
5
|
+
from .relay import RelayBrowser
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"BrowserEnvRelay",
|
|
9
|
+
"CaptchaType",
|
|
10
|
+
"ManagedCaptchaPlugin",
|
|
11
|
+
"PreparedCaptchaExtension",
|
|
12
|
+
"RelayBrowser",
|
|
13
|
+
]
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Connect to BrowserEnv's browser or start its managed-plugin mode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import base64
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Protocol
|
|
17
|
+
from urllib.parse import unquote, urlsplit
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from dataapp_crawler.errors import ConfigurationError
|
|
21
|
+
from dataapp_crawler.models import Identity
|
|
22
|
+
from dataapp_crawler.providers.browser import (
|
|
23
|
+
BrowserHandle,
|
|
24
|
+
_best_effort_close,
|
|
25
|
+
_maybe_await,
|
|
26
|
+
_start_playwright,
|
|
27
|
+
)
|
|
28
|
+
from dataapp_crawler.providers.proxy import ProxyHandle
|
|
29
|
+
|
|
30
|
+
from .captcha import ManagedCaptchaPlugin, PreparedCaptchaExtension
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _WafTask(Protocol):
|
|
34
|
+
@property
|
|
35
|
+
def indices(self) -> tuple[int, ...] | None: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _WafResources(Protocol):
|
|
39
|
+
async def solve_captcha(
|
|
40
|
+
self, *, type: str, target: str, images: Sequence[str],
|
|
41
|
+
idempotency_key: str, timeout: float,
|
|
42
|
+
) -> _WafTask: ...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class BrowserEnvRelay:
|
|
46
|
+
"""Borrow BrowserEnv's exclusive Relay browser for one Crawler.
|
|
47
|
+
|
|
48
|
+
In the default mode, BrowserEnv's ``octoparse-relay-task`` owns the browser
|
|
49
|
+
process and Relay extension. With ``captcha=...``, this provider obtains a
|
|
50
|
+
run-scoped plugin grant and asks BrowserEnv's launcher to start the browser
|
|
51
|
+
only after the current Data App Run exists. A proxy is installed
|
|
52
|
+
process-wide in BrowserEnv's local GOST chain; BrowserEnv may still route
|
|
53
|
+
image, audio, and video requests directly.
|
|
54
|
+
Pass ``aws_waf_resources=app.resources()`` to use the current Run's
|
|
55
|
+
managed image solver with the Relay extension.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
_state_lock = threading.Lock()
|
|
59
|
+
_active = False
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
connect_timeout: float = 45,
|
|
65
|
+
playwright_factory: Callable[[], Any] | None = None,
|
|
66
|
+
connection_factory: Callable[..., Any] | None = None,
|
|
67
|
+
http_transport: httpx.AsyncBaseTransport | None = None,
|
|
68
|
+
captcha: ManagedCaptchaPlugin | None = None,
|
|
69
|
+
aws_waf_resources: _WafResources | None = None,
|
|
70
|
+
launcher_path: str | Path = "/usr/local/bin/octoparse-relay-task",
|
|
71
|
+
launcher_factory: Callable[..., Any] | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not math.isfinite(connect_timeout) or connect_timeout <= 0:
|
|
74
|
+
raise ConfigurationError("connect_timeout must be finite and positive")
|
|
75
|
+
self._connect_timeout = connect_timeout
|
|
76
|
+
self._playwright_factory = playwright_factory
|
|
77
|
+
self._connection_factory = connection_factory
|
|
78
|
+
self._http_transport = http_transport
|
|
79
|
+
self._captcha = captcha
|
|
80
|
+
self._aws_waf_resources = aws_waf_resources
|
|
81
|
+
self._launcher_path = Path(launcher_path)
|
|
82
|
+
self._launcher_factory = launcher_factory
|
|
83
|
+
|
|
84
|
+
def __repr__(self) -> str:
|
|
85
|
+
return "BrowserEnvRelay()"
|
|
86
|
+
|
|
87
|
+
async def _solve_aws_waf(
|
|
88
|
+
self, target: str, images: list[str], request_id: str
|
|
89
|
+
) -> Sequence[int]:
|
|
90
|
+
resources = self._aws_waf_resources
|
|
91
|
+
assert resources is not None
|
|
92
|
+
task = await resources.solve_captcha(
|
|
93
|
+
type="aws_waf_image",
|
|
94
|
+
target=target,
|
|
95
|
+
images=images,
|
|
96
|
+
idempotency_key=request_id,
|
|
97
|
+
timeout=45,
|
|
98
|
+
)
|
|
99
|
+
if task.indices is None:
|
|
100
|
+
raise ConfigurationError("Managed AWS WAF solver returned no image selection")
|
|
101
|
+
return task.indices
|
|
102
|
+
|
|
103
|
+
@classmethod
|
|
104
|
+
def _claim(cls) -> None:
|
|
105
|
+
with cls._state_lock:
|
|
106
|
+
if cls._active:
|
|
107
|
+
raise ConfigurationError(
|
|
108
|
+
"BrowserEnv exposes one shared browser; close its current handle "
|
|
109
|
+
"before opening another"
|
|
110
|
+
)
|
|
111
|
+
cls._active = True
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
async def _release_claim(cls) -> None:
|
|
115
|
+
with cls._state_lock:
|
|
116
|
+
cls._active = False
|
|
117
|
+
|
|
118
|
+
async def open(
|
|
119
|
+
self, *, identity: Identity | None = None, proxy: ProxyHandle | None = None
|
|
120
|
+
) -> BrowserHandle:
|
|
121
|
+
if identity is not None and (
|
|
122
|
+
identity.cookies or identity.headers or identity.storage_state is not None
|
|
123
|
+
):
|
|
124
|
+
raise ConfigurationError(
|
|
125
|
+
"BrowserEnv's shared browser cannot safely apply per-Crawler identity state"
|
|
126
|
+
)
|
|
127
|
+
if proxy is not None:
|
|
128
|
+
# Validate before claiming the process-wide browser. A failed GOST
|
|
129
|
+
# request may still have applied, so later transport failures must
|
|
130
|
+
# always attempt to restore direct routing.
|
|
131
|
+
_chain_payload(proxy.http_url())
|
|
132
|
+
self._claim()
|
|
133
|
+
playwright = None
|
|
134
|
+
connection = None
|
|
135
|
+
proxy_configured = proxy is not None
|
|
136
|
+
prepared_captcha: PreparedCaptchaExtension | None = None
|
|
137
|
+
launcher = None
|
|
138
|
+
launch_runtime: tempfile.TemporaryDirectory[str] | None = None
|
|
139
|
+
relay_environment: dict[str, str] = {}
|
|
140
|
+
try:
|
|
141
|
+
await self._configure_proxy(proxy)
|
|
142
|
+
if self._captcha is not None:
|
|
143
|
+
prepared_captcha = await self._captcha.prepare()
|
|
144
|
+
prepared_captcha.require_active()
|
|
145
|
+
launcher, launch_runtime, relay_environment = (
|
|
146
|
+
await self._start_managed_browser(prepared_captcha)
|
|
147
|
+
)
|
|
148
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
149
|
+
playwright = await _start_playwright(self._playwright_factory)
|
|
150
|
+
connection_factory = self._connection_factory
|
|
151
|
+
if connection_factory is None:
|
|
152
|
+
try:
|
|
153
|
+
from octoparse.playwright_relay import connect_async # type: ignore[import-untyped]
|
|
154
|
+
except ImportError as error:
|
|
155
|
+
raise ConfigurationError(
|
|
156
|
+
"Install dataapp-crawler-octoparse-browser to use BrowserEnvRelay"
|
|
157
|
+
) from error
|
|
158
|
+
connection_factory = connect_async
|
|
159
|
+
connection_options: dict[str, Any] = {"timeout": self._connect_timeout}
|
|
160
|
+
if self._aws_waf_resources is not None:
|
|
161
|
+
connection_options["aws_waf_solver"] = self._solve_aws_waf
|
|
162
|
+
with _temporary_environment(relay_environment):
|
|
163
|
+
connection = await _maybe_await(
|
|
164
|
+
connection_factory(playwright.chromium, **connection_options)
|
|
165
|
+
)
|
|
166
|
+
browser = connection.browser
|
|
167
|
+
if not browser.contexts:
|
|
168
|
+
raise ConfigurationError("BrowserEnv Relay exposed no browser context")
|
|
169
|
+
context = browser.contexts[0]
|
|
170
|
+
if prepared_captcha is not None:
|
|
171
|
+
prepared_captcha.require_active()
|
|
172
|
+
except BaseException as error:
|
|
173
|
+
closers = []
|
|
174
|
+
if playwright is not None:
|
|
175
|
+
closers.append(playwright.stop)
|
|
176
|
+
if connection is not None and getattr(connection, "server", None) is not None:
|
|
177
|
+
closers.append(connection.close)
|
|
178
|
+
if launcher is not None:
|
|
179
|
+
closers.append(lambda: _stop_launcher(launcher))
|
|
180
|
+
if prepared_captcha is not None:
|
|
181
|
+
closers.append(prepared_captcha.close)
|
|
182
|
+
await _best_effort_close(*closers)
|
|
183
|
+
if launch_runtime is not None:
|
|
184
|
+
try:
|
|
185
|
+
launch_runtime.cleanup()
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
if proxy_configured:
|
|
189
|
+
try:
|
|
190
|
+
await self._configure_proxy(None)
|
|
191
|
+
except BaseException:
|
|
192
|
+
pass
|
|
193
|
+
await self._release_claim()
|
|
194
|
+
if not isinstance(error, Exception):
|
|
195
|
+
raise
|
|
196
|
+
if isinstance(error, ConfigurationError):
|
|
197
|
+
raise
|
|
198
|
+
raise ConfigurationError(
|
|
199
|
+
f"BrowserEnv Relay connection failed ({type(error).__name__})"
|
|
200
|
+
) from None
|
|
201
|
+
|
|
202
|
+
async def stop_playwright() -> None:
|
|
203
|
+
await _maybe_await(playwright.stop())
|
|
204
|
+
|
|
205
|
+
async def close_owned_relay() -> None:
|
|
206
|
+
await _maybe_await(connection.close())
|
|
207
|
+
|
|
208
|
+
async def restore_direct() -> None:
|
|
209
|
+
await self._configure_proxy(None)
|
|
210
|
+
|
|
211
|
+
async def stop_launcher() -> None:
|
|
212
|
+
if launcher is not None:
|
|
213
|
+
await _stop_launcher(launcher)
|
|
214
|
+
|
|
215
|
+
async def cleanup_captcha() -> None:
|
|
216
|
+
if prepared_captcha is not None:
|
|
217
|
+
await prepared_captcha.close()
|
|
218
|
+
|
|
219
|
+
async def cleanup_launch_runtime() -> None:
|
|
220
|
+
if launch_runtime is not None:
|
|
221
|
+
launch_runtime.cleanup()
|
|
222
|
+
|
|
223
|
+
closers = [stop_playwright]
|
|
224
|
+
if getattr(connection, "server", None) is not None:
|
|
225
|
+
closers.append(close_owned_relay)
|
|
226
|
+
if launcher is not None:
|
|
227
|
+
closers.extend([stop_launcher, cleanup_captcha, cleanup_launch_runtime])
|
|
228
|
+
closers.extend([restore_direct, self._release_claim])
|
|
229
|
+
capabilities = {"page", "relay", "process_proxy"}
|
|
230
|
+
if launcher is None:
|
|
231
|
+
capabilities.add("borrowed")
|
|
232
|
+
else:
|
|
233
|
+
capabilities.add("captcha_plugin")
|
|
234
|
+
return BrowserHandle(
|
|
235
|
+
provider="browserenv-relay",
|
|
236
|
+
capabilities=frozenset(capabilities),
|
|
237
|
+
owned=launcher is not None,
|
|
238
|
+
context=context,
|
|
239
|
+
_closers=closers,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
async def _start_managed_browser(
|
|
243
|
+
self, prepared: PreparedCaptchaExtension
|
|
244
|
+
) -> tuple[Any, tempfile.TemporaryDirectory[str], dict[str, str]]:
|
|
245
|
+
launcher_path = self._launcher_path
|
|
246
|
+
if not launcher_path.is_absolute():
|
|
247
|
+
raise ConfigurationError("BrowserEnv launcher path must be absolute")
|
|
248
|
+
runtime = tempfile.TemporaryDirectory(prefix="dataos-browserenv-")
|
|
249
|
+
ready_file = Path(runtime.name) / "ready"
|
|
250
|
+
factory = self._launcher_factory or asyncio.create_subprocess_exec
|
|
251
|
+
environment = dict(os.environ)
|
|
252
|
+
environment.pop("CAPMONSTER_API_KEY", None)
|
|
253
|
+
environment.pop("CAPMONSTER_CAPTCHA_TYPES", None)
|
|
254
|
+
try:
|
|
255
|
+
process = await _maybe_await(
|
|
256
|
+
factory(
|
|
257
|
+
str(launcher_path),
|
|
258
|
+
"--browser-only",
|
|
259
|
+
"--captcha-extension",
|
|
260
|
+
str(prepared.extension_path),
|
|
261
|
+
"--ready-file",
|
|
262
|
+
str(ready_file),
|
|
263
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
264
|
+
env=environment,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
async with asyncio.timeout(self._connect_timeout):
|
|
268
|
+
while not ready_file.is_file():
|
|
269
|
+
if process.returncode is not None:
|
|
270
|
+
raise ConfigurationError(
|
|
271
|
+
"BrowserEnv launcher exited before browser readiness"
|
|
272
|
+
)
|
|
273
|
+
await asyncio.sleep(0.05)
|
|
274
|
+
relay_environment = _read_launcher_ready(ready_file)
|
|
275
|
+
if not relay_environment:
|
|
276
|
+
raise ConfigurationError("BrowserEnv launcher returned invalid readiness")
|
|
277
|
+
prepared.require_active()
|
|
278
|
+
return process, runtime, relay_environment
|
|
279
|
+
except BaseException:
|
|
280
|
+
if "process" in locals():
|
|
281
|
+
await _best_effort_close(lambda: _stop_launcher(process))
|
|
282
|
+
runtime.cleanup()
|
|
283
|
+
raise
|
|
284
|
+
|
|
285
|
+
async def _configure_proxy(self, proxy: ProxyHandle | None) -> None:
|
|
286
|
+
api_url = _proxy_api_setting(
|
|
287
|
+
"URL",
|
|
288
|
+
"http://127.0.0.1:18080/api/config/chains/chain-0",
|
|
289
|
+
).strip()
|
|
290
|
+
endpoint = urlsplit(api_url)
|
|
291
|
+
if (
|
|
292
|
+
endpoint.scheme != "http"
|
|
293
|
+
or endpoint.hostname is None
|
|
294
|
+
or endpoint.username is not None
|
|
295
|
+
or endpoint.password is not None
|
|
296
|
+
or endpoint.query
|
|
297
|
+
or endpoint.fragment
|
|
298
|
+
or not _loopback(endpoint.hostname)
|
|
299
|
+
):
|
|
300
|
+
raise ConfigurationError(
|
|
301
|
+
"BrowserEnv proxy software API must be an HTTP loopback endpoint"
|
|
302
|
+
)
|
|
303
|
+
payload = _chain_payload(proxy.http_url() if proxy is not None else None)
|
|
304
|
+
username = _proxy_api_setting("USERNAME", "admin")
|
|
305
|
+
password = _proxy_api_setting("PASSWORD", "")
|
|
306
|
+
authorization = base64.b64encode(f"{username}:{password}".encode()).decode()
|
|
307
|
+
try:
|
|
308
|
+
async with httpx.AsyncClient(
|
|
309
|
+
transport=self._http_transport,
|
|
310
|
+
timeout=self._connect_timeout,
|
|
311
|
+
trust_env=False,
|
|
312
|
+
) as client:
|
|
313
|
+
response = await client.put(
|
|
314
|
+
api_url,
|
|
315
|
+
json=payload,
|
|
316
|
+
headers={"Authorization": f"Basic {authorization}"},
|
|
317
|
+
)
|
|
318
|
+
response.raise_for_status()
|
|
319
|
+
except (httpx.HTTPError, ValueError):
|
|
320
|
+
raise ConfigurationError("BrowserEnv proxy configuration failed") from None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _loopback(hostname: str) -> bool:
|
|
324
|
+
if hostname.lower().rstrip(".") == "localhost":
|
|
325
|
+
return True
|
|
326
|
+
try:
|
|
327
|
+
return ipaddress.ip_address(hostname).is_loopback
|
|
328
|
+
except ValueError:
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
async def _stop_launcher(process: Any) -> None:
|
|
333
|
+
if process.returncode is not None:
|
|
334
|
+
await _maybe_await(process.wait())
|
|
335
|
+
return
|
|
336
|
+
process.terminate()
|
|
337
|
+
try:
|
|
338
|
+
async with asyncio.timeout(4):
|
|
339
|
+
await _maybe_await(process.wait())
|
|
340
|
+
except TimeoutError:
|
|
341
|
+
process.kill()
|
|
342
|
+
await _maybe_await(process.wait())
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _read_launcher_ready(path: Path) -> dict[str, str]:
|
|
346
|
+
try:
|
|
347
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
348
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
349
|
+
return {}
|
|
350
|
+
if payload.get("ready") is not True:
|
|
351
|
+
return {}
|
|
352
|
+
browser = payload.get("browser")
|
|
353
|
+
chrome = payload.get("chrome")
|
|
354
|
+
profile = payload.get("profile")
|
|
355
|
+
if browser not in {"chromium", "google-stable", "cloakbrowser"}:
|
|
356
|
+
return {}
|
|
357
|
+
if not all(
|
|
358
|
+
isinstance(value, str)
|
|
359
|
+
and value.startswith("/")
|
|
360
|
+
and "\x00" not in value
|
|
361
|
+
and "\n" not in value
|
|
362
|
+
for value in (chrome, profile)
|
|
363
|
+
):
|
|
364
|
+
return {}
|
|
365
|
+
return {
|
|
366
|
+
"BROWSER": "/usr/local/bin/octoparse-relay-open-url %s",
|
|
367
|
+
"OCTOPARSE_RELAY_BROWSER": browser,
|
|
368
|
+
"OCTOPARSE_RELAY_CHROME_BIN": chrome,
|
|
369
|
+
"OCTOPARSE_RELAY_PROFILE_DIR": profile,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@contextmanager
|
|
374
|
+
def _temporary_environment(values: dict[str, str]):
|
|
375
|
+
previous = {name: os.environ.get(name) for name in values}
|
|
376
|
+
os.environ.update(values)
|
|
377
|
+
try:
|
|
378
|
+
yield
|
|
379
|
+
finally:
|
|
380
|
+
for name, value in previous.items():
|
|
381
|
+
if value is None:
|
|
382
|
+
os.environ.pop(name, None)
|
|
383
|
+
else:
|
|
384
|
+
os.environ[name] = value
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _proxy_api_setting(name: str, default: str) -> str:
|
|
388
|
+
return os.environ.get(
|
|
389
|
+
f"DATAAPP_BROWSER_PROXY_API_{name}",
|
|
390
|
+
os.environ.get(f"OCTOPUS_PROXY_SOFTWARE_API_{name}", default),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _chain_payload(proxy_url: str | None) -> dict[str, Any]:
|
|
395
|
+
keepalive = {
|
|
396
|
+
"keepalive": True,
|
|
397
|
+
"keepalive.idle": "60s",
|
|
398
|
+
"keepalive.interval": "15s",
|
|
399
|
+
"keepalive.count": 4,
|
|
400
|
+
}
|
|
401
|
+
if proxy_url is None:
|
|
402
|
+
node: dict[str, Any] = {
|
|
403
|
+
"name": "node-0",
|
|
404
|
+
"addr": ":0",
|
|
405
|
+
"connector": {"type": "direct"},
|
|
406
|
+
"dialer": {"type": "direct", "metadata": keepalive},
|
|
407
|
+
}
|
|
408
|
+
else:
|
|
409
|
+
parts = urlsplit(proxy_url)
|
|
410
|
+
if (
|
|
411
|
+
parts.scheme not in {"http", "https"}
|
|
412
|
+
or parts.hostname is None
|
|
413
|
+
or parts.path not in {"", "/"}
|
|
414
|
+
or parts.query
|
|
415
|
+
or parts.fragment
|
|
416
|
+
):
|
|
417
|
+
raise ConfigurationError("BrowserEnv supports only HTTP and HTTPS upstream proxies")
|
|
418
|
+
if parts.password is not None and parts.username is None:
|
|
419
|
+
raise ConfigurationError("BrowserEnv proxy password requires a username")
|
|
420
|
+
host = f"[{parts.hostname}]" if ":" in parts.hostname else parts.hostname
|
|
421
|
+
tls = parts.scheme == "https"
|
|
422
|
+
dialer: dict[str, Any] = {"type": "tcp", "metadata": keepalive}
|
|
423
|
+
if tls:
|
|
424
|
+
dialer["type"] = "tls"
|
|
425
|
+
dialer["tls"] = {"secure": True, "serverName": parts.hostname}
|
|
426
|
+
port = parts.port if parts.port is not None else (443 if tls else 80)
|
|
427
|
+
node = {
|
|
428
|
+
"name": "node-0",
|
|
429
|
+
"addr": f"{host}:{port}",
|
|
430
|
+
"connector": {
|
|
431
|
+
"type": "http",
|
|
432
|
+
"auth": {
|
|
433
|
+
"username": unquote(parts.username or ""),
|
|
434
|
+
"password": unquote(parts.password or ""),
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
"dialer": dialer,
|
|
438
|
+
}
|
|
439
|
+
return {"name": "chain-0", "hops": [{"name": "hop-0", "nodes": [node]}]}
|