从零搭建一个 Web 目录扫描器(Python + FastAPI)

1.省流版

第一次做算小工具的完整项目,来练手

感觉还不是很完善

用法可以看readme.md(应该没人会用,dirsearch好用的多)

不过可以看看源码

2.核心原理

目录扫描本质上就是三个步骤:

复制代码
字典 = ["admin", "api", "login", ".env", ...]
扩展名 = ["", ".php", ".html", ".bak"]

对每个字典词 × 每个扩展名:
    拼接 URL: https://target.com/admin
              https://target.com/admin.php
              https://target.com/admin.html
              ...
    发送 HTTP GET 请求
    如果状态码是 200/301/403 → 记录(说明路径存在)
    如果状态码是 404        → 忽略

用代码表示就是两层循环:

复制代码
for word in wordlist:
    for ext in extensions:
        url = f"{base}/{word}{ext}"   # 笛卡尔积
        resp = await client.get(url)  # 发请求
        if resp.status_code in [200, 301, 403]:
            results.append(url)

3.项目结构

复制代码
dirscanner_simple/
├── scanner.py          # 核心引擎:并发请求 + 过滤
├── web.py              # Web 后端:FastAPI 路由
├── templates/
│   └── index.html      # 前端界面:配置面板 + 结果表格
├── main.py             # 入口:一行 uvicorn.run()
└── requirements.txt    # 4 个依赖

4. 关键技术点

4.1 笛卡尔积生成 URL

scanner.py:147-157 --- 这 10 行代码把 wordlist × extensions 展开为完整 URL:

复制代码
for word in self.config.wordlist:        # ["admin", "api", ...]
    path = f"/{word}"
    for ext in self.config.extensions:   # ["", ".php", ".html", ...]
        if ext and not path.endswith(ext):
            full_path = f"{path}{ext}"   # /admin.php
        else:
            full_path = path             # /admin
        url = f"{base}{full_path}"       # https://target.com/admin.php
4.2 异步并发

scanner.py:168-181 --- 关键代码:

复制代码
async with httpx.AsyncClient(limits=limits, timeout=...) as client:
    sem = asyncio.Semaphore(concurrency)     # 最多同时 20 个请求
    workers = [
        asyncio.create_task(self._worker(i, client, queue, sem))
        for i in range(concurrency)           # 启动 20 个协程
    ]
  • AsyncClient 复用 HTTP 连接,比每次新建连接快很多
  • Semaphore(20) 就像只有 20 张入场券,用完要归还
  • asyncio.create_task() 创建协程任务,不是线程
4.3 Worker 工作循环

scanner.py:215-273 --- 每个 worker 的循环:

复制代码
while not self._cancel.is_set():
    url = await asyncio.wait_for(queue.get(), timeout=0.5)
    async with sem:
        resp = await client.get(url)
        if _should_report(resp, self.config):
            results.append(...)
        queue.task_done()

timeout=0.5 是关键------不是永久阻塞取任务,每隔 0.5 秒检查一次"用户是不是点了停止"。

4.4 三级过滤

scanner.py:94-114 --- 短路径求值:

复制代码
def _should_report(resp, config):
    # ↓ 第1级:状态码过滤
    if resp.status_code in exclude: return False     # 是 404 就丢
    if resp.status_code not in include: return False # 不在白名单就丢
    
    # ↓ 第2级:大小过滤
    if config.min_size and len(resp.content) < config.min_size: return False
    
    # ↓ 第3级:关键词过滤(可选)
    if config.keyword and config.keyword not in resp.text: return False
    
    return True  # 三级全通过,这页面值得报告

短路逻辑利用 return False 提前退出,后面更昂贵的操作(比如读取 resp.text)能省就省。

4.5 前端轮询

index.html:288-341 --- 不用 WebSocket,用最简单的方式:

复制代码
// 启动时:开始每秒轮询
timer = setInterval(poll, 1000);

async function poll() {
    const status = await fetch('/api/scan/status');   // 拿进度
    const results = await fetch('/api/scan/results');  // 拿结果
    
    更新进度条和表格;
    
    if (!status.running) {          // 扫描结束
        clearInterval(timer);       // 停轮询
        btnExport.disabled = false; // 启用导出按钮
    }
}