wayparam 0.3.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.
wayparam/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ __all__ = ["__version__"]
4
+ __version__ = "0.3.0"
wayparam/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
wayparam/cli.py ADDED
@@ -0,0 +1,350 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import inspect
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ import httpx
12
+
13
+ from .filters import DEFAULT_EXT_BLACKLIST, FilterOptions, is_boring, parse_ext_set
14
+ from .http import HttpConfig
15
+ from .io import ensure_dir, read_domains
16
+ from .normalize import NormalizeOptions, canonicalize_url
17
+ from .output import (
18
+ UrlRecord,
19
+ now_utc_iso,
20
+ open_outfile,
21
+ print_hint_stderr,
22
+ print_record_stdout,
23
+ write_record,
24
+ )
25
+ from .ratelimit import RateLimiter
26
+ from .wayback import CdxOptions, iter_original_urls
27
+
28
+ log = logging.getLogger("wayparam")
29
+
30
+
31
+ def build_arg_parser() -> argparse.ArgumentParser:
32
+ p = argparse.ArgumentParser(
33
+ prog="wayparam",
34
+ description="Fetch and normalize parameterized URLs from the Wayback CDX API.",
35
+ )
36
+
37
+ src = p.add_mutually_exclusive_group(required=True)
38
+ src.add_argument("-d", "--domain", help="Single domain/host (e.g. example.com)")
39
+ src.add_argument("-l", "--list", help="File with domains (one per line). Use '-' for stdin.")
40
+
41
+ p.add_argument("-o", "--outdir", default="results", help="Output directory (default: results)")
42
+ p.add_argument(
43
+ "--stdout",
44
+ action="store_true",
45
+ help="Stream results to stdout (machine-readable). Diagnostics stay on stderr.",
46
+ )
47
+ p.add_argument(
48
+ "--format",
49
+ choices=["txt", "jsonl"],
50
+ default="txt",
51
+ help="Output format: txt or jsonl (default: txt)",
52
+ )
53
+ p.add_argument(
54
+ "--no-files", action="store_true", help="Do not write per-domain files (use with --stdout)."
55
+ )
56
+ p.add_argument(
57
+ "--stats", action="store_true", help="Print per-domain stats to stderr at the end."
58
+ )
59
+ p.add_argument("--quiet", action="store_true", help="Only show errors (stderr).")
60
+
61
+ # Wayback/CDX options
62
+ p.add_argument(
63
+ "--include-subdomains", action="store_true", help="Include subdomains (matchType=domain)."
64
+ )
65
+ p.add_argument(
66
+ "--from", dest="from_ts", default=None, help="Filter captures from timestamp/year."
67
+ )
68
+ p.add_argument("--to", dest="to_ts", default=None, help="Filter captures to timestamp/year.")
69
+ p.add_argument(
70
+ "--no-collapse", action="store_true", help="Disable collapse=urlkey (more duplicates)."
71
+ )
72
+ p.add_argument(
73
+ "--filter",
74
+ action="append",
75
+ default=None,
76
+ help="CDX filter string (repeatable). Example: statuscode:200",
77
+ )
78
+ p.add_argument("--limit", type=int, default=50000, help="CDX page size (default: 50000).")
79
+
80
+ # Normalization/filtering options
81
+ p.add_argument(
82
+ "--placeholder", default="FUZZ", help="Placeholder for parameter values (default: FUZZ)."
83
+ )
84
+ p.add_argument("--keep-values", action="store_true", help="Keep original parameter values.")
85
+ p.add_argument(
86
+ "--all-urls", action="store_true", help="Keep URLs even without query parameters."
87
+ )
88
+ p.add_argument(
89
+ "--drop-tracking",
90
+ action="store_true",
91
+ default=True,
92
+ help="Drop common tracking params (default: on).",
93
+ )
94
+ p.add_argument(
95
+ "--no-drop-tracking",
96
+ action="store_false",
97
+ dest="drop_tracking",
98
+ help="Do not drop tracking params.",
99
+ )
100
+
101
+ p.add_argument(
102
+ "--ext-blacklist",
103
+ default=None,
104
+ help="Comma-separated extensions to exclude (overrides defaults).",
105
+ )
106
+ p.add_argument(
107
+ "--ext-whitelist",
108
+ default=None,
109
+ help="Comma-separated extensions to allow; anything else is excluded.",
110
+ )
111
+ p.add_argument(
112
+ "--exclude-path-regex",
113
+ action="append",
114
+ default=None,
115
+ help="Regex to exclude by PATH (repeatable).",
116
+ )
117
+
118
+ # Performance/network
119
+ p.add_argument("--concurrency", type=int, default=6, help="Concurrent domains (default: 6).")
120
+ p.add_argument(
121
+ "--rps",
122
+ type=float,
123
+ default=0.0,
124
+ help="Global requests-per-second to Wayback (0 = unlimited).",
125
+ )
126
+ p.add_argument(
127
+ "--timeout", type=float, default=30.0, help="HTTP timeout seconds (default: 30)."
128
+ )
129
+ p.add_argument("--retries", type=int, default=4, help="HTTP retries (default: 4).")
130
+ p.add_argument("--proxy", default=None, help="HTTP proxy URL (e.g. http://127.0.0.1:8080).")
131
+ p.add_argument("--user-agent", default=None, help="Override User-Agent.")
132
+ p.add_argument(
133
+ "-v", "--verbose", action="count", default=0, help="Increase log verbosity (-v or -vv)."
134
+ )
135
+ return p
136
+
137
+
138
+ def _setup_logging(verbosity: int, quiet: bool) -> None:
139
+ if quiet:
140
+ level = logging.ERROR
141
+ else:
142
+ level = logging.WARNING
143
+ if verbosity == 1:
144
+ level = logging.INFO
145
+ elif verbosity >= 2:
146
+ level = logging.DEBUG
147
+ logging.basicConfig(level=level, format="%(levelname)s %(message)s")
148
+
149
+
150
+ def _maybe_print_wayback_vpn_hint(exc: Exception) -> None:
151
+ msg = str(exc)
152
+ if "web.archive.org/cdx/search/cdx" in msg and "failed after retries" in msg.lower():
153
+ print_hint_stderr(
154
+ "Hint: Requests to the Wayback CDX API failed after multiple retries. "
155
+ "This is often caused by a VPN/proxy exit node being blocked or rate-limited by web.archive.org. "
156
+ "Try disconnecting your VPN/proxy (or switching to a different VPN server), then re-run the same command."
157
+ )
158
+
159
+
160
+ def _asyncclient_kwargs(args: argparse.Namespace, limits: httpx.Limits) -> dict:
161
+ """Support httpx 'proxy' (new) and 'proxies' (old) without pinning versions."""
162
+ kwargs: dict = {"limits": limits, "follow_redirects": True}
163
+ if not args.proxy:
164
+ return kwargs
165
+
166
+ try:
167
+ params = inspect.signature(httpx.AsyncClient).parameters
168
+ if "proxy" in params:
169
+ kwargs["proxy"] = args.proxy
170
+ else:
171
+ kwargs["proxies"] = args.proxy
172
+ except Exception:
173
+ # Fallback: try the new name first
174
+ kwargs["proxy"] = args.proxy
175
+ return kwargs
176
+
177
+
178
+ async def _process_domain(
179
+ domain: str,
180
+ *,
181
+ client: httpx.AsyncClient,
182
+ http_cfg: HttpConfig,
183
+ rate_limiter: RateLimiter | None,
184
+ cdx_opt: CdxOptions,
185
+ norm_opt: NormalizeOptions,
186
+ filt_opt: FilterOptions,
187
+ outdir: Path,
188
+ write_files: bool,
189
+ to_stdout: bool,
190
+ out_format: str,
191
+ ) -> tuple[str, int, int]:
192
+ fetched = 0
193
+ kept = 0
194
+ seen: set[str] = set()
195
+
196
+ out_fh = None
197
+ if write_files:
198
+ ext = "jsonl" if out_format == "jsonl" else "txt"
199
+ out_fh = open_outfile(outdir / f"{domain}.{ext}")
200
+
201
+ try:
202
+ async for raw in iter_original_urls(
203
+ domain,
204
+ client=client,
205
+ http_config=http_cfg,
206
+ rate_limiter=rate_limiter,
207
+ opt=cdx_opt,
208
+ ):
209
+ fetched += 1
210
+
211
+ if is_boring(raw, filt_opt):
212
+ continue
213
+
214
+ canon = canonicalize_url(raw, norm_opt)
215
+ if canon is None:
216
+ continue
217
+
218
+ if is_boring(canon, filt_opt):
219
+ continue
220
+
221
+ if canon in seen:
222
+ continue
223
+ seen.add(canon)
224
+
225
+ kept += 1
226
+ rec = UrlRecord(domain=domain, url=canon, fetched_at=now_utc_iso())
227
+
228
+ if out_fh:
229
+ write_record(out_fh, rec, out_format) # type: ignore[arg-type]
230
+ if to_stdout:
231
+ print_record_stdout(rec, out_format) # type: ignore[arg-type]
232
+
233
+ finally:
234
+ if out_fh:
235
+ out_fh.close()
236
+
237
+ return domain, fetched, kept
238
+
239
+
240
+ async def run_async(args: argparse.Namespace) -> int:
241
+ # Input
242
+ domains = [args.domain.strip().lower()] if args.domain else read_domains(args.list)
243
+
244
+ # Output
245
+ outdir = Path(args.outdir)
246
+ write_files = not args.no_files
247
+ if write_files:
248
+ ensure_dir(outdir)
249
+
250
+ if args.no_files and not args.stdout:
251
+ raise SystemExit("--no-files requires --stdout")
252
+
253
+ # Filters
254
+ if args.ext_blacklist:
255
+ ext_blacklist = parse_ext_set(args.ext_blacklist)
256
+ else:
257
+ ext_blacklist = set(DEFAULT_EXT_BLACKLIST)
258
+
259
+ ext_whitelist = parse_ext_set(args.ext_whitelist) if args.ext_whitelist else None
260
+
261
+ import re as _re
262
+
263
+ path_rx = [_re.compile(x) for x in (args.exclude_path_regex or [])] or None
264
+ filt_opt = FilterOptions(
265
+ ext_blacklist=ext_blacklist, ext_whitelist=ext_whitelist, path_exclude_regex=path_rx
266
+ )
267
+
268
+ # Normalization
269
+ norm_opt = NormalizeOptions(
270
+ placeholder=args.placeholder,
271
+ keep_values=args.keep_values,
272
+ only_params=(not args.all_urls),
273
+ drop_tracking=args.drop_tracking,
274
+ )
275
+
276
+ # CDX options
277
+ cdx_opt = CdxOptions(
278
+ include_subdomains=args.include_subdomains,
279
+ collapse=None if args.no_collapse else "urlkey",
280
+ from_ts=args.from_ts,
281
+ to_ts=args.to_ts,
282
+ limit=args.limit,
283
+ filters=args.filter,
284
+ )
285
+
286
+ # HTTP config
287
+ http_cfg = HttpConfig(
288
+ timeout_s=args.timeout,
289
+ retries=args.retries,
290
+ user_agent=args.user_agent,
291
+ proxy=args.proxy,
292
+ )
293
+
294
+ limits = httpx.Limits(
295
+ max_connections=max(10, args.concurrency * 4),
296
+ max_keepalive_connections=max(10, args.concurrency * 2),
297
+ )
298
+
299
+ rate_limiter = RateLimiter(args.rps) if args.rps and args.rps > 0 else None
300
+ sem = asyncio.Semaphore(max(1, args.concurrency))
301
+
302
+ async def guarded(d: str):
303
+ async with sem:
304
+ return await _process_domain(
305
+ d,
306
+ client=client,
307
+ http_cfg=http_cfg,
308
+ rate_limiter=rate_limiter,
309
+ cdx_opt=cdx_opt,
310
+ norm_opt=norm_opt,
311
+ filt_opt=filt_opt,
312
+ outdir=outdir,
313
+ write_files=write_files,
314
+ to_stdout=args.stdout,
315
+ out_format=args.format,
316
+ )
317
+
318
+ async with httpx.AsyncClient(**_asyncclient_kwargs(args, limits)) as client:
319
+ tasks = [asyncio.create_task(guarded(d)) for d in domains]
320
+ results = await asyncio.gather(*tasks, return_exceptions=True)
321
+
322
+ ok = 0
323
+ domain_stats: list[tuple[str, int, int]] = []
324
+
325
+ for r in results:
326
+ if isinstance(r, Exception):
327
+ log.error("Error: %s", r)
328
+ _maybe_print_wayback_vpn_hint(r)
329
+ continue
330
+ domain, fetched, kept = r
331
+ ok += 1
332
+ domain_stats.append((domain, fetched, kept))
333
+ log.info("%s: fetched=%d kept=%d", domain, fetched, kept)
334
+
335
+ if args.stats:
336
+ for d, fetched, kept in domain_stats:
337
+ print_hint_stderr(f"Stats: {d}: fetched={fetched} kept={kept}")
338
+
339
+ return 0 if ok == len(domains) else 2
340
+
341
+
342
+ def main(argv: list[str] | None = None) -> int:
343
+ parser = build_arg_parser()
344
+ args = parser.parse_args(argv)
345
+ _setup_logging(args.verbose, args.quiet)
346
+
347
+ try:
348
+ return asyncio.run(run_async(args))
349
+ except KeyboardInterrupt:
350
+ return 130
wayparam/filters.py ADDED
@@ -0,0 +1,105 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+ from urllib.parse import urlsplit
9
+
10
+ DEFAULT_EXT_BLACKLIST = {
11
+ ".7z",
12
+ ".avi",
13
+ ".bmp",
14
+ ".css",
15
+ ".csv",
16
+ ".doc",
17
+ ".docx",
18
+ ".eot",
19
+ ".eps",
20
+ ".exe",
21
+ ".gif",
22
+ ".gz",
23
+ ".ico",
24
+ ".iso",
25
+ ".jpeg",
26
+ ".jpg",
27
+ ".js",
28
+ ".json",
29
+ ".map",
30
+ ".mkv",
31
+ ".mov",
32
+ ".mp3",
33
+ ".mp4",
34
+ ".mpeg",
35
+ ".mpg",
36
+ ".otf",
37
+ ".pdf",
38
+ ".png",
39
+ ".ppt",
40
+ ".pptx",
41
+ ".rar",
42
+ ".rss",
43
+ ".svg",
44
+ ".tar",
45
+ ".tif",
46
+ ".tiff",
47
+ ".ttf",
48
+ ".txt",
49
+ ".wav",
50
+ ".webm",
51
+ ".webp",
52
+ ".woff",
53
+ ".woff2",
54
+ ".xml",
55
+ ".zip",
56
+ }
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class FilterOptions:
61
+ ext_blacklist: set[str]
62
+ ext_whitelist: set[str] | None = None
63
+ path_exclude_regex: list[re.Pattern] | None = None
64
+
65
+
66
+ def _path_extension(url: str) -> str:
67
+ try:
68
+ path = urlsplit(url).path
69
+ except Exception:
70
+ return ""
71
+ return PurePosixPath(path).suffix.lower()
72
+
73
+
74
+ def is_boring(url: str, opt: FilterOptions) -> bool:
75
+ ext = _path_extension(url)
76
+
77
+ if opt.ext_whitelist is not None:
78
+ if ext and ext not in opt.ext_whitelist:
79
+ return True
80
+
81
+ if ext and ext in opt.ext_blacklist:
82
+ return True
83
+
84
+ if opt.path_exclude_regex:
85
+ path = urlsplit(url).path
86
+ for rx in opt.path_exclude_regex:
87
+ if rx.search(path):
88
+ return True
89
+
90
+ return False
91
+
92
+
93
+ def parse_ext_set(csv: str) -> set[str]:
94
+ """
95
+ Parse comma-separated extensions like ".png,.jpg,css" -> {".png",".jpg",".css"}
96
+ """
97
+ out: set[str] = set()
98
+ for raw in csv.split(","):
99
+ raw = raw.strip()
100
+ if not raw:
101
+ continue
102
+ if not raw.startswith("."):
103
+ raw = "." + raw
104
+ out.add(raw.lower())
105
+ return out
wayparam/http.py ADDED
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ from dataclasses import dataclass
8
+
9
+ import httpx
10
+
11
+ DEFAULT_UAS = [
12
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123 Safari/537.36",
13
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17 Safari/605.1.15",
14
+ "Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/121.0",
15
+ ]
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class HttpConfig:
20
+ timeout_s: float = 30.0
21
+ retries: int = 4
22
+ backoff_base_s: float = 0.7
23
+ max_backoff_s: float = 12.0
24
+ user_agent: str | None = None
25
+ proxy: str | None = None
26
+
27
+
28
+ def _pick_ua(config: HttpConfig) -> str:
29
+ return config.user_agent or random.choice(DEFAULT_UAS)
30
+
31
+
32
+ async def get_text(
33
+ client: httpx.AsyncClient,
34
+ url: str,
35
+ *,
36
+ params: list[tuple[str, str]] | None = None,
37
+ config: HttpConfig,
38
+ ) -> str:
39
+ headers = {"User-Agent": _pick_ua(config)}
40
+ last_exc: Exception | None = None
41
+ last_status: int | None = None
42
+
43
+ for attempt in range(config.retries + 1):
44
+ try:
45
+ resp = await client.get(url, params=params, headers=headers, timeout=config.timeout_s)
46
+
47
+ last_status = resp.status_code
48
+ if resp.status_code in (429, 503):
49
+ retry_after = resp.headers.get("Retry-After")
50
+ if retry_after and retry_after.isdigit():
51
+ await asyncio.sleep(min(int(retry_after), config.max_backoff_s))
52
+ else:
53
+ await asyncio.sleep(
54
+ min(config.backoff_base_s * (2**attempt), config.max_backoff_s)
55
+ )
56
+ continue
57
+
58
+ last_status = resp.status_code
59
+ resp.raise_for_status()
60
+ return resp.text
61
+
62
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError) as e:
63
+ last_exc = e
64
+ if attempt >= config.retries:
65
+ break
66
+ await asyncio.sleep(min(config.backoff_base_s * (2**attempt), config.max_backoff_s))
67
+
68
+ detail = f"status={last_status}" if last_status else "no-status"
69
+ raise RuntimeError(f"HTTP request failed after retries ({detail}): {url}") from last_exc
wayparam/io.py ADDED
@@ -0,0 +1,48 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ def read_domains(path: str) -> list[str]:
9
+ """
10
+ Read domains from a file (or '-' for stdin). Normalizes:
11
+ - strips scheme
12
+ - strips paths
13
+ - lowercases
14
+ - drops blank/comment lines
15
+ """
16
+ import sys
17
+ from urllib.parse import urlsplit
18
+
19
+ if path == "-":
20
+ content = sys.stdin.read().splitlines()
21
+ else:
22
+ content = Path(path).read_text(encoding="utf-8", errors="ignore").splitlines()
23
+
24
+ out: list[str] = []
25
+ for line in content:
26
+ line = line.strip()
27
+ if not line or line.startswith("#"):
28
+ continue
29
+ if "://" in line:
30
+ parts = urlsplit(line)
31
+ host = parts.netloc
32
+ else:
33
+ host = line.split("/")[0]
34
+ host = host.strip().lower()
35
+ if host:
36
+ out.append(host)
37
+
38
+ seen = set()
39
+ deduped = []
40
+ for d in out:
41
+ if d not in seen:
42
+ seen.add(d)
43
+ deduped.append(d)
44
+ return deduped
45
+
46
+
47
+ def ensure_dir(p: Path) -> None:
48
+ p.mkdir(parents=True, exist_ok=True)
wayparam/normalize.py ADDED
@@ -0,0 +1,99 @@
1
+ # SPDX-License-Identifier: GPL-3.0
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
7
+
8
+ TRACKING_PREFIXES = ("utm_",)
9
+ TRACKING_KEYS = {
10
+ "gclid",
11
+ "fbclid",
12
+ "msclkid",
13
+ "igshid",
14
+ "mc_cid",
15
+ "mc_eid",
16
+ "ref",
17
+ "ref_src",
18
+ "yclid",
19
+ "gbraid",
20
+ "wbraid",
21
+ "twclid",
22
+ }
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class NormalizeOptions:
27
+ placeholder: str = "FUZZ"
28
+ keep_values: bool = False
29
+ only_params: bool = True
30
+ drop_tracking: bool = True
31
+ drop_empty: bool = True
32
+ sort_params: bool = True
33
+
34
+
35
+ def _is_tracking_key(k: str) -> bool:
36
+ k_l = k.lower()
37
+ if k_l in TRACKING_KEYS:
38
+ return True
39
+ return any(k_l.startswith(p) for p in TRACKING_PREFIXES)
40
+
41
+
42
+ _DEFAULT_PORTS = {("http", 80), ("https", 443)}
43
+
44
+
45
+ def canonicalize_url(url: str, opt: NormalizeOptions) -> str | None:
46
+ """
47
+ Returns a canonicalized URL or None if filtered out by only_params / drop_empty.
48
+
49
+ - removes fragments
50
+ - normalizes default ports
51
+ - sorts params
52
+ - optionally drops tracking params
53
+ - optionally masks values
54
+ """
55
+ try:
56
+ parts = urlsplit(url.strip())
57
+ except Exception:
58
+ return None
59
+
60
+ if not parts.scheme or not parts.netloc:
61
+ return None
62
+
63
+ scheme = parts.scheme.lower()
64
+
65
+ netloc = parts.netloc
66
+ if "@" in netloc:
67
+ userinfo, hostport = netloc.rsplit("@", 1)
68
+ else:
69
+ userinfo, hostport = "", netloc
70
+
71
+ host, sep, port = hostport.partition(":")
72
+ host = host.lower()
73
+ if port.isdigit() and (scheme, int(port)) in _DEFAULT_PORTS:
74
+ hostport_norm = host
75
+ else:
76
+ hostport_norm = host + (sep + port if sep else "")
77
+
78
+ netloc_norm = (userinfo + "@" if userinfo else "") + hostport_norm
79
+
80
+ path = parts.path or "/"
81
+
82
+ qsl = parse_qsl(parts.query, keep_blank_values=True)
83
+ out: list[tuple[str, str]] = []
84
+ for k, v in qsl:
85
+ if opt.drop_tracking and _is_tracking_key(k):
86
+ continue
87
+ if opt.drop_empty and k.strip() == "":
88
+ continue
89
+ out.append((k, v if opt.keep_values else opt.placeholder))
90
+
91
+ if opt.sort_params:
92
+ out.sort(key=lambda kv: (kv[0].lower(), kv[0], kv[1]))
93
+
94
+ query = urlencode(out, doseq=True)
95
+
96
+ if opt.only_params and not query:
97
+ return None
98
+
99
+ return urlunsplit((scheme, netloc_norm, path, query, ""))