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 +4 -0
- wayparam/__main__.py +6 -0
- wayparam/cli.py +350 -0
- wayparam/filters.py +105 -0
- wayparam/http.py +69 -0
- wayparam/io.py +48 -0
- wayparam/normalize.py +99 -0
- wayparam/output.py +49 -0
- wayparam/ratelimit.py +29 -0
- wayparam/wayback.py +100 -0
- wayparam-0.3.0.dist-info/METADATA +272 -0
- wayparam-0.3.0.dist-info/RECORD +16 -0
- wayparam-0.3.0.dist-info/WHEEL +5 -0
- wayparam-0.3.0.dist-info/entry_points.txt +2 -0
- wayparam-0.3.0.dist-info/licenses/LICENSE +674 -0
- wayparam-0.3.0.dist-info/top_level.txt +1 -0
wayparam/__init__.py
ADDED
wayparam/__main__.py
ADDED
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, ""))
|