简介:“批量获取网站标题1.3”是一款面向开发者与网络数据分析人员的轻量级抓取工具,核心用途是批量采集网站标题,同时支持域名、IP和端口识别,并能在网页多次跳转时自动跟随重定向,减少人工逐个访问的繁琐操作。工具底层依赖TCP/IP协议通信与HTTP状态码解析,从TCP三次握手建立连接,到识别301/302重定向并更新目标地址,完整呈现了网络爬虫的关键环节,适合做站点巡检、SEO数据整理或爬虫入门学习的读者。资源包共13个文件,压缩后仅1.6MB,包括GetWebTitle.exe主程序、NPOI系列dll、SmoothProgressBar.dll进度条控件、config配置文件与示例图片,附带的PDB调试文件也有助于理解程序结构,整体简洁,便于运行和二次开发。目前已有292人学习下载。借助该工具,读者既能理解HTTP请求与响应解析、重定向处理等原理,也能通过NPOI将抓取结果批量导出为Excel表格,再结合进度条控件的界面反馈,可以完整掌握从数据采集到结果导出的实用技能,对实际开展网络数据采集和桌面工具开发有直接帮助。
1. 批量获取网站标题1.3 到底解决什么问题
做站点巡检、外链普查、改版核对的时候,「这批域名现在还没有 title、title 是不是还挂在旧站上」往往比看首页截图更快暴露问题。批量获取网站标题这类工具,核心就是给一批 URL 各发一次请求,把<title>抽出来,顺带告诉你哪些超时、哪些跳转、哪些服务器根本没回。1.3 这个版本号在同类小工具里,通常意味着已经把编码、超时、重试这些兜底逻辑补全了,而不是单纯加了个并发开关。适合运维、SEO 和对站群做体检的数据工程师,把它接进自己的巡检脚本里按需改参数。
2. 批量获取网站标题的解析原理:从 HTML 到 title 的两条路径
2.1<title>和 og:title,哪个才是页面真正的标题
浏览器地址栏显示的是<head>里的<title>,社交平台分享卡用的却是<meta property="og:title">。多数页面两者一致,但并不保证。真正做批量获取网站标题时,我一般把优先级定为:标准<title>有内容就用它,没有或为空再回退 og:title,两者都没有才记为缺失。这样得到的字段不会因为某个 CMS 模板只写了分享卡标题而整行空缺。
举个例子,很多 WordPress 主题在 SEO 插件未启用时,文章页的<title>被压成一行,正文里的评论数等变量会把标题撑散,而 og:title 反而是完整标题。反过来,某些企业站只在<title>里写「首页」,og:title 却写了品牌全称。到底信谁取决于业务口径:做收录核对信<title>,做分享文案核查信 og:title。脚本里把两个值都取出来,让下游自己选口径,比在采集端硬定规则更省事。
2.2 三种解析器选型:正则、lxml、BeautifulSoup
标题提取看起来是「找两个标签」,但现实页面的 HTML 质量参差不齐。常见做法是三个方案里选一个:
- 正则
<title>(.*?)</title>:最快,但遇到 title 标签里嵌套其他标签、属性带换行就会翻车,只适合快速验证。 - lxml 的
etree.HTMLParser:libxml2 的容错解析能力最强,能把浏览器模式下不闭合的标签自动补全,速度仅次于纯正则。 - BeautifulSoup + html.parser:API 友好,但解析同一份文档通常比 lxml 慢一个数量级,批量过万 URL 时差距非常明显。
我的选择是 lxml,理由只有一个:解析坏 HTML 的恢复能力决定了整批任务的失败率。下面这段是最小提取函数,1.3 这类版本常见做法是把解析和请求拆开,方便单独加断言:
from lxml import etree def extract_title(html_text: str) -> tuple[str, str]: """从 HTML 文本中提取标题,返回 (标题内容, 来源标签)""" parser = etree.HTMLParser(recover=True) tree = etree.fromstring(html_text, parser=parser) if tree is None: return "", "empty" for node in tree.xpath("//title"): text = "".join(node.itertext()).strip() if text: return text, "title" for node in tree.xpath("//meta[@property='og:title']"): text = (node.get("content") or "").strip() if text: return text, "og:title" return "", "missing"这段代码有两个细节值得注意:一是recover=True让解析器遇到标签错乱时尽量跳过而不是抛异常;二是node.itertext()把 title 内部可能存在的 em、strong 等子标签文本也拼接进来,避免「标题被拆成两段」的假缺失。xpath 里同时限定 property 属性,是为了避开name="twitter:title"这类重复字段。
2.3 提取层最容易翻车的 7 个边界情况
下方表格是我排查时固定对照的清单,每一条都对应线上真实出现过的返回结果:
| 情况 | 现象 | 处理口径 |
|---|---|---|
<title>为空但标签存在 | 返回空字符串 | 回退 og:title,仍为空记 missing |
title 里套了<script>或<style> | 提取到一段 JS 代码 | 过滤 script/style 子节点后再拼接 |
<title>出现在<body>里 | 浏览器会忽略它,正则却抓得到 | 只信任 head 内节点,或用 xpath 限定位置 |
| 页面是 gzip 响应但未解压 | 解析出乱码甚至空标题 | 在请求层强制解压,见 3.3 |
| title 文本里含右尖括号 | 正则提前截断 | 用解析器取 text,不用正则 |
| 整页是 JS 渲染,HTML 里没有 title | 永远 missing | 标记为 js-rendered,交给无头浏览器抽查 |
| 响应是 404/302 错误页 | 抓到「404 Not Found」 | 先按状态码分流,错误页标题不写进结果 |
第 4 行和第 7 行其实是请求层问题,但排查时最容易误判成解析 bug。我的做法是:先把状态码和最终 URL 记下来,再把标题字段填成原始文本,最后统一清洗。这样批量获取网站标题的结果数据里,「无标题」和「错误页标题」可区分,而不是混成一堆空值。
3. 用 Python 写最小可用的批量获取网站标题脚本
3.1 请求头与超时参数:先定规矩再写循环
批量场景和浏览器访问最大的差别是:没有 cookie、没有 JS、没有用户手工等待。所以请求层要把默认行为定死。我常用的请求头只有四个字段,多了反而容易被一些 WAF 识别成脚本特征:
HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.8,en;q=0.5", }请求超时用元组分别指定连接和读取:timeout=(3, 8)表示 TCP 连不上 3 秒就放弃,首字节 8 秒等不到就放弃。单设一个timeout=8的问题是,某些路由黑洞会让 connect 阶段挂满 8 秒再进入读取阶段,单个 URL 总耗时翻倍。allow_redirects保留默认 True,但要把session.max_redirects调小,避免遇到循环跳转的站点在单个 URL 上空转几十跳。
3.2 单线程版本:先把正确性跑通
批量获取网站标题的最小脚本,我习惯先写成单线程、带状态码分流、失败不中断的形态。只有这个版本跑出来的结果和人工抽查一致,才值得上并发:
import csv import requests HEADERS = {"User-Agent": "Mozilla/5.0 ..."} TIMEOUT = (3, 8) def fetch_title(url: str, session: requests.Session | None = None) -> dict: """抓取单个 URL 的标题;传入 session 时复用连接,否则自建并关闭""" result = {"url": url, "status": 0, "final_url": url, "title": "", "source": "", "error": ""} close_session = session is None if close_session: session = requests.Session() try: with session.get(url, headers=HEADERS, timeout=TIMEOUT, allow_redirects=True, stream=True) as resp: resp.raw.read(1024 * 512) # 只读前 512KB,防止大文件拖垮任务 result["status"] = resp.status_code result["final_url"] = resp.url if resp.status_code != 200: result["error"] = f"http_{resp.status_code}" return result if "text/html" not in resp.headers.get("Content-Type", ""): result["error"] = "not_html" return result result["title"], result["source"] = extract_title(resp.text) except requests.Timeout: result["error"] = "timeout" except requests.RequestException as exc: result["error"] = f"network:{type(exc).__name__}" finally: if close_session: session.close() return result这里的stream=True配合手动读取前 512KB 是关键写法:不设 stream 时 requests 会把整个响应体下载完才返回,遇到一个 200MB 的安装包会让单线程脚本卡到超时边界。读完即弃,连接由 with 块自动归还。状态码非 200 时直接返回不解析页面,避免错误页的 title 污染数据。
调用方用循环收集结果即可,每个 URL 独立写成一行 CSV,即使中间某个请求异常,前面的结果也已经落盘:
with open("titles.csv", "w", newline="", encoding="utf-8-sig") as fp: writer = csv.DictWriter(fp, fieldnames=["url", "status", "final_url", "title", "source", "error"]) writer.writeheader() for url in url_list: writer.writerow(fetch_title(url))3.3 编码乱码:排在所有问题第一位的坑
requests 的resp.text默认按响应头里的 charset 解码,但大量老站不返回 charset,或返回过期的 GB2312。此时 requests 会回退到 ISO-8859-1,中文全部变乱码。解决顺序是:优先用响应头的 charset;没有则用apparent_encoding探测;探测结果是 GB2312 时统一按 GBK 解码,因为 GB2312 是 GBK 的子集,按 GBK 解不会引入额外错误。
if resp.encoding is None or resp.encoding.lower() in ("iso-8859-1", "ascii"): detected = resp.apparent_encoding or "utf-8" if detected.upper() in ("GB2312", "GBK"): detected = "gbk" resp.encoding = detected提示:
apparent_encoding对短文本和纯英文页面经常误判,所以只有响应头没给 charset 或给了错误兜底值时,才允许它接管。抓回来的 title 如果还有零星乱码,大概率是页面本身把 GBK 字节写进了 utf-8 声明的文档里,那种只能整站统一转码,单请求层面救不回来。
4. 并发提速与限速:批量获取网站标题的吞吐量调优
4.1 线程池还是协程:先看目标站点数量级
批量获取网站标题的耗时瓶颈绝大部分在网络 IO,不在 CPU。两个主流方案:concurrent.futures.ThreadPoolExecutor+ requests,或者 httpx 的 AsyncClient + asyncio。两者在千级 URL 场景差距不大,差别主要在工程层面:
| 对比项 | ThreadPoolExecutor | httpx 异步 |
|---|---|---|
| 代码改动量 | 循环换成 map,几乎不动 | 整个请求链都要 async |
| 调试门槛 | 报错堆栈清晰 | 协程报错需要额外定位 |
| 会话复用 | 每线程一个 Session | 全局一个 AsyncClient |
| 上万 URL 吞吐 | 够用 | 更高,但限速逻辑更难写 |
我的习惯是五千 URL 以内用线程池,代码可读性优先;上两万再考虑 httpx 异步加信号量限流。不要一上来就异步,异步版本一旦某个第三方站点长时间不响应,排查成本比省下的那几分钟高得多。
4.2 ThreadPoolExecutor 的并发抓取模板
并发版本的核心改动有两点:每个线程持有一个独立的 requests.Session 以复用连接;结果收集放主线程,避免多线程同时写 CSV。下面是一个可以直接替换单循环的模板:
import threading from concurrent.futures import ThreadPoolExecutor, as_completed _local = threading.local() def get_session() -> requests.Session: """返回当前线程自己的 Session,保证连接复用且线程隔离""" if not hasattr(_local, "sess"): _local.sess = requests.Session() _local.sess.headers.update(HEADERS) return _local.sess def worker(url: str) -> dict: session = get_session() # 此处在 worker 线程内执行,拿到的是本线程实例 return fetch_title(url, session=session) def run_batch(url_list: list[str], max_workers: int = 8) -> list[dict]: results = [] with ThreadPoolExecutor(max_workers=max_workers) as pool: future_map = {pool.submit(worker, url): url for url in url_list} for future in as_completed(future_map): results.append(future.result()) return results注意get_session()是在worker()内部被调用的,而不是在主线程里先建好再传给线程。ThreadPoolExecutor 的 worker 线程是池内固定的,所以每个执行过任务的线程只会创建一个 Session,同站多次请求可以复用 TCP 连接。as_completed保证哪个先完成先收集,不会因为列表靠后的慢 URL 阻塞前面结果的处理。
4.3 三个必调参数:max_workers、delay、重试退避
并发不是越大越好。目标站点是普通企业站时,常见的合理取值如下:
| 参数 | 建议值 | 调整依据 |
|---|---|---|
| max_workers | 8~16 | 站点返回 429/503 就调小到 4 |
| 同域名最小间隔 | 0.1~0.5 秒 | 被限流就翻倍 |
| 单请求超时 | 连接 3s + 读取 8s | 移动端弱网环境放宽到 15s |
| 重试次数 | 2~3 次 | 超过 3 次收益极低 |
| 重试退避 | 0.5s 起,每次乘 2 | 加随机抖动,避免同步重试 |
重试只对网络层错误和 5xx 做,4xx 一律不重试。常见做法是:超时重试、连接错误重试、429/503 重试,404/403 直接标记失败。重试时对整批任务加锁保护请求时间戳,能让限速不随并发数失效:
_last_request = {} _lock = threading.Lock() def rate_limited_get(session, url, min_interval=0.2): with _lock: now = time.monotonic() netloc = url.split("/")[2] gap = now - _last_request.get(netloc, 0) if gap < min_interval: time.sleep(min_interval - gap) _last_request[netloc] = time.monotonic() return session.get(url, timeout=TIMEOUT)注意:4xx 一律不重试。403 通常是请求头问题,重试只会放大被封风险;404 是目标自身问题,重试浪费连接。只有超时、连接错误、429 和 503 才值得做指数退避重试。
按域名 netloc 做粒度限速而不是全局限速,是批量抓取多站点列表时的正确姿势:A 站的 1000 个 URL 不会因为 B 站慢而整体降速,但同一站内部不会瞬间打满并发。sleep放在锁内,是为了防止多个线程同时睡醒后在同一毫秒发出请求,把限速变成摆设。
5. 抓完只是开始:批量获取网站标题的结果校验与交付技巧
5.1 落盘格式里值得追加的三个字段
CSV 里除了 url、title,建议至少再存final_url、status、source三个字段。final_url能反查跳转链是否指向已售域名或竞争对手页面;source区分标题来自<title>还是 og:title;status用来在 Excel 里直接筛选非 200 的异常行。编码用utf-8-sig而不是utf-8,这样用 Excel 打开不会出现列头乱码。
5.2 用分布表和抽样复核验收结果
跑完两万条之后,先看三张分布:状态码分布、source 分布、标题长度分布。标题长度在 50 字符左右的密集柱,通常是被模板截断的标题;source 里 og:title 占比超过 20%,说明很多站点主标题字段为空,要回头检查解析逻辑是否漏了某些 head 结构。最后随机抽 20 条手工打开比对,重点看 title 来源是 og:title 和 missing 的记录,这两类最可能是解析误判而不是站点真的没写。
5.3 一个可复用的标题相关性探针技巧
最后一个技巧:把抓回来的标题和已知锚文本对比,不增加任何额外请求。对外链普查场景,站点 B 首页标题里通常含品牌词,如果标题连外链锚文本里的核心词都不包含,这条记录就需要标记为「疑似关联度低」。具体做法是在写 CSV 前加一个本地计算函数:
def mark_unrelated(row: dict, anchor: str) -> dict: """标题不含锚文本核心词时打标记,供人工复核""" if anchor.lower() not in row["title"].lower(): row["flag"] = "title_unrelated" else: row["flag"] = "ok" return row这个函数纯本地做子串匹配,批量两万行也就几十毫秒,却能让下游从两万行标题里直接挑出需要人工复核的几百行。配合前两节的状态码、来源分布,批量获取网站标题1.3 这类工具就从「数据采集」迈过了「数据体检」的坎,输出结果可以直接作为巡检报告交付。
本文还有配套的精品资源,点击获取