001/* 002 * Copyright (c) 2023-2026, Agents-Flex (fuhai999@gmail.com). 003 * <p> 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * <p> 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * <p> 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package com.agentsflex.core.file2text.source; 017 018 019import com.agentsflex.core.file2text.util.IOUtils; 020 021import java.io.*; 022import java.net.HttpURLConnection; 023import java.net.URL; 024import java.nio.file.Paths; 025import java.util.concurrent.atomic.AtomicBoolean; 026import java.util.regex.Matcher; 027import java.util.regex.Pattern; 028 029/** 030 * 从 HTTP/HTTPS URL 读取文档的输入源,支持缓存(避免重复请求) 031 * 自动判断使用内存缓存还是临时文件缓存 032 */ 033public class HttpDocumentSource implements DocumentSource { 034 private static final int DEFAULT_CONNECT_TIMEOUT = 20_000; 035 private static final int DEFAULT_READ_TIMEOUT = 60_000; 036 private static final long MEMORY_THRESHOLD = 10 * 1024 * 1024; // 10MB 以内走内存 037 038 private final String url; 039 private final String providedFileName; 040 private final String mimeType; 041 private final int connectTimeout; 042 private final int readTimeout; 043 private final java.util.function.Consumer<HttpURLConnection> connectionCustomizer; 044 045 private volatile byte[] cachedBytes = null; 046 private volatile File tempFile = null; 047 private volatile String resolvedFileName = null; 048 private volatile String resolvedMimeType = null; 049 private final AtomicBoolean downloaded = new AtomicBoolean(false); 050 051 public HttpDocumentSource(String url) { 052 this(url, null, null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); 053 } 054 055 public HttpDocumentSource(String url, String fileName) { 056 this(url, fileName, null, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); 057 } 058 059 public HttpDocumentSource(String url, String fileName, String mimeType) { 060 this(url, fileName, mimeType, DEFAULT_CONNECT_TIMEOUT, DEFAULT_READ_TIMEOUT, null); 061 } 062 063 public HttpDocumentSource( 064 String url, 065 String fileName, 066 String mimeType, 067 int connectTimeout, 068 int readTimeout, 069 java.util.function.Consumer<HttpURLConnection> connectionCustomizer 070 ) { 071 this.url = validateUrl(url); 072 this.providedFileName = fileName; 073 this.mimeType = mimeType; 074 this.connectTimeout = connectTimeout; 075 this.readTimeout = readTimeout; 076 this.connectionCustomizer = connectionCustomizer; 077 } 078 079 private String validateUrl(String url) { 080 try { 081 new URL(url).toURI(); 082 return url; 083 } catch (Exception e) { 084 throw new RuntimeException("Invalid URL: " + url); 085 } 086 } 087 088 @Override 089 public String getFileName() { 090 if (resolvedFileName != null) { 091 return resolvedFileName; 092 } 093 synchronized (this) { 094 if (resolvedFileName == null) { 095 resolvedFileName = detectFileName(); 096 } 097 } 098 return resolvedFileName; 099 } 100 101 private String detectFileName() { 102 // 1. 用户提供 103 if (providedFileName != null && !providedFileName.trim().isEmpty()) { 104 return sanitizeFileName(providedFileName); 105 } 106 107 // 2. 从 URL 路径提取 108 String fromUrl = extractFileNameFromUrl(); 109 if (fromUrl != null) return fromUrl; 110 111 // 3. 从 Content-Disposition 提取(需要连接) 112 try { 113 HttpURLConnection conn = createConnection(); 114 conn.setRequestMethod("HEAD"); // 只获取头 115 conn.connect(); 116 String fromHeader = extractFileNameFromHeader(conn); 117 conn.disconnect(); 118 if (fromHeader != null) return fromHeader; 119 } catch (IOException e) { 120 // 忽略 121 } 122 123 return "downloaded-file"; 124 } 125 126 private String extractFileNameFromUrl() { 127 try { 128 URL urlObj = new URL(this.url); 129 String path = urlObj.getPath(); 130 if (path != null && path.length() > 1) { 131 String name = Paths.get(path).getFileName().toString(); 132 if (name.contains(".")) { 133 return sanitizeFileName(name); 134 } 135 } 136 } catch (Exception e) { 137 // 忽略 138 } 139 return null; 140 } 141 142 private String extractFileNameFromHeader(HttpURLConnection conn) { 143 try { 144 String header = conn.getHeaderField("Content-Disposition"); 145 if (header != null) { 146 Pattern pattern = Pattern.compile("filename\\s*=\\s*\"?([^\";]+)\"?"); 147 Matcher matcher = pattern.matcher(header); 148 if (matcher.find()) { 149 return sanitizeFileName(matcher.group(1)); 150 } 151 } 152 } catch (Exception e) { 153 // 忽略 154 } 155 return null; 156 } 157 158 public static String sanitizeFileName(String filename) { 159 if (filename == null) return "unknown"; 160 return filename 161 .replaceAll("[\\\\/:*?\"<>|]", "_") 162 .replaceAll("\\.\\.", "_") 163 .replaceAll("^\\s+|\\s+$", "") 164 .isEmpty() ? "file" : filename; 165 } 166 167 @Override 168 public String getMimeType() { 169 if (resolvedMimeType != null) { 170 return resolvedMimeType; 171 } 172 synchronized (this) { 173 if (resolvedMimeType == null) { 174 try { 175 HttpURLConnection conn = createConnection(); 176 conn.setRequestMethod("HEAD"); 177 conn.connect(); 178 resolvedMimeType = conn.getContentType(); 179 conn.disconnect(); 180 } catch (IOException e) { 181 resolvedMimeType = mimeType; // fallback 182 } 183 if (resolvedMimeType == null) { 184 resolvedMimeType = mimeType; 185 } 186 } 187 } 188 return resolvedMimeType; 189 } 190 191 @Override 192 public InputStream openStream() throws IOException { 193 downloadIfNeeded(); 194 if (cachedBytes != null) { 195 return new ByteArrayInputStream(cachedBytes); 196 } else if (tempFile != null) { 197 return new FileInputStream(tempFile); 198 } else { 199 throw new IOException("No content available"); 200 } 201 } 202 203 /** 204 * 下载一次,缓存结果 205 */ 206 private void downloadIfNeeded() throws IOException { 207 if (downloaded.get()) return; 208 209 synchronized (this) { 210 if (downloaded.get()) return; 211 212 HttpURLConnection conn = createConnection(); 213 conn.connect(); 214 215 try { 216 int code = conn.getResponseCode(); 217 if (code >= 400) { 218 throw new IOException("HTTP " + code + " from " + url); 219 } 220 221 // 判断是否走内存 or 临时文件 222 long contentLength = conn.getContentLengthLong(); 223 boolean useMemory = contentLength > 0 && contentLength <= MEMORY_THRESHOLD; 224 225 if (useMemory) { 226 // 内存缓存 227 this.cachedBytes = IOUtils.toByteArray(conn.getInputStream(), MEMORY_THRESHOLD); 228 } else { 229 // 临时文件缓存 230 this.tempFile = File.createTempFile("http-", ".cache"); 231 this.tempFile.deleteOnExit(); 232 try (FileOutputStream fos = new FileOutputStream(tempFile)) { 233 IOUtils.copyStream(conn.getInputStream(), fos, Long.MAX_VALUE); 234 } 235 } 236 237 // 更新 MIME(如果未指定) 238 if (this.resolvedMimeType == null) { 239 this.resolvedMimeType = conn.getContentType(); 240 } 241 242 } finally { 243 conn.disconnect(); 244 } 245 246 downloaded.set(true); 247 } 248 } 249 250 private HttpURLConnection createConnection() throws IOException { 251 URL urlObj = new URL(this.url); 252 HttpURLConnection conn = (HttpURLConnection) urlObj.openConnection(); 253 conn.setConnectTimeout(connectTimeout); 254 conn.setReadTimeout(readTimeout); 255 conn.setInstanceFollowRedirects(true); 256 conn.setRequestMethod("GET"); 257 conn.setRequestProperty("User-Agent", "DocumentParser/1.0"); 258 if (connectionCustomizer != null) { 259 connectionCustomizer.accept(conn); 260 } 261 return conn; 262 } 263 264 /** 265 * 获取缓存大小(用于调试) 266 */ 267 public long getCachedSize() { 268 if (cachedBytes != null) return cachedBytes.length; 269 if (tempFile != null) return tempFile.length(); 270 return 0; 271 } 272 273 /** 274 * 清理临时文件 275 */ 276 public void cleanup() { 277 if (tempFile != null && tempFile.exists()) { 278 tempFile.delete(); 279 } 280 } 281}