ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

轻量级Favicon爬虫:requests与BeautifulSoup实战解析

轻量级Favicon爬虫:requests与BeautifulSoup实战解析 接手一个几十个域名的后台系统时最难搞的不是业务是 Favicon。每个站的 HTML 写法都不一样有的在link relicon里有的藏在 CSS 里有的干脆只留一个/favicon.ico。于是我花了一天把爬虫工具从设计到实现做了一遍核心代码和踩坑点全部整理出来。适合两类人读一类是刚接触爬虫想通过一个真实项目把 requests BeautifulSoup 用熟另一类是在网上找过 favicon 抓取方案但发现现成工具要么是线上服务要么是重型库想自己维护一个轻量版本。这篇文章不打算从头讲 HTTP 基础而是直接围绕怎么把一个网站的小图标稳定摘出来这一件事展开。我会把全流程拆解开讲清楚每一步为什么这么设计再给出完整可跑的代码最后附上我拿 50 个站点实测后统计出来的问题分类。你可以直接复制代码去改也可以只学思路。1. 为什么单独写一个 Favicon 爬虫工具而不是直接调现成接口1.1 我在什么场景下开始写这个工具的我们后台有个模块要用卡片形式展示外部站点卡片左上角需要显示对方的小图标。站点数量一多人工维护根本不现实新增一个域名就要去查一次图标查完还要手动下载、重命名、放到静态目录。更麻烦的是图标这类资源经常改路径今天写死的路径明天就 404。后来我把需求抽象了一下输入一个 URL输出一个可直接使用的图标地址必要时直接把图标文件下载到本地。听起来很简单但真正动手后发现这里面的细节比预期多得多尤其是不同站点的 HTML 写法差异和网络异常处理。与其到处找半成品不如自己写一个可以被业务反复调用的工具类。1.2 现成方案看着省事实际坑更多我先对比了几条路结果都不太满意方案优点真实痛点第三方在线 favicon 服务省事传个域名就返回图标依赖外网隐私不可控高频调用有限流返回的尺寸往往由对方决定通用爬虫框架Scrapy、Crawlee功能强健壮性好维护成本高杀鸡用牛刀没有专门针对 icon 的语义处理浏览器截图工具一定能拿到真实渲染结果太重了要跑无头浏览器内存开销大不适合服务端高频调用自己写 requests 脚本完全可控逻辑透明需要自己处理各种边界条件我最后选了最后一条。核心原因不是自己写更厉害而是只有自己写才能把需求做成刚好能用的形状一个不到 200 行的类放进项目里不碍事又能在任何地方被调用。1.3 自己写要抓住的核心设计目标动手之前我先列了四个必须满足的目标后面所有设计都围着它们转输入容忍度高用户传example.com、https://example.com/abc都能处理。必须有兜底就算 HTML 里一个 icon 链接都没有也要尝试/favicon.ico。错误不能被吞掉找不到图标就明确返回空而不是抛一串堆栈让上层崩溃。可被复用同事不想了解内部实现直接fetch(url)就能拿到结果。这四个目标看起来简单但它们直接决定了代码结构。尤其是错误不能被吞掉这条我在后文会专门展开讲。2. 全流程拆解从 URL 到图标文件一共要过几道关2.1 第一关页面地址的清洗与补全用户丢过来的地址五花八门有人给example.com有人给http://example.com还有人给example.com/path/to/page。我要做的第一件事就是把字符串变成一个可以安全发起请求的地址。如果地址里没有协议头我会默认补上https://。注意优先用 HTTPS 而不是 HTTP因为现在大多数站点都强制跳转 HTTPS补了也不亏。补完之后必须用urlparse检查netloc是否为空否则像https://这种半吊子输入会直接引发异常。from urllib.parse import urlparse def normalize_url(url: str) - str: url url.strip() if not url: raise ValueError(URL 不能为空) if not urlparse(url).scheme: url https:// url if not urlparse(url).netloc: raise ValueError(f无法解析 URL: {url}) return url这里有个容易忽略的点urlparse(example.com/path)会把example.com当 pathscheme 为空所以我们要先补 scheme 再解析 netloc。顺序反了就会出错。2.2 第二关拉取 HTML 并找出所有 icon 链接地址干净了就要真实去请求一次页面。这里我用requests.Session而不是裸requests.get因为同一个工具类可能被多次调用Session 会复用底层连接速度快不少。请求时要带一个像样的 User-Agent很多站点对空 UA 的请求直接拒绝。拿到 HTML 后用 BeautifulSoup 解析遍历所有带href的link标签过滤出rel属于图标类型的候选。这一关是核心我在第 3 节会重点讲那些写法差异这里先只看主干逻辑from bs4 import BeautifulSoup from urllib.parse import urljoin def extract_candidates(html: str, base_url: str) - list: soup BeautifulSoup(html, lxml) found [] for link in soup.find_all(link, hrefTrue): rel link.get(rel) if not rel: continue if isinstance(rel, str): rel_values {rel.lower()} else: rel_values {v.lower() for v in rel} if not (rel_values ICON_RELS): continue href link.get(href, ).strip() if not href: continue absolute_url urljoin(base_url, href) found.append(absolute_url) return found为什么要用urljoin(base_url, href)因为很多站点写的是相对路径比如/icons/favicon.png或者./favicon.png。而 base_url 不能用请求前的地址要用请求结束后response.url的地址。如果页面从example.com302 跳到了www.example.com/page.html后续所有相对路径都必须以跳转后的地址为准。2.3 第三关候选链接的去重、排序与校验从 HTML 里可能筛出好几个候选icon、apple-touch-icon、shortcut icon它们可能指向同一个资源也可能指向不同尺寸。这时要做的第一件事是去重否则后面会重复请求浪费带宽。去重之后我会给候选按sizes属性排序。Apple 的触摸图标尺寸通常是 180x180标准 icon 可能是 32x32。如果最终业务端需要的是高清图优先取大尺寸如果只要一个通用小图标排序后取第一个即可。真正请求候选图标之前还要做一次校验请求。这一步的目的是确认这个链接真的能返回图片而不是一个 404 页面。我用streamTrue发起请求检查状态码、Content-Type以及文件二进制头。为什么要看二进制头因为有些服务器的Content-Type会写application/octet-stream甚至text/plain但实际内容确实是图片。def is_valid_icon_url(url: str) - bool: try: resp session.get(url, streamTrue, timeout(5, 10)) if resp.status_code ! 200: return False content_type resp.headers.get(Content-Type, ).lower() if content_type.startswith(image/): return True # 看一眼文件头 first_bytes next(resp.iter_content(16), b) if first_bytes[:4] b\x00\x00\x01\x00: # ico return True if first_bytes[:4] b\x89PNG: # png return True return False except requests.RequestException: return False finally: resp.close()这里有个细节校验是带streamTrue的 GET不是 HEAD。因为有些服务器 HEAD 请求的响应头不完整甚至不允许 HEAD用 GET 判断最保险。但由于只读了 16 字节就关闭连接实际下载的开销很小。2.4 第四关真正的下载与本地落盘校验通过后剩下的事情就是二次请求并写入文件。写文件时要以wb二进制模式打开不能写文本模式否则图片会被换行符破坏。def download(url: str, output_path: str) - None: resp session.get(url, timeout(5, 10)) with open(output_path, wb) as f: f.write(resp.content)注意这里的第二次请求不是浪费。第一次请求是校验只读了 16 字节第二次才是完整下载。如果你对吞吐量有更高要求可以在校验时直接把内容 split 成两份一份做魔数检查、一份保存彻底省掉二次请求。我这个工具为了逻辑清晰选择了二段式实际性能损失完全可以接受。3. 真正折磨人的不是爬而是 HTML 里的那些写法差异3.1 rel 属性的排列组合比想象中多规范文档里的 favicon 写法是link relicon但现实世界根本不按规范来。我见过的写法包括reliconrelshortcut iconIE 时代的老写法relapple-touch-iconiPhone 主屏图标relapple-touch-icon-precomposedrelmask-iconSafari 的墨迹图标处理时必须把这些都纳入识别范围。但要注意relmask-icon通常需要配color属性而且只有 Safari 会用到如果你不想收集这种非通用格式可以在 filter 阶段去掉。还有一个特别容易踩的坑BeautifulSoup 对rel属性的解析结果可能是列表也可能是字符串。在 HTMLParser 标准里rel是多值属性所以多数情况下它是[icon]但若遇到link relshortcut icon它可能是[shortcut, icon]。我在代码里统一转 set 再判断交集这样不管它返回什么结构都不会出错。3.2 href 的相对路径、协议相对与 data URIhref的多样性是第二个坑。常见的有四种绝对地址https://example.com/favicon.ico协议相对地址//cdn.example.com/favicon.ico相对地址/static/images/favicon.pngdata URIdata:image/svgxml;base64,....前三种都能用urljoin(base_url, href)解决。只要 base_url 正确协议相对地址也会被自动补成完整 URL。data URI 则比较特殊它是一个把图片内容直接嵌在 HTML 里的格式。遇到这种情况你要做的不是去下载而是从data:后面把内容解析出来去掉逗号前的 MIME 声明然后 base64 解码后直接保存文件。如果 HTML 里用了 data URI 还带上引号或者换行解析前需要先清理一下。def handle_data_uri(uri: str) - bytes: # 格式: data:[mediatype][;base64],data if , not in uri: raise ValueError(不识别的 data URI) header, payload uri.split(,, 1) if ;base64 not in header: raise ValueError(暂时只支持 base64 编码的数据) import base64 return base64.b64decode(payload)3.3 有些网站根本不写 link 标签只能靠兜底路径这是最让人头疼的一类。有些站点把 icon 写在 CSS 里比如给body设了background: url(favicon.png)有些站点甚至是在 JavaScript 运行后才动态插入link。对于静态爬虫来说这些情况很难优雅处理除非你动用无头浏览器。所以我在设计里加了一层默认路径兜底当 HTML 里找不到合法 icon 链接时依次尝试https://域名/favicon.ico和https://域名/favicon.png。这两条路径是 90% 站点默认放置图标的位置命中率很高。默认路径兜底听起来简单但它有个副作用每次都要发起多一次请求遇到 404 页面也要等超时。所以兜底路径不宜列太多。网上有人会把十几条常见路径全试一遍比如/assets/favicon.ico、/images/favicon.ico效率太低了。真要支持这类站点应该建一个域名到路径的映射配置而不是无脑穷举。4. 网络层的容错策略超时、重定向、SSL 与假 2004.1 超时和重定向不是越快越好也不是越慢越好爬虫最怕的不是失败而是挂在一个请求上半天不返回。所以我所有请求都用了元组形式的超时设置timeout(5, 10)。这表示连接超时 5 秒读取超时 10 秒。为什么分开有些站点连接很快但响应很慢有些站点连接就卡住。分别设置能更精准地控制。重定向方面requests默认会跟随 30 次重定向。对 favicon 抓取来说30 次太多了很容易掉进死循环。比较好的做法是让Session.max_redirects限制在 5 次左右超过就当失败处理。虽然请求页面时合理重定向是常态但一个 icon 链接连续跳转 5 次还拿不到图就得怀疑是恶意循环了。4.2 SSL 证书校验到底开不开网上很多爬虫代码喜欢直接verifyFalse把 SSL 警告一关就完事。我不太推荐全局这么做。你永远不知道目标站点是不是被人做了中间人关闭校验等于把整条链路的安全都交给了运气。我见过最合理的做法是默认verifyTrue如果遇到证书过期的站点单独在异常处理里提示而不是直接放行。如果你实在要处理自签证书站点也应该维护一个域名白名单只对白名单内域名关闭校验。DEFAULT_VERIFY True INSECURE_DOMAINS {self-signed.example.com} def should_verify(url: str) - bool: host urlparse(url).netloc.lower() return host not in INSECURE_DOMAINS4.3 状态码 200 不代表内容真的是图标这个坑我刚开始写时经常踩。很多老旧站点对所有缺失资源都统一返回 200但页面内容其实是一段默认 HTML。你高高兴兴地把resp.content存成.ico文件结果浏览器解析失败。我处理这个问题靠两层检查Content-Type是否以image/开头。如果返回text/html基本可以断定不是图标。如果Content-Type不清晰看文件魔数。这样就算服务器把图片当成二进制流返回也能正确识别。格式魔数特征PNG\x89PNG\r\n\x1a\nJPEG\xff\xd8\xffICO\x00\x00\x01\x00WEBPRIFFWEBPSVG文本内容以svg开头之所以检查文件头而不是只信Content-Type是因为有些 CDN 会把所有文件都打成application/octet-stream但内容依然是图片。如果不检查魔数这类站点就全废了。5. 完整代码实现一个 200 行左右的 FaviconFetcher5.1 依赖安装与项目结构这个工具只需要三个依赖我建议用虚拟环境安装pip install requests beautifulsoup4 lxml项目结构非常简单全部逻辑放在一个模块里favicon_fetcher/ ├── fetcher.py ├── requirements.txt └── cli.pyfetcher.py放核心类cli.py放命令行入口requirements.txt放三个依赖。如果你只是想在业务代码里调用甚至不需要 cli.py。5.2 核心类完整代码下面是fetcher.py的完整实现我加上了类型注解方便后续维护from __future__ import annotations import base64 import logging import re from dataclasses import dataclass from typing import Optional from urllib.parse import urljoin, urlparse import requests from bs4 import BeautifulSoup logger logging.getLogger(favicon-fetcher) ICON_RELS { icon, shortcut icon, apple-touch-icon, apple-touch-icon-precomposed, mask-icon, } DEFAULT_USER_AGENT ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36 ) dataclass class FaviconCandidate: url: str rel: str size: Optional[str] None class FaviconFetchError(Exception): pass class FaviconFetcher: def __init__( self, timeout: tuple (5, 10), user_agent: str DEFAULT_USER_AGENT, verify: bool True, ) - None: self.timeout timeout self.verify verify self.session requests.Session() self.session.headers.update({User-Agent: user_agent}) self.session.max_redirects 5 staticmethod def normalize_url(url: str) - str: url url.strip() if not url: raise ValueError(URL 不能为空) if not urlparse(url).scheme: url https:// url if not urlparse(url).netloc: raise ValueError(f无法解析 URL: {url}) return url def _get(self, url: str): try: resp self.session.get(url, timeoutself.timeout, verifyself.verify) resp.raise_for_status() return resp except requests.RequestException as exc: raise FaviconFetchError(f请求 {url} 失败: {exc}) from exc def get_favicon_url(self, page_url: str) - Optional[str]: page_url self.normalize_url(page_url) resp self._get(page_url) html resp.text base_url resp.url candidates self._extract_candidates(html, base_url) candidates self._default_candidates(base_url) seen set() unique_candidates [] for candidate in candidates: if candidate.url not in seen: seen.add(candidate.url) unique_candidates.append(candidate) unique_candidates.sort(keylambda c: self._size_score(c.size), reverseTrue) for candidate in unique_candidates: if candidate.url.startswith(data:): return candidate.url if self._is_valid_icon_url(candidate.url): return candidate.url return None def _extract_candidates(self, html: str, base_url: str) - list[FaviconCandidate]: soup BeautifulSoup(html, lxml) candidates [] for link in soup.find_all(link, hrefTrue): rel link.get(rel) if not rel: continue if isinstance(rel, str): rel_values {rel.lower()} else: rel_values {v.lower() for v in rel} if not (rel_values ICON_RELS): continue href link.get(href, ).strip() if not href: continue candidates.append( FaviconCandidate( urlurljoin(base_url, href), rel .join(rel_values), sizelink.get(sizes), ) ) return candidates def _default_candidates(self, base_url: str) - list[FaviconCandidate]: parsed urlparse(base_url) origin f{parsed.scheme}://{parsed.netloc} return [ FaviconCandidate(urlurljoin(origin, /favicon.ico), reldefault), FaviconCandidate(urlurljoin(origin, /favicon.png), reldefault), ] staticmethod def _size_score(size: Optional[str]) - int: if not size: return 0 match re.search(r(\d)\s*x\s*(\d), size) if not match: return 0 return int(match.group(1)) * int(match.group(2)) def _is_valid_icon_url(self, url: str) - bool: try: resp self.session.get( url, timeoutself.timeout, streamTrue, verifyself.verify, ) if resp.status_code ! 200: resp.close() return False content_type resp.headers.get(Content-Type, ).lower() if content_type.startswith(image/): resp.close() return True first_bytes next(resp.iter_content(16), b) resp.close() if first_bytes.startswith(b\x89PNG): return True if first_bytes.startswith(b\xff\xd8\xff): return True if first_bytes.startswith(bsvg): return True if first_bytes.startswith(bRIFF) and bWEBP in first_bytes: return True if len(first_bytes) 4 and first_bytes[:4] b\x00\x00\x01\x00: return True return False except requests.RequestException: return False def download(self, url: str, output_path: str) - None: data self._download_bytes(url) with open(output_path, wb) as f: f.write(data) def _download_bytes(self, url: str) - bytes: if url.startswith(data:): try: _, payload url.split(,, 1) return base64.b64decode(payload) except Exception as exc: raise FaviconFetchError(f无法解析 data URI: {url[:60]}) from exc resp self._get(url) return resp.content这段代码一共不到 160 行核心逻辑都在get_favicon_url和_is_valid_icon_url这两个方法里。你不用担心它过度设计每个方法职责单一后面想加缓存、加代理、加超时配置都不需要改结构。5.3 怎么把这个类变成命令行工具如果只做一次性抓取命令行入口也很简单写一个cli.pyimport argparse from fetcher import FaviconFetcher, FaviconFetchError def main(): parser argparse.ArgumentParser(descriptionFavicon 爬虫工具) parser.add_argument(--url, help目标站点 URL, requiredTrue) parser.add_argument(--output, help输出文件路径默认不保存文件) args parser.parse_args() fetcher FaviconFetcher() try: icon_url fetcher.get_favicon_url(args.url) if not icon_url: print(未找到可用 Favicon) return print(f找到 Favicon: {icon_url}) if args.output: fetcher.download(icon_url, args.output) print(f已下载到: {args.output}) except FaviconFetchError as exc: print(f抓取失败: {exc}) if __name__ __main__: main()使用示例# 只打印图标地址 python cli.py --url example.com # 下载到本地 python cli.py --url example.com --output favicon.ico在这个阶段工具已经可以满足大部分日常需求。但我还是要提醒一句命令行工具好写真正的稳定性要靠批量跑过真实站点来验证。6. 实测反馈我拿这个脚本跑了 50 个站点6.1 测试结果分类写完代码后我从业务后台里随机抽了 50 个域名跑了一遍结果大致分成四类结果类型占比说明直接在 HTML 里命中 icon 链接52%大部分现代站点不会偷懒没有 link 标签但/favicon.ico可用30%中规中矩靠兜底路径救回命中了 data URI 图标10%多出现在小型个人站彻底失败8%超时、404、证书异常、假 20052% 的直接命中率听起来不高但如果把兜底算上整体成功率已经到 92%。对业务来说剩下的 8% 直接用默认图兜底即可完全不影响展示。6.2 最常出现的三个失败模式第一是站点本身响应很慢。首页 HTML 超过 5 秒才返回的站点确实存在但它们未必不可用。对付这类站点单纯把超时调大不理智更合理的做法是在调度层做并发控制把慢站点踢到重试队列。第二是服务器把缺失图标伪装成 200。我遇到过几个老站请求/favicon.ico时返回 200但内容是 404 页面本身Content-Type是text/html而不是图片。这正好验证了必须要做Content-Type和魔数双重校验不能只看状态码。第三是CDN 或域名本身无法解析。这类问题没有任何代码能完全规避唯一的经验是批量抓取时给每个域名记录失败原因而不是简单打印一个异常。后来我把失败原因分类保存方便上游运营同事去和对应站点联系。6.3 值得按需增加的进阶功能跑完这 50 个站点我会顺手理一下哪些功能属于最好有而不是必须有并发版本把requests.Session替换成httpx.AsyncClient用 asyncio 并发抓取能显著提高批量速度。但注意控制并发数在 20 左右否则容易被对方限流。结果缓存以域名为主键把成功或失败结果缓存到 SQLite。第二次请求同一个域名时直接返回上一次结果避免重复消耗目标服务器资源。图标归一化apple-touch-icon通常是大图格式多为 PNG如果业务端需要统一输出.ico可以引入 Pillow 做格式转换。合规性处理如果抓取范围进一步扩大建议在请求前读取目标站点robots.txt对明确禁止的路径做跳过处理。这既是基本礼貌也能减少不必要的纠纷。我不建议一开始就把这些功能全部放进代码里因为我们很容易高估项目的复杂度。先把核心工具跑稳再去按真实反馈逐步加功能这才是更务实的路线。最后分享一个实际经验不要为了追求 100% 成功率而把所有异常都吞掉。这个工具我用到现在最稳的做法是让它找不到图标时返回None上层业务拿到空结果后自动展示一张默认图而不是强行用一个假图标或者报错。宁可没有也别给错这个原则在很多数据采集场景里都成立。
返回列表