用Java手搓一个网页链接爬虫:从HttpURLConnection到队列管理的完整实现
用Java手搓一个网页链接爬虫:从HttpURLConnection到队列管理的完整实现
在信息爆炸的时代,网页爬虫技术已经成为开发者获取数据的必备技能。虽然市面上有Scrapy、Jsoup等成熟框架,但亲手实现一个基础爬虫能让你真正理解HTTP请求、HTML解析、URL去重等核心机制。本文将带你用Java从零构建一个完整的网页链接爬虫,涵盖从底层网络请求到高级队列调度的全流程。
1. 爬虫基础架构设计
一个完整的网页爬虫通常由四个核心模块组成:
- 网络请求模块:负责发送HTTP请求并获取网页内容
- 链接提取模块:从HTML中解析出超链接
- URL管理模块:处理已访问和待访问的URL队列
- 调度控制模块:协调整个爬取流程
我们先来看一个最简单的爬虫工作流程:
public class SimpleCrawler { public static void main(String[] args) { String startUrl = "https://example.com"; crawl(startUrl); } static void crawl(String url) { // 1. 发送HTTP请求 String html = fetchHtml(url); // 2. 提取链接 List<String> links = extractLinks(html); // 3. 处理新链接 processNewLinks(links); } }这个基础架构虽然简单,但已经包含了爬虫的核心要素。接下来我们将逐步完善每个模块。
2. 网络请求模块实现
Java标准库中的HttpURLConnection类提供了基础的HTTP功能,虽然不如Apache HttpClient或OkHttp强大,但对于学习目的已经足够。
2.1 基础HTTP请求实现
public class HttpFetcher { public static String fetchHtml(String urlString) throws IOException { URL url = new URL(urlString); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); // 设置请求参数 conn.setRequestMethod("GET"); conn.setConnectTimeout(3000); conn.setReadTimeout(3000); // 处理响应 try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { StringBuilder response = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { response.append(line); } return response.toString(); } finally { conn.disconnect(); } } }注意:实际项目中应该添加更完善的错误处理,包括重试机制、HTTP状态码检查等。
2.2 请求头设置与模拟浏览器
许多网站会阻止简单的爬虫请求,因此我们需要模拟浏览器行为:
// 在打开连接后添加 conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); conn.setRequestProperty("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9");3. 链接提取与处理
从HTML中提取链接有多种方法,我们将比较几种常见方式。
3.1 基于正则表达式的简单提取
public static List<String> extractLinksWithRegex(String html) { List<String> links = new ArrayList<>(); Pattern pattern = Pattern.compile("<a\\s+href=\"([^\"]*)\""); Matcher matcher = pattern.matcher(html); while (matcher.find()) { String url = matcher.group(1); if (url.startsWith("http")) { links.add(url); } } return links; }这种方法简单但不够健壮,无法处理复杂的HTML结构。
3.2 基于字符串操作的提取
public static List<String> extractLinksWithString(String html) { List<String> links = new ArrayList<>(); int index = 0; while (true) { index = html.indexOf("<a href=\"", index); if (index == -1) break; int start = index + "<a href=\"".length(); int end = html.indexOf("\"", start); if (end == -1) break; String url = html.substring(start, end); if (url.startsWith("http")) { links.add(url); } index = end; } return links; }3.3 相对URL转绝对URL
提取的链接可能是相对路径,需要转换为绝对URL:
public static String toAbsoluteUrl(String baseUrl, String relativeUrl) { try { URL base = new URL(baseUrl); URL absolute = new URL(base, relativeUrl); return absolute.toString(); } catch (MalformedURLException e) { return null; } }4. URL队列管理
高效的URL管理是爬虫性能的关键。我们需要解决两个核心问题:去重和优先级。
4.1 基础队列实现
public class UrlQueue { private final Set<String> visitedUrls = new HashSet<>(); private final Queue<String> unvisitedUrls = new LinkedList<>(); public synchronized void addUnvisited(String url) { if (url == null || visitedUrls.contains(url) || unvisitedUrls.contains(url)) { return; } unvisitedUrls.add(url); } public synchronized String getNextUrl() { return unvisitedUrls.poll(); } public synchronized void markVisited(String url) { visitedUrls.add(url); } public synchronized int getVisitedCount() { return visitedUrls.size(); } }4.2 优先级队列实现
某些场景下,我们希望优先爬取特定URL(如首页、重要页面):
public class PriorityUrlQueue { private final Set<String> visitedUrls = new HashSet<>(); private final PriorityQueue<String> unvisitedUrls = new PriorityQueue<>(this::compareUrls); private int compareUrls(String url1, String url2) { // 首页优先级最高 if (url1.endsWith("/")) return -1; if (url2.endsWith("/")) return 1; // 其他规则... return 0; } // 其他方法同上 }5. 完整爬虫实现
现在我们将所有模块组合成一个完整的爬虫:
public class WebCrawler { private final UrlQueue queue; private final int maxPages; public WebCrawler(String startUrl, int maxPages) { this.queue = new UrlQueue(); this.queue.addUnvisited(startUrl); this.maxPages = maxPages; } public void start() throws IOException { while (!queue.isEmpty() && queue.getVisitedCount() < maxPages) { String url = queue.getNextUrl(); if (url == null) continue; System.out.println("Crawling: " + url); String html = HttpFetcher.fetchHtml(url); List<String> links = LinkExtractor.extractLinks(html); for (String link : links) { String absoluteLink = LinkProcessor.toAbsoluteUrl(url, link); queue.addUnvisited(absoluteLink); } queue.markVisited(url); } System.out.println("Crawling finished. Total pages: " + queue.getVisitedCount()); } }6. 高级功能与优化
基础爬虫完成后,我们可以考虑添加更多高级功能。
6.1 多线程爬取
public class ConcurrentCrawler { private final ExecutorService executor = Executors.newFixedThreadPool(4); private final UrlQueue queue = new UrlQueue(); public void start(String startUrl) { queue.addUnvisited(startUrl); for (int i = 0; i < 4; i++) { executor.submit(this::crawlTask); } } private void crawlTask() { while (!queue.isEmpty()) { String url = queue.getNextUrl(); // 爬取逻辑... } } }6.2 限速与礼貌爬取
public class PoliteCrawler { private long lastRequestTime = 0; private final long delayMs; public PoliteCrawler(long delayMs) { this.delayMs = delayMs; } public String fetchHtml(String url) throws IOException { long now = System.currentTimeMillis(); long elapsed = now - lastRequestTime; if (elapsed < delayMs) { try { Thread.sleep(delayMs - elapsed); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } lastRequestTime = System.currentTimeMillis(); return HttpFetcher.fetchHtml(url); } }6.3 链接过滤
public class LinkFilter { private final List<Pattern> allowedPatterns = new ArrayList<>(); private final List<Pattern> disallowedPatterns = new ArrayList<>(); public void addAllowedPattern(String regex) { allowedPatterns.add(Pattern.compile(regex)); } public boolean isAllowed(String url) { // 检查黑名单 for (Pattern pattern : disallowedPatterns) { if (pattern.matcher(url).matches()) { return false; } } // 检查白名单 if (!allowedPatterns.isEmpty()) { for (Pattern pattern : allowedPatterns) { if (pattern.matcher(url).matches()) { return true; } } return false; } return true; } }7. 与成熟框架的对比
虽然手写爬虫有教育意义,但在生产环境中,成熟框架通常更高效可靠。以Jsoup为例:
// 使用Jsoup提取链接 Document doc = Jsoup.connect("https://example.com").get(); Elements links = doc.select("a[href]"); for (Element link : links) { System.out.println(link.attr("abs:href")); }主要优势对比:
| 特性 | 手写爬虫 | Jsoup |
|---|---|---|
| 学习价值 | 高 | 低 |
| 开发效率 | 低 | 高 |
| 灵活性 | 高 | 中 |
| 健壮性 | 需要自行处理 | 内置 |
| 性能 | 取决于实现 | 优化好 |
在实际项目中,我通常会根据需求选择方案:学习原理时手写实现,生产环境使用成熟框架。这种组合既能深入理解底层机制,又能保证开发效率。
