+----------------+ 種子 URL +------------------+
| 初始化 | ─────────────────► | Unvisited Queue | (PriorityQueue 支持優先級/PageRank)
| (種子頁面) | | (待訪問) |
+----------------+ +------------------+
│
取出 URL (poll)
▼
+------------------+
| Downloader | (HttpClient 4.5+)
| - 連接/讀取超時 |
| - 重試策略 |
| - User-Agent 池 |
+------------------+
│
保存頁面 / 解析鏈接
▼
+------------------+
| Parser | (HtmlParser / Jsoup)
| - 提取 <a> href |
| - 提取 <frame> src|
| - 過濾規則 |
+------------------+
│
新鏈接入隊 (addUnvisited)
▼
+------------------+
| Visited Set | (HashSet O(1) 去重)
| - 防止循環 |
| - 統計已訪問數 |
+------------------+
public class LinkQueue {
private final Queue<String> unvisited = new PriorityQueue<>();
private final Set<String> visited = ConcurrentHashMap.newKeySet(); // JDK 8+ 線程安全 Set
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void addUnvisited(String url) {
if (StringUtils.isBlank(url)) return;
String normalized = normalizeUrl(url); // 統一協議/大小寫/去片段
lock.writeLock().lock();
try {
if (!visited.contains(normalized) && !unvisited.contains(normalized)) {
unvisited.offer(normalized);
}
} finally {
lock.writeLock().unlock();
}
}
public String pollUnvisited() {
lock.writeLock().lock();
try {
return unvisited.poll();
} finally {
lock.writeLock().unlock();
}
}
public void addVisited(String url) {
if (StringUtils.isNotBlank(url)) {
visited.add(normalizeUrl(url));
}
}
public boolean isUnvisitedEmpty() {
lock.readLock().lock();
try {
return unvisited.isEmpty();
} finally {
lock.readLock().unlock();
}
}
public int getVisitedCount() {
return visited.size();
}
private String normalizeUrl(String url) {
try {
URI uri = new URI(url);
// 移除 fragment,統一 host 小寫
return new URI(uri.getScheme(), uri.getAuthority(), uri.getPath(),
uri.getQuery(), null).toASCIIString().toLowerCase();
} catch (URISyntaxException e) {
return url.toLowerCase();
}
}
}
@Slf4j
public class PageDownloader {
private final CloseableHttpClient httpClient;
private final RequestConfig requestConfig;
public PageDownloader() {
this.requestConfig = RequestConfig.custom()
.setConnectTimeout(5000)
.setSocketTimeout(10000)
.setConnectionRequestTimeout(1000)
.setRedirectsEnabled(true)
.setMaxRedirects(3)
.build();
this.httpClient = HttpClients.custom()
.setDefaultRequestConfig(requestConfig)
.setRetryStrategy(new DefaultHttpRequestRetryStrategy(2, TimeValue.ofSeconds(1)))
.setUserAgent("Mozilla/5.0 (compatible; JavaCrawler/1.0; +https://example.com/bot)")
.evictIdleConnections(30, TimeUnit.SECONDS)
.build();
}
public PageDownloadResult download(String url) {
HttpGet get = new HttpGet(url);
try (CloseableHttpResponse response = httpClient.execute(get)) {
int status = response.getStatusLine().getStatusCode();
if (status != HttpStatus.SC_OK) {
return PageDownloadResult.failure(url, "HTTP " + status);
}
HttpEntity entity = response.getEntity();
String contentType = EntityUtils.getContentType(entity);
String charset = ContentType.getOrDefault(entity).getCharset();
String html = EntityUtils.toString(entity, charset != null ? charset : "UTF-8");
return PageDownloadResult.success(url, html, contentType);
} catch (Exception e) {
log.warn("下載失敗: {} - {}", url, e.getMessage());
return PageDownloadResult.failure(url, e.getMessage());
}
}
public void close() throws IOException {
httpClient.close();
}
}
public class LinkParser {
private final LinkFilter filter;
public LinkParser(LinkFilter filter) {
this.filter = filter;
}
public Set<String> extractLinks(String baseUrl, String html) {
Set<String> links = new HashSet<>();
Document doc = Jsoup.parse(html, baseUrl);
//
for (Element a : doc.select("a[href]")) {
String href = a.attr("abs:href"); // 自動轉絕對路徑
if (filter.accept(href)) links.add(href);
}
// /
@Slf4j
public class WebCrawler {
private final LinkQueue queue;
private final PageDownloader downloader;
private final LinkParser parser;
private final PageProcessor processor; // 業務處理:存儲/索引/分析
private final int maxPages;
private final AtomicInteger successCount = new AtomicInteger();
private final AtomicInteger failCount = new AtomicInteger();
public WebCrawler(String seedUrl, int maxPages, PageProcessor processor) {
this.queue = new LinkQueue();
this.downloader = new PageDownloader();
this.parser = new LinkParser(LinkFilter.defaultFilter(extractDomain(seedUrl)));
this.processor = processor;
this.maxPages = maxPages;
queue.addUnvisited(seedUrl);
}
public CrawlReport start() {
log.info("爬蟲啟動,種子: {}, 最大頁數: {}", queue, maxPages);
long startTime = System.currentTimeMillis();
while (!queue.isUnvisitedEmpty() && queue.getVisitedCount() < maxPages) {
String url = queue.pollUnvisited();
if (url == null) continue;
PageDownloadResult result = downloader.download(url);
if (!result.isSuccess()) {
failCount.incrementAndGet();
log.debug("跳過失敗頁面: {} - {}", url, result.getError());
continue;
}
queue.addVisited(url);
processor.process(url, result.getHtml(), result.getContentType());
successCount.incrementAndGet();
// 解析新鏈接入隊
Set<String> newLinks = parser.extractLinks(url, result.getHtml());
for (String link : newLinks) {
queue.addUnvisited(link);
}
// 禮貌延遲,避免對目標站點造成壓力
sleepQuietly(100, 500);
}
try { downloader.close(); } catch (IOException ignored) {}
return new CrawlReport(successCount.get(), failCount.get(),
System.currentTimeMillis() - startTime, queue.getVisitedCount());
}
private String extractDomain(String url) {
try { return new URI(url).getHost(); }
catch (URISyntaxException e) { return ""; }
}
private void sleepQuietly(long minMs, long maxMs) {
try { Thread.sleep(ThreadLocalRandom.current().nextLong(minMs, maxMs)); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
User-agent: * 規則,配合 Crawl-Delay。Last-Modified/ETag,配合 If-Modified-Since/If-None-Match 請求頭,僅抓取變更頁面。一個健壯的 Java 爬蟲核心在於:**有界隊列 + 高效去重 + 容錯下載 + 靈活解析 + 禮貌調度**。本文提供的組件化架構,可直接作為生產項目骨架,按需替換下載器、解析器、存儲層。切記:爬蟲的本質是「禮貌地獲取公開信息」,合規合法是第一原則。