agent-bypass 2.11.5 → 2.11.6

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.
@@ -1,25 +1,92 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- 并发目录/文件爆破
4
+ 并发目录/文件爆破(双引擎:curl -Z 批量 / py 线程池)
5
5
 
6
6
  用法:
7
7
  python3 dirbrute.py https://target.com
8
8
  python3 dirbrute.py https://target.com --wordlist my.txt
9
9
  python3 dirbrute.py https://target.com --ext .php,.bak,.zip --mc 200,301,302,403
10
10
  python3 dirbrute.py https://target.com --conc 30 --delay 0.1
11
+ python3 dirbrute.py https://target.com --engine py --no-soft404 --retries 3
11
12
 
12
- 输出: jsonl
13
+ 引擎:
14
+ curl curl -Z 批量并行(默认,最快);并发由 --batch 控制
15
+ py 线程池(--conc 精确控制;--delay 为全局总速率限制)
16
+ auto --delay>0 或无 curl 时走 py,否则走 curl
17
+
18
+ 两引擎行为一致,jsonl 字段一致: {"url","status","len","location","server","engine"}
19
+ 软404过滤(默认开启,两引擎共用同一套基线):
20
+ 先用若干随机路径取基线 (code, size),命中项若 code 与基线相同且
21
+ |len - 基线len| <= SOFT404_TOL(64) 字节则判定为误报丢弃;--no-soft404 关闭。
22
+ 瞬态失败(连接被拒/超时)默认重试 --retries 次:指数退避 + 逐轮降低并发,
23
+ 仍失败则计入 error 并在 stderr 汇总(不再静默丢结果)。
13
24
  """
14
- import argparse, json, sys, ssl, time
15
- from concurrent.futures import ThreadPoolExecutor, as_completed
16
- import urllib.request, urllib.error
25
+ import argsmini as argparse
26
+ import json
27
+ import random
28
+ import re
29
+ import os
30
+ import shutil
31
+ import string
32
+ import sys
33
+ import threading
34
+ import time
35
+ from collections import Counter
36
+
37
+
38
+ # ---- 懒加载/popen 优化:ssl/urllib/concurrent import 各耗 1-5s(iSH),仅首次用时载入 ----
39
+ _ssl_m = None
40
+ def _get_ssl():
41
+ global _ssl_m
42
+ if _ssl_m is None: import ssl as _s; _ssl_m = _s
43
+ return _ssl_m
44
+ _url_m = None
45
+ def _get_urllib():
46
+ global _url_m
47
+ if _url_m is None: import urllib.request as _r; import urllib.error as _e; _url_m = (_r, _e)
48
+ return _url_m
49
+ _cf_m = None
50
+ def _get_cf():
51
+ global _cf_m
52
+ if _cf_m is None: import concurrent.futures as _c; _cf_m = _c
53
+ return _cf_m
54
+ def _urljoin(base, url):
55
+ import urllib.parse as _p
56
+ return _p.urljoin(base, url)
57
+ _SAFE = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789@%_+=:,./-")
58
+ def _shq(s):
59
+ if s and all(c in _SAFE for c in s): return s
60
+ return "'%s'" % s.replace("'", "'" + chr(92) + "'" + "'")
61
+ def _run_curl(cargs):
62
+ p = os.popen(" ".join(_shq(a) for a in cargs) + " 2>/dev/null")
63
+ out = p.read(); p.close(); return out
64
+
65
+ def _new_ctx():
66
+ """不用 create_default_context():本脚本已关闭证书校验,加载 CA 库纯属浪费时间
67
+ (实测多花 ~2s 启动,iSH 上尤其明显)。"""
68
+ ssl = _get_ssl()
69
+ c = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
70
+ c.check_hostname = False
71
+ c.verify_mode = ssl.CERT_NONE
72
+ return c
73
+
17
74
 
18
- CTX = ssl.create_default_context()
19
- CTX.check_hostname = False
20
- CTX.verify_mode = ssl.CERT_NONE
75
+ _CTX = None
76
+
77
+
78
+ def _ctx():
79
+ global _CTX
80
+ if _CTX is None:
81
+ _CTX = _new_ctx()
82
+ return _CTX
21
83
  UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36"
22
84
 
85
+ SOFT404_TOL = 64 # 软404 尺寸容差(字节)
86
+ SOFT404_PROBES = 3 # 软404 基线随机路径探测次数
87
+ MAX_BODY = 4 * 1024 * 1024
88
+ BACKOFF = (0.3, 0.8, 1.5, 3.0)
89
+
23
90
  # 内置字典(常用路径/文件)
24
91
  DEFAULT = """admin
