在爬虫开发中经常遇到一类场景:拿到一批URL,有的是普通HTML网页需要解析文本,有的是PDF、Excel、压缩包等下载文件需要保存二进制。

仅仅依靠URL后缀判断并不靠谱,很多动态下载接口没有真实文件后缀;直接全部请求完整内容又浪费带宽。本文分享一套兼顾性能与准确度的实现方案。
爬虫抓取列表页拿到大量链接,我们需要做分流:
同时文件类型字符串可以直接存入数据库,方便后续业务使用。
一共有两种判断手段,组合使用兼顾性能与准确率:
解析URL路径,提取末尾文件扩展名。
/download?id=123没有后缀;也存在伪装后缀,例如xxx.pdf实际返回HTML页面。适合做前置过滤,能识别就直接返回,减少不必要的HTTP请求。
优先发送HEAD请求,只获取响应头,不下载响应体。
Content-Type:资源MIME类型,用来区分是text/html网页还是application/pdf等二进制文件;Content‑Disposition:如果头部包含attachment,代表浏览器触发下载,里面还可以拿到服务器下发的真实文件名,这对无后缀动态下载接口最为关键。部分服务器拒绝HEAD请求,此时降级为GET + stream=True,依然只读取响应头,不会拉取完整文件内容。
优先级顺序:Content‑Disposition中的真实文件名后缀 > MIME类型映射 > URL路径后缀兜底
import requestsimport refrom urllib.parse import urlparse, unquote# 需要识别的下载后缀集合(带点小写)DOWNLOAD_EXT = { ".pdf", ".zip", ".rar", ".7z", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".txt", ".csv", ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".mp4", ".mp3", ".tar", ".gz"}# 网页后缀集合HTML_EXT = {".html", ".htm", ".php", ".asp", ".aspx", ".jsp"}# MIME类型映射为文件后缀MIME_MAP = { "application/pdf": "pdf", "application/zip": "zip", "application/x-zip-compressed": "zip", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", "application/msword": "doc", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", "application/vnd.ms-excel": "xls", "text/csv": "csv", "image/jpeg": "jpg", "image/png": "png", "text/plain": "txt"}HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0"}def check_resource_type(url: str, timeout=8) -> str: """ 判断URL资源类型 :param url: 待检测链接 :param timeout: 请求超时时间 :return: "html" | "pdf"/"docx"/"zip"... | "unknown" """ # 第一步:本地解析URL后缀快速判断,不走网络 parse_result = urlparse(url) path = parse_result.path.lower() dot_index = path.rfind(".") if dot_index != -1: ext_with_dot = path[dot_index:] ext_raw = path[dot_index + 1:] if ext_with_dot in HTML_EXT: return "html" if ext_with_dot in DOWNLOAD_EXT: return ext_raw # 本地无法判断,发起HTTP头探测 try: resp = requests.head(url, headers=HEADERS, timeout=timeout, allow_redirects=True) except Exception: # HEAD被拒绝,降级GET,stream=True不下载body try: resp = requests.get(url, headers=HEADERS, timeout=timeout, allow_redirects=True, stream=True) except Exception: return "unknown" content_type = resp.headers.get("Content-Type", "").lower().split(";")[0].strip() disposition = resp.headers.get("Content-Disposition", "") # 判断是否HTML网页 if "text/html" in content_type: return "html" # 优先从Content‑Disposition提取服务器返回的文件名后缀(动态下载接口核心) fn_match = re.search(r'filename*?=(?:["']?)([^"';n]+)', disposition) if fn_match: filename = fn_match.group(1) # 处理RFC5987编码文件名 filename*=utf-8''xxx.pdf if filename.startswith("utf-8''"): filename = unquote(filename[7:]) filename = filename.lower() fd = filename.rfind(".") if fd != -1: return filename[fd + 1:] # MIME映射获取后缀 if content_type in MIME_MAP: return MIME_MAP[content_type] # 兜底再次使用URL后缀 if dot_index != -1: return path[dot_index + 1:] return "unknown"if __name__ == "__main__": test_urls = [ "https://example.com/detail.html", "https://example.com/report.pdf", "https://example.com/api/download?id=100", "https://example.com/no_suffix_page" ] for test_url in test_urls: t = check_resource_type(test_url) print(f"{test_url} --> {t}")返回的字符串可以直接存入数据库varchar字段。
url = "https://xxx.com/xxx"res_type = check_resource_type(url)if res_type == "html": # 网页,请求获取文本 passelse: # 文件,res_type为后缀,unknown时兜底为bin suffix = res_type if res_type != "unknown" else "bin" save_filename = f"output.{suffix}" # 执行文件保存逻辑| URL示例 | 返回结果 | 业务动作 |
|---|---|---|
xxx/detail.html | html | 解析网页文本 |
xxx/file.pdf | pdf | 保存pdf文件 |
xxx/api/download?id=123,响应头携带attachment;filename="data.xlsx" | xlsx | 保存xlsx文件 |
| 无后缀链接返回HTML页面 | html | 解析网页文本 |
| 二进制资源全部识别失败 | unknown | 保存为.bin |
unknown,不会抛出异常中断爬虫主流程。大量URL循环调用的时候,大部分普通HTML页面会在本地URL后缀阶段直接返回,不会产生网络请求。
只有没有后缀的模糊链接,才会发送HTTP探测请求,大幅减少爬虫网络开销。
完整函数对外只有一个入口,返回简单字符串,方便数据库存储与后续分流处理。
通过 URL 判断文件类型,通常有三种策略,按准确度从低到高排列:① 扩展名、② HTTP 响应头 Content-Type、③ 文件内容魔数(Magic Number)。实际项目中,建议组合使用 ② 和 ③ 以获得最佳结果。
下面给出完整的实现方案。
准备工作:安装依赖
pip install requests python-magic
requests 用于发起 HTTP 请求,python-magic 用于读取文件魔数(底层依赖 libmagic,Linux/macOS 通常已自带,Windows 需额外安装)。
仅通过 URL 扩展名(快速,但不可靠)
import osfrom urllib.parse import urlparsedef get_type_by_extension(url: str) -> str: """根据 URL 路径的扩展名猜测 MIME 类型""" ext_to_mime = { '.html': 'text/html', '.htm': 'text/html', '.txt': 'text/plain', '.pdf': 'application/pdf', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.json': 'application/json', '.xml': 'application/xml', '.zip': 'application/zip', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', # ... 更多映射 } path = urlparse(url).path ext = os.path.splitext(path)[1].lower() return ext_to_mime.get(ext, 'application/octet-stream')局限:无扩展名或动态 URL(如 /api/file?id=123)无法判断,且扩展名可伪造。
通过 HTTP 响应头 Content-Type(推荐,最常用)
import requestsdef get_type_by_content_type(url: str, timeout: int = 10) -> str: """ 发起 HEAD 请求(或 GET with stream)获取 Content-Type """ try: # 使用 HEAD 请求获取头部,不下载实际内容 resp = requests.head(url, timeout=timeout, allow_redirects=True) content_type = resp.headers.get('Content-Type') if content_type: # 去掉 charset 等参数,只取主类型 return content_type.split(';')[0].strip().lower() else: # 若 HEAD 未返回 Content-Type,可改用 GET 只读前几个字节 resp = requests.get(url, timeout=timeout, stream=True) content_type = resp.headers.get('Content-Type') if content_type: return content_type.split(';')[0].strip().lower() return 'application/octet-stream' except Exception as e: return f'Error: {e}'注意:部分服务器可能不支持 HEAD,或 HEAD 返回的 Content-Type 与 GET 不一致。若不可靠,可改用 stream=True 的 GET 请求,只读取头部分。
通过文件内容魔数(最准确,但需下载少量数据)
import requestsimport magicdef get_type_by_magic(url: str, timeout: int = 10, bytes_to_read: int = 2048) -> str: """ 下载前 N 个字节,用 libmagic 识别 MIME 类型 """ try: resp = requests.get(url, timeout=timeout, stream=True) # 只读取前 N 个字节 chunk = resp.raw.read(bytes_to_read) # 使用 magic 检测 mime_type = magic.from_buffer(chunk, mime=True) return mime_type or 'application/octet-stream' except Exception as e: return f'Error: {e}'优点:即使 Content-Type 缺失或被伪造,依然能通过文件头真实识别,例如 PDF 以 %PDF 开头,JPEG 以 FF D8 开头。
综合方案:优先级策略
实际生产环境中,推荐按以下顺序判断:
Content-Type(服务端明确指定)。application/octet-stream或缺失,则下载少量字节进行魔数检测。import osfrom urllib.parse import urlparseimport requestsimport magicdef guess_file_type(url: str, timeout: int = 10) -> str: # 1. 尝试获取 Content-Type try: resp_head = requests.head(url, timeout=timeout, allow_redirects=True) content_type = resp_head.headers.get('Content-Type') if content_type: main_type = content_type.split(';')[0].strip().lower() if main_type != 'application/octet-stream': return main_type except: pass # 2. 若 Content-Type 不可靠或为 octet-stream,使用魔数 try: resp_get = requests.get(url, timeout=timeout, stream=True) chunk = resp_get.raw.read(2048) mime = magic.from_buffer(chunk, mime=True) if mime and mime != 'application/octet-stream': return mime except: pass # 3. 最后回退到扩展名 ext = os.path.splitext(urlparse(url).path)[1].lower() ext_map = { '.html': 'text/html', '.htm': 'text/html', '.txt': 'text/plain', '.pdf': 'application/pdf', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.json': 'application/json', '.xml': 'application/xml', '.zip': 'application/zip', '.mp4': 'video/mp4', '.mp3': 'audio/mpeg', } return ext_map.get(ext, 'application/octet-stream')# 示例print(guess_file_type('https://example.com/photo.jpg')) # image/jpegprint(guess_file_type('https://example.com/download?file=123')) # 实际会通过魔数识别