25
92
  login
@@ -207,92 +274,459 @@ assets.html
207
274
  """.strip().split("\n")
208
275
 
209
276
 
210
- def req(url, timeout=8, no_redirect=True):
211
- h = {"User-Agent": UA, "Accept": "*/*"}
212
- if no_redirect:
213
- class NoRedir(urllib.request.HTTPRedirectHandler):
214
- def redirect_request(self, *a, **k):
277
+ # ---------------------------------------------------------------- 通用工具
278
+ def _hget(headers, name, default=""):
279
+ """大小写不敏感取响应头,headers 可能为 None。"""
280
+ try:
281
+ v = headers.get(name)
282
+ return v if v is not None else default
283
+ except Exception:
284
+ return default
285
+
286
+
287
+ def _abs_location(url, loc):
288
+ """把相对 Location 补成绝对 URL,与 curl 的 %{redirect_url} 对齐。"""
289
+ if not loc:
290
+ return ""
291
+ try:
292
+ return _urljoin(url, loc)
293
+ except Exception:
294
+ return loc
295
+
296
+
297
+ def _size_from(fp, headers, cap=MAX_BODY):
298
+ """响应体字节数。优先用 Content-Length(与 curl 的 %{size_download} 对齐),
299
+ 缺失时流式读取计数(最多 cap 字节)。返回 (size, truncated)。"""
300
+ cl = _hget(headers, "Content-Length", None)
301
+ if cl is not None:
302
+ try:
303
+ n = int(cl)
304
+ if n >= 0:
305
+ return n, n > cap
306
+ except (TypeError, ValueError):
307
+ pass
308
+ total = 0
309
+ try:
310
+ while total < cap:
311
+ buf = fp.read(min(65536, cap - total))
312
+ if not buf:
313
+ break
314
+ total += len(buf)
315
+ except Exception:
316
+ pass
317
+ return total, total >= cap
318
+
319
+
320
+ _NOREDIR = None
321
+
322
+
323
+ def _noredir_cls():
324
+ """显式抛 HTTPError 的 no-redirect handler(首次用时构造,懒加载 urllib)。"""
325
+ global _NOREDIR
326
+ if _NOREDIR is None:
327
+ _r, _e = _get_urllib()
328
+
329
+ class _NoRedir(_r.HTTPRedirectHandler):
330
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
215
331
  return None
216
- op = urllib.request.build_opener(NoRedir)
217
- else:
218
- op = urllib.request.build_opener()
219
- # 关键:context 必须装在 opener 的 handler 上,不能传给 open()
220
- https = urllib.request.HTTPSHandler(context=CTX)
221
- op.add_handler(https)
332
+
333
+ def http_error_302(self, req, fp, code, msg, headers):
334
+ raise _e.HTTPError(req.full_url, code, msg, headers, fp)
335
+
336
+ http_error_301 = http_error_303 = http_error_307 = http_error_308 = http_error_302
337
+
338
+ _NOREDIR = _NoRedir
339
+ return _NOREDIR
340
+
341
+
342
+ _OPENERS = {}
343
+
344
+
345
+ def _get_opener(no_redirect=True):
346
+ """opener 全局缓存(SSL context 与 handler 只装一次)。"""
347
+ key = bool(no_redirect)
348
+ if key not in _OPENERS:
349
+ _r, _e = _get_urllib()
350
+ handlers = [_r.HTTPSHandler(context=_ctx())]
351
+ if key:
352
+ handlers.insert(0, _noredir_cls()())
353
+ _OPENERS[key] = _r.build_opener(*handlers)
354
+ return _OPENERS[key]
355
+
356
+
357
+ class RateLimiter:
358
+ """全局速率限制:--delay 表示所有 worker 合计的每请求最小间隔
359
+ (原实现是每个 worker 各自 sleep,20 并发下 0.1s 实际会打出 ~200 req/s)。"""
360
+
361
+ def __init__(self, delay):
362
+ self.delay = float(delay or 0)
363
+ self._lock = threading.Lock()
364
+ self._next = 0.0
365
+
366
+ def wait(self):
367
+ if self.delay <= 0:
368
+ return
369
+ with self._lock:
370
+ now = time.monotonic()
371
+ if self._next < now:
372
+ self._next = now
373
+ self._next += self.delay
374
+ sleep_for = self._next - now
375
+ if sleep_for > 0:
376
+ time.sleep(sleep_for)
377
+
378
+
379
+ # ---------------------------------------------------------------- py 引擎
380
+ def _probe_py(url, timeout):
381
+ """单请求探测,返回 (code|None, size, location, server, err)。
382
+
383
+ code=None 表示传输层失败(连接被拒/DNS/超时等)。
384
+ 3xx 不跟随重定向,状态码照常返回(见 _NoRedir)。
385
+ """
222
386
  try:
223
- r = urllib.request.Request(url, headers=h)
224
- with op.open(r, timeout=timeout) as x:
225
- return x.status, len(x.read(200000)), dict(x.headers)
226
- except urllib.error.HTTPError as e:
227
- return e.code, 0, dict(e.headers)
387
+ _r, _e = _get_urllib()
388
+ req = _r.Request(url, headers={"User-Agent": UA, "Accept": "*/*"})
389
+ except ValueError as e:
390
+ return None, 0, "", "", "URL 非法: %s" % e
391
+ try:
392
+ with _get_opener(True).open(req, timeout=timeout) as x:
393
+ size, _ = _size_from(x, x.headers)
394
+ return (x.status, size,
395
+ _abs_location(url, _hget(x.headers, "Location")),
396
+ _hget(x.headers, "Server"), None)
397
+ except _e.HTTPError as e:
398
+ try:
399
+ size, _ = _size_from(e, e.headers)
400
+ except Exception:
401
+ size = 0
402
+ loc = _abs_location(url, _hget(e.headers, "Location"))
403
+ srv = _hget(e.headers, "Server")
404
+ try:
405
+ e.close()
406
+ except Exception:
407
+ pass
408
+ return e.code, size, loc, srv, None
228
409
  except Exception as e:
229
- # 不再静默吞掉,首次报错打到 stderr 便于排查
230
- if not req._warned:
231
- print(f"[req 错误] {url}: {type(e).__name__}: {str(e)[:80]}",
232
- file=sys.stderr)
233
- req._warned = True
234
- return None, 0, {}
410
+ return None, 0, "", "", "%s: %s" % (type(e).__name__, str(e)[:90])
235
411
 
236
412
 
237
- req._warned = False
413
+ def _probe_py_retry(url, timeout, retries):
414
+ """带退避重试的 py 探测。"""
415
+ last_err = ""
416
+ for attempt in range(retries + 1):
417
+ code, size, loc, srv, err = _probe_py(url, timeout)
418
+ if code is not None:
419
+ return code, size, loc, srv, None
420
+ last_err = err or "未知错误"
421
+ if attempt < retries:
422
+ time.sleep(BACKOFF[min(attempt, len(BACKOFF) - 1)])
423
+ return None, 0, "", "", last_err
424
+
425
+
426
+ # ---------------------------------------------------------------- curl 引擎
427
+ def curl_available():
428
+ return shutil.which("curl") is not None
429
+
430
+
431
+ _CURL_HDR_OK = None
432
+
433
+
434
+ def _curl_supports_header():
435
+ """%header{name} 需要 curl >= 7.84。"""
436
+ global _CURL_HDR_OK
437
+ if _CURL_HDR_OK is None:
438
+ _CURL_HDR_OK = False
439
+ try:
440
+ out = _run_curl(["curl", "--version"])
441
+ m = re.search(r"curl\s+(\d+)\.(\d+)", out)
442
+ if m:
443
+ _CURL_HDR_OK = (int(m.group(1)), int(m.group(2))) >= (7, 84)
444
+ except Exception:
445
+ pass
446
+ return _CURL_HDR_OK
447
+
448
+
449
+ def curl_batch(urls, timeout, parallel, with_server=None):
450
+ """curl -Z 批量探测。
451
+
452
+ 返回 {url: (code, size, location, server)};code=0 表示该 URL 传输失败。
453
+ with_server=False 时不追加 %header{server}(老版本 curl),此时 server 恒为 ""。
454
+ """
455
+ if not urls:
456
+ return {}
457
+ if with_server is None:
458
+ with_server = _curl_supports_header()
459
+ par = max(1, min(int(parallel), len(urls)))
460
+ fmt = "%{http_code} %{size_download} %{redirect_url} %{url_effective}"
461
+ if with_server:
462
+ fmt += " %header{server}"
463
+ fmt += "\n"
464
+ cargs = ["curl", "-sZ", "--parallel-max", str(par),
465
+ "--connect-timeout", "%g" % min(timeout, 5.0),
466
+ "-m", "%g" % timeout, "-w", fmt]
467
+ for u in urls:
468
+ cargs += ["-o", "/dev/null", u]
469
+ try:
470
+ out = _run_curl(cargs)
471
+ except OSError:
472
+ return {u: (0, 0, "", "") for u in urls}
473
+ res = {}
474
+ nfields = 5 if with_server else 4
475
+ for line in out.splitlines():
476
+ parts = line.split(" ", nfields - 1)
477
+ if len(parts) < 4:
478
+ continue
479
+ code = int(parts[0]) if parts[0].isdigit() else 0
480
+ size = int(parts[1]) if parts[1].isdigit() else 0
481
+ server = parts[4].strip() if (with_server and len(parts) > 4) else ""
482
+ # parts[3] 是 url_effective;极少数情况下 curl 会把它做归一化,做一次容错匹配
483
+ res[parts[3]] = (code, size, parts[2], server)
484
+ return res
485
+
486
+
487
+ def curl_run(urls, timeout, batch, retries, on_result, on_progress=None):
488
+ """分批发车 + 逐轮降并发重试。返回最终仍失败的 URL 列表。
489
+
490
+ 每轮按 chunk 分期调用 curl(避免超长命令行撞 ARG_MAX),
491
+ chunk 内的并发由 --parallel-max 控制。
492
+ """
493
+ pending = list(urls)
494
+ total = len(pending)
495
+ decided = 0
496
+ par = batch
497
+ chunk = max(batch, 200)
498
+ for attempt in range(retries + 1):
499
+ failed = []
500
+ for i in range(0, len(pending), chunk):
501
+ part = pending[i:i + chunk]
502
+ res = curl_batch(part, timeout, par)
503
+ for u in part:
504
+ got = res.get(u)
505
+ if got is None or got[0] == 0:
506
+ failed.append(u)
507
+ else:
508
+ decided += 1
509
+ on_result(u, got)
510
+ if on_progress:
511
+ on_progress(decided, total, len(failed), attempt)
512
+ if not failed or attempt >= retries:
513
+ return failed
514
+ time.sleep(BACKOFF[min(attempt, len(BACKOFF) - 1)])
515
+ par = max(2, min(8, par // 2))
516
+ print("# 重试第 %d 轮: %d 个失败 (并发降到 %d)"
517
+ % (attempt + 1, len(failed), min(par, len(failed))), file=sys.stderr)
518
+ pending = failed
519
+ return pending
520
+
521
+
522
+ # ---------------------------------------------------------------- 主程序
523
+ def _rand_path(n=12):
524
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
525
+
526
+
527
+ def _parse_mc(ap, txt):
528
+ mc = set()
529
+ for x in txt.split(","):
530
+ x = x.strip()
531
+ if not x:
532
+ continue
533
+ if not x.isdigit():
534
+ ap.error("--mc 含非法状态码: %r" % x)
535
+ mc.add(int(x))
536
+ if not mc:
537
+ ap.error("--mc 不能为空")
538
+ return mc
539
+
540
+
541
+ def _load_words(ap, path):
542
+ if not path:
543
+ return list(DEFAULT)
544
+ try:
545
+ with open(path, "r", encoding="utf-8", errors="replace") as f:
546
+ words = [l.strip() for l in f if l.strip()]
547
+ except OSError as e:
548
+ ap.error("读取字典失败 %s: %s" % (path, e))
549
+ return words
550
+
551
+
552
+ def soft404_baseline(engine, base, timeout, retries):
553
+ """随机路径软404基线探测,返回 (code, size);全部失败返回 None。"""
554
+ probes = ["%s/%s" % (base, _rand_path()) for _ in range(SOFT404_PROBES)]
555
+ cand = []
556
+ if engine == "curl":
557
+ res = curl_batch(probes, timeout, len(probes))
558
+ for u in probes:
559
+ got = res.get(u)
560
+ if got and got[0] != 0:
561
+ cand.append((got[0], got[1]))
562
+ else:
563
+ for u in probes:
564
+ code, size, _loc, _srv, err = _probe_py_retry(u, timeout, retries)
565
+ if code is not None:
566
+ cand.append((code, size))
567
+ if not cand:
568
+ return None
569
+ return Counter(cand).most_common(1)[0][0]
238
570
 
239
571
 
240
572
  def main():
241
- ap = argparse.ArgumentParser(description="并发目录爆破")
242
- ap.add_argument("base")
573
+ ap = argparse.ArgumentParser(description="并发目录/文件批量爆破")
574
+ ap.add_argument("base", help="目标,如 https://target.com(缺协议按 http:// 处理)")
243
575
  ap.add_argument("--wordlist", help="字典文件(一行一条)")
244
576
  ap.add_argument("--ext", default="", help="附加扩展名,如 .php,.bak")
245
577
  ap.add_argument("--mc", default="200,204,301,302,307,401,403,405,500",
246
578
  help="关注的状态码")
247
- ap.add_argument("--conc", type=int, default=20)
248
- ap.add_argument("--delay", type=float, default=0.0, help="每次请求间隔(秒)")
579
+ ap.add_argument("--conc", type=int, default=20, help="py 引擎并发数(1-512)")
580
+ ap.add_argument("--delay", type=float, default=0.0, help="全局每请求最小间隔(秒)")
249
581
  ap.add_argument("--timeout", type=float, default=8)
582
+ ap.add_argument("--engine", choices=["auto", "curl", "py"], default="auto",
583
+ help="curl=批量并行(快) py=线程池(精细/delay) auto=自动")
584
+ ap.add_argument("--batch", type=int, default=48, help="curl 引擎每批并行数(1-512)")
585
+ ap.add_argument("--retries", type=int, default=2,
586
+ help="瞬态失败(连接被拒/超时)重试次数,默认 2")
587
+ ap.add_argument("--no-soft404", action="store_true",
588
+ help="关闭软404基线过滤(默认开启,两引擎共用)")
589
+ ap.add_argument("--force", action="store_true", help="跳过预检(连接失败仍全量跑)")
250
590
  a = ap.parse_args()
251
591
 
252
- words = DEFAULT
253
- if a.wordlist:
254
- words = [l.strip() for l in open(a.wordlist) if l.strip()]
592
+ # ---- 参数校验 ----
593
+ if a.conc < 1:
594
+ ap.error("--conc 必须 >= 1")
595
+ if a.batch < 1:
596
+ ap.error("--batch 必须 >= 1")
597
+ if a.retries < 0:
598
+ ap.error("--retries 必须 >= 0")
599
+ if a.timeout <= 0:
600
+ ap.error("--timeout 必须 > 0")
601
+ if a.delay < 0:
602
+ ap.error("--delay 必须 >= 0")
603
+ if a.conc > 512:
604
+ a.conc = 512
605
+ if a.batch > 512:
606
+ a.batch = 512
255
607
 
256
- exts = [e.strip() for e in a.ext.split(",") if e.strip()] if a.ext else [""]
257
- paths = []
608
+ base = a.base.strip()
609
+ if not base:
610
+ ap.error("目标不能为空")
611
+ if not re.match(r"^[A-Za-z][A-Za-z0-9+.\-]*://", base):
612
+ base = "http://" + base
613
+ print("# 目标未带协议,按 %s 处理" % base, file=sys.stderr)
614
+ base = base.rstrip("/")
615
+
616
+ mc = _parse_mc(ap, a.mc)
617
+ words = _load_words(ap, a.wordlist)
618
+ exts = ([""] + [e.strip() for e in a.ext.split(",") if e.strip()]) if a.ext else [""]
619
+ paths, seen = [], set()
258
620
  for w in words:
259
621
  for e in exts:
260
- paths.append(w + e)
622
+ p = (w + e).lstrip("/")
623
+ if p and p not in seen:
624
+ seen.add(p)
625
+ paths.append(p)
626
+ if not paths:
627
+ ap.error("字典为空,无探测路径")
628
+ urls = ["%s/%s" % (base, p) for p in paths]
261
629
 
262
- base = a.base.rstrip("/")
263
- mc = {int(x) for x in a.mc.split(",") if x.strip()}
630
+ engine = a.engine
631
+ if engine == "auto":
632
+ engine = "py" if (a.delay and a.delay > 0) or not curl_available() else "curl"
633
+ if engine == "curl" and a.delay and a.delay > 0:
634
+ print("# --delay>0 时 curl 批量引擎无法逐请求限速,自动改用 py 引擎", file=sys.stderr)
635
+ engine = "py"
636
+ if engine == "curl" and not curl_available():
637
+ print("# 未找到 curl,自动改用 py 引擎", file=sys.stderr)
638
+ engine = "py"
639
+ if a.conc > 200:
640
+ print("# 警告: --conc %d 过高,本地/目标可能大量连接被拒(会触发重试)" % a.conc,
641
+ file=sys.stderr)
264
642
 
265
- print(f"# {base} 共 {len(paths)} 条 (并发 {a.conc})", file=sys.stderr)
643
+ soft = not a.no_soft404
644
+ _purl = "%s/%s" % (base, _rand_path())
645
+ if engine == "curl":
646
+ _pg = curl_batch([_purl], min(a.timeout, 6.0), 1).get(_purl)
647
+ _pcode = _pg[0] if _pg else 0
648
+ else:
649
+ _pc = _probe_py(_purl, min(a.timeout, 6.0))[0]
650
+ _pcode = _pc if _pc is not None else 0
651
+ if _pcode == 0 and not a.force:
652
+ print("# [预检失败] %s 不可达,已跳过全量。可达请加 --force。" % base, file=sys.stderr)
653
+ sys.exit(3)
654
+ baseline = None
655
+ if soft:
656
+ baseline = soft404_baseline(engine, base, a.timeout, a.retries)
657
+ if baseline is None:
658
+ soft = False
659
+ print("# 软404基线探测全部失败,已自动关闭软404过滤", file=sys.stderr)
660
+ else:
661
+ print("# 软404基线: %d / %dB (随机路径 x%d)" % (baseline[0], baseline[1], SOFT404_PROBES),
662
+ file=sys.stderr)
266
663
 
267
- hits = []
664
+ def is_soft(code, size):
665
+ return bool(soft and baseline and code == baseline[0]
666
+ and abs(size - baseline[1]) <= SOFT404_TOL)
268
667
 
269
- def one(p):
270
- if a.delay:
271
- time.sleep(a.delay)
272
- url = f"{base}/{p}"
273
- st, ln, hd = req(url, a.timeout)
274
- if st in mc:
275
- return {"url": url, "status": st, "len": ln,
276
- "location": hd.get("Location", ""),
277
- "server": hd.get("Server", "")}
278
- return None
668
+ def emit(url, code, size, loc, srv):
669
+ r = {"url": url, "status": code, "len": size,
670
+ "location": loc or "", "server": srv or "", "engine": engine}
671
+ print(json.dumps(r, ensure_ascii=False))
672
+ return r
673
+
674
+ hits, errors = [], []
675
+ t0 = time.time()
676
+
677
+ if engine == "curl":
678
+ print("# %s 共 %d 条 (curl 引擎, 批 %d, 软404%s)"
679
+ % (base, len(paths), min(a.batch, len(paths)),
680
+ "开启" if soft else "关闭"), file=sys.stderr)
681
+
682
+ def on_result(url, got):
683
+ code, size, loc, srv = got
684
+ if code in mc and not is_soft(code, size):
685
+ hits.append(emit(url, code, size, loc, srv))
686
+
687
+ def on_progress(done_n, total_n, waiting, attempt):
688
+ print(" ... 已探测 %d/%d 命中 %d 待重试 %d%s"
689
+ % (done_n, total_n, len(hits), waiting,
690
+ "" if attempt == 0 else " [第 %d 轮]" % (attempt + 1)),
691
+ file=sys.stderr)
692
+
693
+ failed = curl_run(urls, a.timeout, a.batch, a.retries, on_result, on_progress)
694
+ errors.extend([(u, "传输失败(重试后仍失败)") for u in failed])
695
+ for u, e in errors[:5]:
696
+ print("# 失败: %s (%s)" % (u, e), file=sys.stderr)
697
+ else:
698
+ print("# %s 共 %d 条 (py 引擎, 并发 %d, delay %gs, 软404%s)"
699
+ % (base, len(paths), a.conc, a.delay, "开启" if soft else "关闭"),
700
+ file=sys.stderr)
701
+ limiter = RateLimiter(a.delay)
702
+ done = [0]
703
+
704
+ def one(url):
705
+ limiter.wait()
706
+ return url, _probe_py_retry(url, a.timeout, a.retries)
707
+
708
+ with _get_cf().ThreadPoolExecutor(max_workers=a.conc) as ex:
709
+ futs = {ex.submit(one, u): u for u in urls}
710
+ for fu in _get_cf().as_completed(futs):
711
+ done[0] += 1
712
+ try:
713
+ url, (code, size, loc, srv, err) = fu.result()
714
+ except Exception as e:
715
+ url, code, size, loc, srv = futs[fu], None, 0, "", ""
716
+ err = "%s: %s" % (type(e).__name__, e)
717
+ if code is None:
718
+ errors.append((url, err or "未知错误"))
719
+ elif code in mc and not is_soft(code, size):
720
+ hits.append(emit(url, code, size, loc, srv))
721
+ if done[0] % 200 == 0 or done[0] == len(urls):
722
+ print(" ... %d/%d 命中 %d 错误 %d"
723
+ % (done[0], len(urls), len(hits), len(errors)), file=sys.stderr)
724
+ for u, e in errors[:5]:
725
+ print("# 失败: %s (%s)" % (u, e), file=sys.stderr)
279
726
 
280
- done = 0
281
- with ThreadPoolExecutor(max_workers=a.conc) as ex:
282
- futs = {ex.submit(one, p): p for p in paths}
283
- for fu in as_completed(futs):
284
- done += 1
285
- try:
286
- r = fu.result()
287
- except Exception:
288
- r = None
289
- if r:
290
- hits.append(r)
291
- print(json.dumps(r, ensure_ascii=False))
292
- if done % 200 == 0:
293
- print(f" ... {done}/{len(paths)} 命中 {len(hits)}", file=sys.stderr)
294
-
295
- print(f"# 完成: 命中 {len(hits)}", file=sys.stderr)
727
+ dt = time.time() - t0
728
+ print("# 完成: 命中 %d, 错误 %d, 共 %d 条, 耗时 %.2fs (%s 引擎)"
729
+ % (len(hits), len(errors), len(urls), dt, engine), file=sys.stderr)
296
730
 
297
731
 
298
732
  if __name__ == "__main__":